@pnlmarket/mcp-server 0.4.2 → 0.5.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 CHANGED
@@ -68,11 +68,29 @@ it comes from `PNL_PASSPHRASE` (set in your MCP host config) or an
68
68
  OS-native dialog. Cached secrets time out automatically and on
69
69
  `pnl_lock`.
70
70
 
71
- Autosign is bounded by the **autosign cap** (defaults 0.05 SOL),
72
- configurable via `pnl_unlock`'s output or by editing
73
- `~/.config/pnl/config.json`. Per-call override available via
74
- `autosignCapSol`. Anything above the cap requires the deep-link
75
- flow by design.
71
+ Autosign is bounded by **two caps**:
72
+
73
+ - **Per-transaction cap** (default 0.05 SOL) — the per-tx ceiling.
74
+ Editable in `~/.config/pnl/config.json` as `autosignCapSol`. The
75
+ per-call `autosignCapSol` arg can only **lower** this ceiling for
76
+ one call, never raise it.
77
+ - **Per-day cap** (default 0.5 SOL, since v0.5.0) — the rolling
78
+ daily limit. Editable as `dailyAutosignCapSol`. Spent total is
79
+ persisted at `~/.config/pnl/spent.json` and resets at UTC
80
+ midnight. This blocks the "chain N sub-per-tx-cap calls in a
81
+ loop" drain pattern.
82
+
83
+ To raise either cap the user edits the config file directly — no
84
+ tool argument can bypass them. Anything above either cap requires
85
+ the deep-link flow.
86
+
87
+ The mnemonic and the passphrase **never enter chat or tool
88
+ arguments** — both flow through OS-native dialogs (osascript on
89
+ macOS, zenity on Linux). `pnl_restore` takes no arguments; it pops
90
+ the dialog and reads the phrase from the OS directly. If a wallet
91
+ already exists, a second OS-native confirmation dialog must be
92
+ clicked before overwrite — agent prompt-injection cannot synthesize
93
+ that click.
76
94
 
77
95
  PNL's non-custodial framing in the
78
96
  [regulatory posture](https://docs.pnl.market/docs/transparency/regulatory-posture)
@@ -83,15 +101,13 @@ controls on their own machine.
83
101
 
84
102
  ### One-shot installer (recommended)
85
103
 
86
- After `pnpm install && pnpm -F @pnlmarket/mcp-server build`:
87
-
88
104
  ```bash
89
- npx @pnlmarket/mcp-server install --write
105
+ npx -y @pnlmarket/mcp-server install --write
90
106
  ```
91
107
 
92
108
  That wires the MCP server into every supported host config it
93
- finds on the machine (Claude Code, Cursor, Cline, Codex, Windsurf)
94
- and copies the 13 slash-command skills into
109
+ finds on the machine (Claude Code, Cursor, Cline, Codex, Windsurf,
110
+ Claude Desktop) and copies the 16 slash-command skills into
95
111
  `~/.claude/skills/`.
96
112
 
97
113
  Run without `--write` first to see the plan. Flags:
@@ -137,6 +153,7 @@ to call.
137
153
  | `PNL_API_BASE_URL` | `https://pnl.market` | Pointing at devnet, staging, or a local Next.js dev server |
138
154
  | `PNL_RPC_URL` | `https://pnl.market/api/mcp/rpc` (hosted proxy) | BYO Helius key (recommended for heavy use — grab one at [helius.dev](https://helius.dev) and set this to your endpoint) |
139
155
  | `PNL_PASSPHRASE` | (prompt via OS dialog) | Skip the OS-native unlock prompt — useful when running an agent unattended, but only set this in your MCP host config, never in shell history |
156
+ | `PNL_MNEMONIC` | (prompt via OS dialog) | One-shot fallback for `pnl_restore` when the OS dialog isn't usable (Windows, headless CI, container). Same warning — only set in MCP host config, never in shell history, and unset it after restore completes |
140
157
 
141
158
  The default RPC is the hosted proxy. It works zero-setup; under
142
159
  load it's rate-limited to 60 reads/min and 10 sends/min per IP.
@@ -172,7 +189,8 @@ Same shape — `pnl_vote_now` if within cap, `pnl_vote` if not.
172
189
  ```
173
190
  ~/.config/pnl/
174
191
  ├── wallet.enc # encrypted secret + metadata (mode 0600)
175
- ├── config.json # autosign cap + RPC URL (mode 0644)
192
+ ├── config.json # autosign caps + RPC URL (mode 0644)
193
+ ├── spent.json # daily autosign spend tracker, resets UTC (mode 0600)
176
194
  └── exports/ # timestamped backup dumps (mode 0700)
177
195
  ```
178
196
 
package/dist/index.js CHANGED
@@ -29,9 +29,10 @@ import { voteNowInputSchema, callVoteNow } from './tools/vote-now.js';
29
29
  import { claimInputSchema, callClaim } from './tools/claim.js';
30
30
  import { claimNowInputSchema, callClaimNow } from './tools/claim-now.js';
31
31
  import { notifyInputSchema, callNotify } from './tools/notify.js';
32
+ import { startUpdateCheck } from './lib/update-check.js';
32
33
  import { runInstall } from './install.js';
33
34
  const SERVER_NAME = 'pnl-mcp-server';
34
- const SERVER_VERSION = '0.4.0';
35
+ const SERVER_VERSION = '0.5.0';
35
36
  // CLI dispatch — when invoked as `pnl-mcp-server install`, run the
36
37
  // installer that wires this server into the user's agent configs and
37
38
  // drops the slash-command skills into ~/.claude/skills/. Otherwise
@@ -69,6 +70,10 @@ async function main() {
69
70
  server.tool('pnl_set_username', "Claim or rename the PNL username for the local wallet. Signs a time-bounded challenge with the keypair from pnl_init so the backend can verify wallet ownership -- no Privy session or Gmail login required. Usernames are 3-20 characters of letters/numbers/_/-. Returns 'taken' if another wallet has claimed the name. Use when the user says 'set my PNL username to X', 'rename my PNL profile', or after pnl_init when they want a custom name instead of the auto-generated Cosmic one.", setUsernameInputSchema, async (args) => callSetUsername(args));
70
71
  const transport = new StdioServerTransport();
71
72
  await server.connect(transport);
73
+ // Fire-and-forget npm registry check. If a newer version is published,
74
+ // a banner gets attached to the first tool reply this session. Never
75
+ // blocks startup, never throws — see lib/update-check.ts.
76
+ startUpdateCheck(SERVER_VERSION);
72
77
  // Stdio transport keeps the process alive while the host (Claude Code /
73
78
  // Cursor / etc.) holds the pipe. No need to log anything to stdout —
74
79
  // stdout is the MCP message channel. stderr is fine for diagnostics.
@@ -0,0 +1,27 @@
1
+ export interface MnemonicPromptOptions {
2
+ /** Title shown on the dialog */
3
+ title?: string;
4
+ /** Prompt text inside the dialog */
5
+ prompt?: string;
6
+ }
7
+ /**
8
+ * Read the BIP39 mnemonic from the user via OS-native dialog (or
9
+ * PNL_MNEMONIC env). Throws if no path is available.
10
+ *
11
+ * Does NOT validate — caller should pass the result through
12
+ * `isValidMnemonic` from wallet.ts (which normalises whitespace and
13
+ * checks the BIP39 wordlist).
14
+ */
15
+ export declare function promptMnemonic(opts?: MnemonicPromptOptions): string;
16
+ /**
17
+ * Ask the user via OS-native dialog whether they really want to
18
+ * overwrite an existing wallet. Returns false on Cancel, on any
19
+ * dialog error, or on unsupported platforms (fail-closed).
20
+ *
21
+ * Note: an env-var bypass is intentionally NOT provided here. The
22
+ * point of this dialog is to defeat prompt-injection attacks where
23
+ * the agent passes `allowOverwrite: true` based on instructions
24
+ * smuggled in via market metadata. If an env-var existed, the agent
25
+ * could set it.
26
+ */
27
+ export declare function confirmOverwrite(oldAddress: string, newAddress: string): boolean;
@@ -0,0 +1,131 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ function fromEnv() {
3
+ const raw = process.env.PNL_MNEMONIC;
4
+ if (raw && raw.length > 0)
5
+ return raw;
6
+ return null;
7
+ }
8
+ function macosMnemonicPrompt(prompt, title) {
9
+ // Same env-var trick as passphrase.ts — values never go through
10
+ // AppleScript string interpolation. Hidden answer keeps the seed off
11
+ // the screen (shoulder-surfing defense).
12
+ const script = `set p to system attribute "PNL_DIALOG_PROMPT"
13
+ set t to system attribute "PNL_DIALOG_TITLE"
14
+ display dialog p default answer "" with hidden answer with title t
15
+ return text returned of result`;
16
+ const out = execFileSync('osascript', ['-e', script], {
17
+ encoding: 'utf8',
18
+ env: { ...process.env, PNL_DIALOG_PROMPT: prompt, PNL_DIALOG_TITLE: title },
19
+ });
20
+ return out.trim();
21
+ }
22
+ function linuxMnemonicPrompt(prompt, title) {
23
+ // zenity --password uses hidden input; perfect for a seed phrase.
24
+ const out = execFileSync('zenity', ['--password', '--title', title, '--text', prompt], { encoding: 'utf8' });
25
+ return out.trim();
26
+ }
27
+ /**
28
+ * Read the BIP39 mnemonic from the user via OS-native dialog (or
29
+ * PNL_MNEMONIC env). Throws if no path is available.
30
+ *
31
+ * Does NOT validate — caller should pass the result through
32
+ * `isValidMnemonic` from wallet.ts (which normalises whitespace and
33
+ * checks the BIP39 wordlist).
34
+ */
35
+ export function promptMnemonic(opts = {}) {
36
+ const envValue = fromEnv();
37
+ if (envValue)
38
+ return envValue;
39
+ const title = opts.title || 'PNL Wallet — Restore';
40
+ const prompt = opts.prompt ||
41
+ 'Enter your 12 or 24 word recovery phrase (words separated by spaces):';
42
+ try {
43
+ if (process.platform === 'darwin') {
44
+ return macosMnemonicPrompt(prompt, title);
45
+ }
46
+ if (process.platform === 'linux') {
47
+ return linuxMnemonicPrompt(prompt, title);
48
+ }
49
+ }
50
+ catch (e) {
51
+ throw new Error(`Couldn't open the native mnemonic dialog (${e instanceof Error ? e.message : String(e)}). Set the PNL_MNEMONIC env var in your Claude Code mcp config (or as a one-shot env on the MCP launch) and try again — see apps/mcp/README.md.`);
52
+ }
53
+ throw new Error(`Native mnemonic dialog isn't supported on ${process.platform} yet. Set the PNL_MNEMONIC env var in your Claude Code mcp config and retry.`);
54
+ }
55
+ // ─── Overwrite-confirm dialog ────────────────────────────────────
56
+ //
57
+ // Restore over an existing wallet is loss-of-funds-irreversible: the
58
+ // old encrypted keystore is replaced with the new one, so any SOL on
59
+ // the previous address becomes inaccessible until the user separately
60
+ // restores the OLD mnemonic. We require a YES/NO OS dialog showing
61
+ // both addresses + the prompt to confirm — the agent can't synthesize
62
+ // a click, so this can't be defeated by prompt injection.
63
+ //
64
+ // Returns true if the user confirmed, false if cancelled.
65
+ function macosConfirmOverwrite(oldAddress, newAddress) {
66
+ const message = `You are about to REPLACE your PNL wallet.\n\n` +
67
+ `OLD address (will become inaccessible unless you have its mnemonic backed up):\n ${oldAddress}\n\n` +
68
+ `NEW address (derived from the recovery phrase you just entered):\n ${newAddress}\n\n` +
69
+ `This cannot be undone. Continue?`;
70
+ const script = `set m to system attribute "PNL_DIALOG_MESSAGE"
71
+ set t to system attribute "PNL_DIALOG_TITLE"
72
+ display dialog m buttons {"Cancel", "Replace wallet"} default button "Cancel" with title t with icon caution
73
+ return button returned of result`;
74
+ try {
75
+ const out = execFileSync('osascript', ['-e', script], {
76
+ encoding: 'utf8',
77
+ env: {
78
+ ...process.env,
79
+ PNL_DIALOG_MESSAGE: message,
80
+ PNL_DIALOG_TITLE: 'PNL Wallet — Confirm Replace',
81
+ },
82
+ });
83
+ return out.trim() === 'Replace wallet';
84
+ }
85
+ catch {
86
+ // osascript exits non-zero when the user clicks Cancel — treat as
87
+ // declined rather than as an error.
88
+ return false;
89
+ }
90
+ }
91
+ function linuxConfirmOverwrite(oldAddress, newAddress) {
92
+ const text = `You are about to REPLACE your PNL wallet.\n\n` +
93
+ `OLD address (will become inaccessible unless backed up):\n ${oldAddress}\n\n` +
94
+ `NEW address (from the recovery phrase you entered):\n ${newAddress}\n\n` +
95
+ `This cannot be undone. Continue?`;
96
+ try {
97
+ execFileSync('zenity', [
98
+ '--question',
99
+ '--title',
100
+ 'PNL Wallet — Confirm Replace',
101
+ '--text',
102
+ text,
103
+ '--ok-label',
104
+ 'Replace wallet',
105
+ '--cancel-label',
106
+ 'Cancel',
107
+ ], { encoding: 'utf8' });
108
+ return true;
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
114
+ /**
115
+ * Ask the user via OS-native dialog whether they really want to
116
+ * overwrite an existing wallet. Returns false on Cancel, on any
117
+ * dialog error, or on unsupported platforms (fail-closed).
118
+ *
119
+ * Note: an env-var bypass is intentionally NOT provided here. The
120
+ * point of this dialog is to defeat prompt-injection attacks where
121
+ * the agent passes `allowOverwrite: true` based on instructions
122
+ * smuggled in via market metadata. If an env-var existed, the agent
123
+ * could set it.
124
+ */
125
+ export function confirmOverwrite(oldAddress, newAddress) {
126
+ if (process.platform === 'darwin')
127
+ return macosConfirmOverwrite(oldAddress, newAddress);
128
+ if (process.platform === 'linux')
129
+ return linuxConfirmOverwrite(oldAddress, newAddress);
130
+ return false;
131
+ }
@@ -69,11 +69,7 @@ export declare const hr = "---";
69
69
  * recognize the pattern.
70
70
  */
71
71
  export declare function next(hint: string): string;
72
- /**
73
- * Wrap a tool result in the MCP content-block shape so callers
74
- * don't have to repeat the boilerplate. Joins parts with two
75
- * newlines so each section gets a paragraph break.
76
- */
72
+ export declare function setPendingBanner(text: string): void;
77
73
  export declare function reply(...parts: Array<string | null | undefined | false>): {
78
74
  content: Array<{
79
75
  type: 'text';
@@ -146,10 +146,27 @@ export function next(hint) {
146
146
  * don't have to repeat the boilerplate. Joins parts with two
147
147
  * newlines so each section gets a paragraph break.
148
148
  */
149
+ // ─── One-shot session banner ─────────────────────────────────────
150
+ //
151
+ // Used by update-check.ts to surface "new version available" notices
152
+ // inside the FIRST tool reply of a session. After it fires once, the
153
+ // banner is consumed and subsequent replies render normally — we
154
+ // don't want to spam the user across every call.
155
+ let pendingBanner = null;
156
+ export function setPendingBanner(text) {
157
+ pendingBanner = text;
158
+ }
159
+ function consumePendingBanner() {
160
+ const out = pendingBanner;
161
+ pendingBanner = null;
162
+ return out;
163
+ }
149
164
  export function reply(...parts) {
150
- const text = parts
165
+ const body = parts
151
166
  .filter((p) => typeof p === 'string' && p.length > 0)
152
167
  .join('\n\n');
168
+ const banner = consumePendingBanner();
169
+ const text = banner ? `${banner}\n\n---\n\n${body}` : body;
153
170
  return { content: [{ type: 'text', text }] };
154
171
  }
155
172
  // ─── Common domain-specific formatters ───────────────────────────
@@ -6,15 +6,24 @@ function fromEnv() {
6
6
  return null;
7
7
  }
8
8
  function macosPrompt(prompt, title) {
9
- // osascript escapes: we use a heredoc-style applescript and inject
10
- // strings as bash args (execFileSync arrays are safe from shell injection).
11
- const script = `display dialog "${prompt.replace(/"/g, '\\"')}" default answer "" with hidden answer with title "${title.replace(/"/g, '\\"')}"
9
+ // Pass prompt + title via env vars so they never go through AppleScript
10
+ // string interpolation. `system attribute` reads them at runtime no
11
+ // escape logic to get wrong, and no path for a future caller's user-
12
+ // controlled input to break out of the dialog literal.
13
+ const script = `set p to system attribute "PNL_DIALOG_PROMPT"
14
+ set t to system attribute "PNL_DIALOG_TITLE"
15
+ display dialog p default answer "" with hidden answer with title t
12
16
  return text returned of result`;
13
- const out = execFileSync('osascript', ['-e', script], { encoding: 'utf8' });
17
+ const out = execFileSync('osascript', ['-e', script], {
18
+ encoding: 'utf8',
19
+ env: { ...process.env, PNL_DIALOG_PROMPT: prompt, PNL_DIALOG_TITLE: title },
20
+ });
14
21
  return out.trim();
15
22
  }
16
23
  function linuxPrompt(prompt, _title) {
17
- const out = execFileSync('zenity', ['--password', '--title', _title], { encoding: 'utf8' });
24
+ // zenity args are passed via execFileSync's array form, which is already
25
+ // safe from shell-injection — no escape needed.
26
+ const out = execFileSync('zenity', ['--password', '--title', _title, '--text', prompt], { encoding: 'utf8' });
18
27
  return out.trim();
19
28
  }
20
29
  /**
@@ -0,0 +1,2 @@
1
+ /** Fire-and-forget startup check. Never throws, never blocks. */
2
+ export declare function startUpdateCheck(currentVersion: string): void;
@@ -0,0 +1,67 @@
1
+ import { setPendingBanner } from './output.js';
2
+ // ─── npm version drift check ─────────────────────────────────────
3
+ //
4
+ // The MCP runs as a long-lived stdio child of the user's agent
5
+ // (Claude Code / Cursor / Cline). Users have no nudge to upgrade —
6
+ // global npm packages don't auto-update. When we ship a security or
7
+ // behavioral fix (e.g., v0.5.0 moves the mnemonic out of the tool
8
+ // input schema), users on the older version stay vulnerable until
9
+ // they happen to reinstall.
10
+ //
11
+ // Fix: on startup, fire-and-forget a request to the npm registry's
12
+ // public metadata endpoint for our package. If the latest published
13
+ // version is newer than what we're running, queue a banner that the
14
+ // first tool reply prepends. The banner shows once per session, then
15
+ // clears.
16
+ //
17
+ // Privacy: registry metadata is anonymous (no auth required, no PII
18
+ // transmitted). Same trust model as `npm outdated`.
19
+ const PKG_NAME = '@pnlmarket/mcp-server';
20
+ const REGISTRY_URL = `https://registry.npmjs.org/${PKG_NAME}/latest`;
21
+ const FETCH_TIMEOUT_MS = 4_000;
22
+ /** Fire-and-forget startup check. Never throws, never blocks. */
23
+ export function startUpdateCheck(currentVersion) {
24
+ void runCheck(currentVersion);
25
+ }
26
+ async function runCheck(currentVersion) {
27
+ try {
28
+ const res = await fetch(REGISTRY_URL, {
29
+ headers: { Accept: 'application/json' },
30
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
31
+ });
32
+ if (!res.ok)
33
+ return;
34
+ const data = (await res.json());
35
+ const latest = typeof data.version === 'string' ? data.version : null;
36
+ if (!latest || latest === currentVersion)
37
+ return;
38
+ if (compareSemver(latest, currentVersion) <= 0)
39
+ return;
40
+ setPendingBanner(`⚠️ **pnl-mcp-server update available — ${currentVersion} → ${latest}**\n\n` +
41
+ `Run \`npm i -g @pnlmarket/mcp-server@latest\` (or \`pnpm add -g @pnlmarket/mcp-server@latest\`) and restart your agent. ` +
42
+ `Security and behavior fixes between versions are published in the [GitHub releases](https://github.com/aitankfish/pnl/releases).`);
43
+ }
44
+ catch {
45
+ // Offline, npm unreachable, timeout, malformed response — silently skip.
46
+ // Update notification is best-effort; it must never break the MCP.
47
+ }
48
+ }
49
+ /** Compare two semver strings. Returns positive if a > b, negative
50
+ * if a < b, zero if equal. Ignores pre-release tags (treats them as
51
+ * the base version) — good enough for "is there a newer stable?". */
52
+ function compareSemver(a, b) {
53
+ const parsePart = (s) => {
54
+ const base = s.split('-')[0]; // drop pre-release tag
55
+ return base.split('.').map((n) => parseInt(n, 10) || 0);
56
+ };
57
+ const pa = parsePart(a);
58
+ const pb = parsePart(b);
59
+ const len = Math.max(pa.length, pb.length);
60
+ for (let i = 0; i < len; i++) {
61
+ const x = pa[i] ?? 0;
62
+ const y = pb[i] ?? 0;
63
+ if (x !== y)
64
+ return x - y;
65
+ }
66
+ return 0;
67
+ }
@@ -4,6 +4,7 @@ import { Connection, Keypair, PublicKey } from '@solana/web3.js';
4
4
  export declare function isUsingHostedRpc(): boolean;
5
5
  export interface PnlConfig {
6
6
  autosignCapSol: number;
7
+ dailyAutosignCapSol: number;
7
8
  rpcUrl: string;
8
9
  }
9
10
  export declare function generateMnemonic(): string;
@@ -61,6 +62,24 @@ export declare function writeMnemonicToFile(mnemonic: string, address: string):
61
62
  path: string;
62
63
  };
63
64
  export declare function loadConfig(): PnlConfig;
65
+ /** Returns lamports spent so far today (UTC). */
66
+ export declare function getSpentTodayLamports(): number;
67
+ /**
68
+ * Reserve `lamports` against the daily autosign cap. Throws if the
69
+ * reservation would exceed the cap. On success, the spent counter is
70
+ * incremented immediately and persisted. Call `releaseSpend(lamports)`
71
+ * on tx failure to roll back.
72
+ *
73
+ * Synchronous + file-based → atomic within a Node tick. Safe under
74
+ * concurrent autosign calls from the same MCP process.
75
+ */
76
+ export declare function reserveSpend(lamports: number): {
77
+ totalAfterLamports: number;
78
+ capLamports: number;
79
+ };
80
+ /** Roll back a previous reservation. Use on tx-send failure. Never
81
+ * drops below 0. */
82
+ export declare function releaseSpend(lamports: number): void;
64
83
  export declare function saveConfig(updates: Partial<PnlConfig>): PnlConfig;
65
84
  export declare function getRpcUrl(): string;
66
85
  export declare function getConnection(): Connection;
@@ -27,6 +27,7 @@ import { randomBytes, scryptSync, createCipheriv, createDecipheriv, timingSafeEq
27
27
  const PNL_DIR = join(homedir(), '.config', 'pnl');
28
28
  const WALLET_PATH = join(PNL_DIR, 'wallet.enc');
29
29
  const CONFIG_PATH = join(PNL_DIR, 'config.json');
30
+ const SPENT_PATH = join(PNL_DIR, 'spent.json');
30
31
  const EXPORTS_DIR = join(PNL_DIR, 'exports');
31
32
  // Default RPC is the hosted MCP proxy on pnl.market, which forwards to
32
33
  // our paid Helius endpoint. The public Solana mainnet RPC is heavily
@@ -35,6 +36,12 @@ const EXPORTS_DIR = join(PNL_DIR, 'exports');
35
36
  // Power users override via PNL_RPC_URL.
36
37
  const DEFAULT_RPC = 'https://pnl.market/api/mcp/rpc';
37
38
  const DEFAULT_AUTOSIGN_CAP_SOL = 0.05;
39
+ // Daily ceiling on top of the per-tx cap. The per-tx cap alone is
40
+ // trivially bypassed by chaining N sub-cap signs (cap=0.05 × 100 calls =
41
+ // 5 SOL drained in a tight loop). The daily cap puts a hard floor on
42
+ // total damage from prompt-injection — to raise it the user has to edit
43
+ // ~/.config/pnl/config.json directly, same as the per-tx cap.
44
+ const DEFAULT_DAILY_AUTOSIGN_CAP_SOL = 0.5;
38
45
  /** True iff the currently active RPC URL is our hosted MCP proxy.
39
46
  * Tools use this to decide whether to surface the BYO-Helius hint. */
40
47
  export function isUsingHostedRpc() {
@@ -346,7 +353,11 @@ export function writeMnemonicToFile(mnemonic, address) {
346
353
  export function loadConfig() {
347
354
  ensureDir();
348
355
  if (!existsSync(CONFIG_PATH)) {
349
- return { autosignCapSol: DEFAULT_AUTOSIGN_CAP_SOL, rpcUrl: DEFAULT_RPC };
356
+ return {
357
+ autosignCapSol: DEFAULT_AUTOSIGN_CAP_SOL,
358
+ dailyAutosignCapSol: DEFAULT_DAILY_AUTOSIGN_CAP_SOL,
359
+ rpcUrl: DEFAULT_RPC,
360
+ };
350
361
  }
351
362
  try {
352
363
  const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
@@ -354,15 +365,89 @@ export function loadConfig() {
354
365
  autosignCapSol: typeof parsed.autosignCapSol === 'number' && parsed.autosignCapSol >= 0
355
366
  ? parsed.autosignCapSol
356
367
  : DEFAULT_AUTOSIGN_CAP_SOL,
368
+ dailyAutosignCapSol: typeof parsed.dailyAutosignCapSol === 'number' && parsed.dailyAutosignCapSol >= 0
369
+ ? parsed.dailyAutosignCapSol
370
+ : DEFAULT_DAILY_AUTOSIGN_CAP_SOL,
357
371
  rpcUrl: typeof parsed.rpcUrl === 'string' && parsed.rpcUrl.length > 0
358
372
  ? parsed.rpcUrl
359
373
  : DEFAULT_RPC,
360
374
  };
361
375
  }
362
376
  catch {
363
- return { autosignCapSol: DEFAULT_AUTOSIGN_CAP_SOL, rpcUrl: DEFAULT_RPC };
377
+ return {
378
+ autosignCapSol: DEFAULT_AUTOSIGN_CAP_SOL,
379
+ dailyAutosignCapSol: DEFAULT_DAILY_AUTOSIGN_CAP_SOL,
380
+ rpcUrl: DEFAULT_RPC,
381
+ };
382
+ }
383
+ }
384
+ function utcDateKey() {
385
+ const d = new Date();
386
+ const y = d.getUTCFullYear();
387
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0');
388
+ const day = String(d.getUTCDate()).padStart(2, '0');
389
+ return `${y}-${m}-${day}`;
390
+ }
391
+ function readSpentTodaySync() {
392
+ const today = utcDateKey();
393
+ if (!existsSync(SPENT_PATH))
394
+ return { date: today, spentLamports: 0 };
395
+ try {
396
+ const parsed = JSON.parse(readFileSync(SPENT_PATH, 'utf8'));
397
+ if (parsed.date !== today)
398
+ return { date: today, spentLamports: 0 };
399
+ const lamports = typeof parsed.spentLamports === 'number' && parsed.spentLamports >= 0
400
+ ? parsed.spentLamports
401
+ : 0;
402
+ return { date: today, spentLamports: lamports };
403
+ }
404
+ catch {
405
+ return { date: today, spentLamports: 0 };
406
+ }
407
+ }
408
+ function writeSpentSync(state) {
409
+ ensureDir();
410
+ writeFileSync(SPENT_PATH, JSON.stringify(state, null, 2), { mode: 0o600 });
411
+ try {
412
+ chmodSync(SPENT_PATH, 0o600);
413
+ }
414
+ catch {
415
+ /* non-fatal */
364
416
  }
365
417
  }
418
+ /** Returns lamports spent so far today (UTC). */
419
+ export function getSpentTodayLamports() {
420
+ return readSpentTodaySync().spentLamports;
421
+ }
422
+ /**
423
+ * Reserve `lamports` against the daily autosign cap. Throws if the
424
+ * reservation would exceed the cap. On success, the spent counter is
425
+ * incremented immediately and persisted. Call `releaseSpend(lamports)`
426
+ * on tx failure to roll back.
427
+ *
428
+ * Synchronous + file-based → atomic within a Node tick. Safe under
429
+ * concurrent autosign calls from the same MCP process.
430
+ */
431
+ export function reserveSpend(lamports) {
432
+ const { dailyAutosignCapSol } = loadConfig();
433
+ const capLamports = Math.floor(dailyAutosignCapSol * LAMPORTS_PER_SOL);
434
+ const state = readSpentTodaySync();
435
+ const totalAfter = state.spentLamports + lamports;
436
+ if (totalAfter > capLamports) {
437
+ const remainingSol = Math.max(0, capLamports - state.spentLamports) / LAMPORTS_PER_SOL;
438
+ throw new Error(`Daily autosign cap reached. ${(state.spentLamports / LAMPORTS_PER_SOL).toFixed(4)} SOL spent today out of ${dailyAutosignCapSol.toFixed(4)} SOL daily cap; only ${remainingSol.toFixed(4)} SOL remaining. ` +
439
+ `Use the browser deep-link flow (pnl_vote / pnl_pitch_idea) or raise dailyAutosignCapSol in ~/.config/pnl/config.json. Resets at UTC midnight.`);
440
+ }
441
+ writeSpentSync({ date: state.date, spentLamports: totalAfter });
442
+ return { totalAfterLamports: totalAfter, capLamports };
443
+ }
444
+ /** Roll back a previous reservation. Use on tx-send failure. Never
445
+ * drops below 0. */
446
+ export function releaseSpend(lamports) {
447
+ const state = readSpentTodaySync();
448
+ const next = Math.max(0, state.spentLamports - lamports);
449
+ writeSpentSync({ date: state.date, spentLamports: next });
450
+ }
366
451
  export function saveConfig(updates) {
367
452
  const current = loadConfig();
368
453
  const next = { ...current, ...updates };
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
3
- import { requireUnlockedKeypair, loadConfig, getConnection, getBalanceSol, } from '../lib/wallet.js';
3
+ import { requireUnlockedKeypair, loadConfig, getConnection, getBalanceSol, reserveSpend, releaseSpend, } from '../lib/wallet.js';
4
4
  import { signSerializedTx, sendAndConfirm, freshNonce, signChallenge, challenge, signedRequestHash, } from '../lib/sign.js';
5
5
  import { pitchIdeaInputSchema } from './pitch-idea.js';
6
6
  import { Badge, headline, code, kvTable, inline, next, reply, hr } from '../lib/output.js';
@@ -104,11 +104,33 @@ export async function callPitchNow(rawInput) {
104
104
  if (creationFeeSol > cap) {
105
105
  throw new Error(`create_market creation fee ${creationFeeSol.toFixed(4)} SOL exceeds autosign cap ${cap} SOL. Raise the cap with autosignCapSol arg or use pnl_pitch_idea for the deep-link flow.`);
106
106
  }
107
+ // Daily ceiling — see wallet.ts `reserveSpend`. Per-tx cap alone is
108
+ // bypassable by chaining sub-cap calls; this caps the rolling total.
109
+ // Reserved BEFORE sign; rolled back on send failure.
110
+ reserveSpend(built.creationFee);
111
+ let spendReleased = false;
112
+ const releaseOnFailure = () => {
113
+ if (!spendReleased) {
114
+ try {
115
+ releaseSpend(built.creationFee);
116
+ }
117
+ catch { /* best effort */ }
118
+ spendReleased = true;
119
+ }
120
+ };
107
121
  // 4. Sign locally and send.
108
- const rawTx = signSerializedTx(built.tx, keypair);
109
- const { signature: txSignature } = await sendAndConfirm(rawTx, getConnection(), {
110
- confirmTimeoutMs: 90_000,
111
- });
122
+ let txSignature;
123
+ try {
124
+ const rawTx = signSerializedTx(built.tx, keypair);
125
+ const sent = await sendAndConfirm(rawTx, getConnection(), {
126
+ confirmTimeoutMs: 90_000,
127
+ });
128
+ txSignature = sent.signature;
129
+ }
130
+ catch (err) {
131
+ releaseOnFailure();
132
+ throw err;
133
+ }
112
134
  // 5. Build the complete-create body first, then sign a challenge that
113
135
  // folds in a SHA-256 of the body. The hash binds the sig to the
114
136
  // exact payload — a captured sig cannot be replayed with a tampered
@@ -1,8 +1,4 @@
1
- import { z } from 'zod';
2
- export declare const restoreInputSchema: {
3
- readonly mnemonic: z.ZodString;
4
- readonly allowOverwrite: z.ZodOptional<z.ZodBoolean>;
5
- };
1
+ export declare const restoreInputSchema: {};
6
2
  export declare function callRestore(rawInput: unknown): Promise<{
7
3
  content: Array<{
8
4
  type: "text";
@@ -1,25 +1,56 @@
1
1
  import { z } from 'zod';
2
- import { hasWallet, restoreWallet, isValidMnemonic, unlockWith } from '../lib/wallet.js';
2
+ import { hasWallet, restoreWallet, isValidMnemonic, keypairFromMnemonic, unlockWith, getAddress, } from '../lib/wallet.js';
3
3
  import { promptPassphrase } from '../lib/passphrase.js';
4
+ import { promptMnemonic, confirmOverwrite } from '../lib/mnemonic.js';
4
5
  import { Badge, headline, next, reply, truncAddress, inline } from '../lib/output.js';
5
- export const restoreInputSchema = {
6
- mnemonic: z
7
- .string()
8
- .min(1)
9
- .describe('The 12 or 24 word BIP39 phrase from pnl_init. Standard recovery format Phantom / Solflare / Backpack / Solana CLI all accept.'),
10
- allowOverwrite: z
11
- .boolean()
12
- .optional()
13
- .describe('Set to true to replace an existing wallet on this machine. Default false — refuses if one exists so the user can back it up first with pnl_export_keypair.'),
14
- };
15
- const RestoreInput = z.object(restoreInputSchema);
6
+ // Tool input: intentionally NO `mnemonic` field. The seed phrase is the
7
+ // most sensitive secret in PNL — passing it as a tool argument would
8
+ // leak it into the agent's chat transcript (Claude Code history,
9
+ // Anthropic API logs, anywhere the conversation is exported). Instead,
10
+ // the user types it into an OS-native dialog (`promptMnemonic`), same
11
+ // pattern as the wallet passphrase.
12
+ //
13
+ // We also no longer accept `allowOverwrite` from the agent. Overwriting
14
+ // an existing wallet is irreversible; prompt-injection could trick the
15
+ // agent into passing `allowOverwrite: true` on attacker-controlled
16
+ // metadata. The user has to click "Replace wallet" in an OS dialog
17
+ // (`confirmOverwrite`) — something the agent cannot synthesize.
18
+ export const restoreInputSchema = {};
19
+ const RestoreInput = z.object(restoreInputSchema).strict();
16
20
  export async function callRestore(rawInput) {
17
- const { mnemonic, allowOverwrite } = RestoreInput.parse(rawInput ?? {});
18
- if (!isValidMnemonic(mnemonic.trim())) {
19
- return reply(headline(`${Badge.err} Not a valid BIP39 phrase.`), 'Check spelling, word count (must be 12 or 24), and that all words are from the BIP39 wordlist.', next('Re-run `/pnl-restore` with the correct phrase.'));
21
+ RestoreInput.parse(rawInput ?? {});
22
+ let mnemonic;
23
+ try {
24
+ mnemonic = promptMnemonic({
25
+ title: 'PNL Wallet — Restore',
26
+ prompt: 'Enter your 12 or 24 word recovery phrase (words separated by spaces):',
27
+ });
20
28
  }
21
- if (hasWallet() && !allowOverwrite) {
22
- return reply(headline(`${Badge.warn} A wallet already exists on this machine.`), `Refusing to overwrite. Back it up first with ${inline('pnl_export_keypair')}, then call \`pnl_restore\` again with \`allowOverwrite: true\`.`, next('`/pnl-export` to back up, then re-run `/pnl-restore`.'));
29
+ catch (e) {
30
+ return reply(headline(`${Badge.err} Couldn't read recovery phrase.`), e instanceof Error ? e.message : String(e));
31
+ }
32
+ const trimmed = mnemonic.trim();
33
+ if (!isValidMnemonic(trimmed)) {
34
+ return reply(headline(`${Badge.err} Not a valid BIP39 phrase.`), 'Check spelling, word count (must be 12 or 24), and that all words are from the BIP39 wordlist.', next('Re-run `/pnl-restore` and re-enter the phrase carefully.'));
35
+ }
36
+ // If a wallet already exists, surface an OS-native YES/NO dialog
37
+ // showing both addresses. Only proceed if the user clicks "Replace".
38
+ if (hasWallet()) {
39
+ const oldAddress = getAddress();
40
+ let newAddress;
41
+ try {
42
+ newAddress = keypairFromMnemonic(trimmed).publicKey.toBase58();
43
+ }
44
+ catch (e) {
45
+ return reply(headline(`${Badge.err} Couldn't derive address from phrase.`), e instanceof Error ? e.message : String(e));
46
+ }
47
+ if (oldAddress === newAddress) {
48
+ return reply(headline(`${Badge.warn} Recovery phrase matches the existing wallet.`), `Already restored to ${truncAddress(oldAddress)} — nothing to do.`, next(`${inline('/pnl-wallet')} to see balance.`));
49
+ }
50
+ const ok = confirmOverwrite(oldAddress, newAddress);
51
+ if (!ok) {
52
+ return reply(headline(`${Badge.warn} Replace cancelled.`), `Existing wallet ${truncAddress(oldAddress)} kept intact.`, `Back up the old wallet first with ${inline('pnl_export_keypair')}, then re-run ${inline('/pnl-restore')} if you really want to swap.`);
53
+ }
23
54
  }
24
55
  let passphrase;
25
56
  try {
@@ -33,9 +64,7 @@ export async function callRestore(rawInput) {
33
64
  return reply(headline(`${Badge.err} Couldn't read passphrase.`), e instanceof Error ? e.message : String(e));
34
65
  }
35
66
  try {
36
- const { address } = restoreWallet(mnemonic.trim(), passphrase, {
37
- allowOverwrite: !!allowOverwrite,
38
- });
67
+ const { address } = restoreWallet(trimmed, passphrase, { allowOverwrite: true });
39
68
  unlockWith(passphrase, 30);
40
69
  return reply(headline(`${Badge.ok} Restored · ${truncAddress(address)} · unlocked 30m`), `On-chain history is preserved — markets, votes, balances tied to this address are visible immediately.`, `Full address: \`${address}\``, next('`/pnl-wallet` to see balance, `/pnl-pitch` to post an idea.'));
41
70
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { PublicKey } from '@solana/web3.js';
3
- import { requireUnlockedKeypair, loadConfig, getConnection, getBalanceSol, } from '../lib/wallet.js';
2
+ import { PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
3
+ import { requireUnlockedKeypair, loadConfig, getConnection, getBalanceSol, reserveSpend, releaseSpend, } from '../lib/wallet.js';
4
4
  import { signSerializedTx, sendAndConfirm, freshNonce, signChallenge, challenge, signedRequestHash, } from '../lib/sign.js';
5
5
  import { getMarket } from '../lib/pnl-api.js';
6
6
  import { Badge, headline, code, kvTable, inline, next, reply, hr } from '../lib/output.js';
@@ -49,6 +49,22 @@ export async function callVoteNow(rawInput) {
49
49
  if (input.amountSol > cap) {
50
50
  throw new Error(`Stake ${input.amountSol} SOL exceeds autosign cap ${cap} SOL. Either raise the cap (autosignCapSol arg) or use pnl_vote for the browser deep-link flow.`);
51
51
  }
52
+ // Daily ceiling — see wallet.ts `reserveSpend`. Per-tx cap alone is
53
+ // trivially bypassed by chaining N sub-cap calls; this enforces a
54
+ // rolling total. Reserved BEFORE sign so racing autosign calls can't
55
+ // each pass the check on a stale read. Rolled back on tx failure.
56
+ const requiredLamports = Math.floor(input.amountSol * LAMPORTS_PER_SOL);
57
+ reserveSpend(requiredLamports);
58
+ let spendReleased = false;
59
+ const releaseOnFailure = () => {
60
+ if (!spendReleased) {
61
+ try {
62
+ releaseSpend(requiredLamports);
63
+ }
64
+ catch { /* best effort */ }
65
+ spendReleased = true;
66
+ }
67
+ };
52
68
  const keypair = requireUnlockedKeypair();
53
69
  const walletAddress = keypair.publicKey.toBase58();
54
70
  const base = getApiBase();
@@ -82,27 +98,37 @@ export async function callVoteNow(rawInput) {
82
98
  if (balance < required) {
83
99
  throw new Error(`Wallet balance ${balance.toFixed(4)} SOL is below the ${required.toFixed(4)} SOL needed (stake + fee buffer). Fund ${walletAddress} and try again.`);
84
100
  }
85
- // 1. build-vote-tx
86
- const buildRes = await fetch(`${base}/api/mcp/markets/build-vote-tx`, {
87
- method: 'POST',
88
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
89
- body: JSON.stringify({
90
- walletAddress,
91
- marketAddress,
92
- voteType: input.vote,
93
- amountSol: input.amountSol,
94
- }),
95
- });
96
- const buildJson = (await buildRes.json());
97
- if (!buildRes.ok || !buildJson.success || !buildJson.data) {
98
- throw new Error(`build-vote-tx failed (${buildRes.status}): ${buildJson.error || 'unknown error'}`);
101
+ let txSignature;
102
+ try {
103
+ // 1. build-vote-tx
104
+ const buildRes = await fetch(`${base}/api/mcp/markets/build-vote-tx`, {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
107
+ body: JSON.stringify({
108
+ walletAddress,
109
+ marketAddress,
110
+ voteType: input.vote,
111
+ amountSol: input.amountSol,
112
+ }),
113
+ });
114
+ const buildJson = (await buildRes.json());
115
+ if (!buildRes.ok || !buildJson.success || !buildJson.data) {
116
+ throw new Error(`build-vote-tx failed (${buildRes.status}): ${buildJson.error || 'unknown error'}`);
117
+ }
118
+ const built = buildJson.data;
119
+ // 2. sign + send locally
120
+ const rawTx = signSerializedTx(built.tx, keypair);
121
+ const sent = await sendAndConfirm(rawTx, getConnection(), {
122
+ confirmTimeoutMs: 90_000,
123
+ });
124
+ txSignature = sent.signature;
125
+ }
126
+ catch (err) {
127
+ // Build/sign/send failed → tx never landed → roll back the reservation
128
+ // so the user keeps their daily budget for the retry.
129
+ releaseOnFailure();
130
+ throw err;
99
131
  }
100
- const built = buildJson.data;
101
- // 2. sign + send locally
102
- const rawTx = signSerializedTx(built.tx, keypair);
103
- const { signature: txSignature } = await sendAndConfirm(rawTx, getConnection(), {
104
- confirmTimeoutMs: 90_000,
105
- });
106
132
  // 3. Build complete-vote body, hash it into the challenge, sign.
107
133
  // The hash binds the sig to voteType + amountSol + marketId so an
108
134
  // attacker who captures the sig within the nonce window cannot
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnlmarket/mcp-server",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Model Context Protocol server for PNL — let agents (Claude Code, Cursor, Cline, Codex) browse live conviction markets, pitch ideas as markets, and stake YES/NO without leaving the terminal. Local encrypted wallet, autosign for sub-cap amounts, deep-link for larger ones.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -44,9 +44,6 @@
44
44
  "publishConfig": {
45
45
  "access": "public"
46
46
  },
47
- "overrides": {
48
- "rpc-websockets": ">=9.3.10"
49
- },
50
47
  "engines": {
51
48
  "node": ">=18.18"
52
49
  },
@@ -56,6 +53,7 @@
56
53
  "bip39": "^3.1.0",
57
54
  "bs58": "^6.0.0",
58
55
  "ed25519-hd-key": "^1.3.0",
56
+ "rpc-websockets": "^9.3.10",
59
57
  "tweetnacl": "^1.0.3",
60
58
  "zod": "^3.23.8"
61
59
  },