@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 +29 -11
- package/dist/index.js +6 -1
- package/dist/lib/mnemonic.d.ts +27 -0
- package/dist/lib/mnemonic.js +131 -0
- package/dist/lib/output.d.ts +1 -5
- package/dist/lib/output.js +18 -1
- package/dist/lib/passphrase.js +14 -5
- package/dist/lib/update-check.d.ts +2 -0
- package/dist/lib/update-check.js +67 -0
- package/dist/lib/wallet.d.ts +19 -0
- package/dist/lib/wallet.js +87 -2
- package/dist/tools/pitch-now.js +27 -5
- package/dist/tools/restore.d.ts +1 -5
- package/dist/tools/restore.js +49 -20
- package/dist/tools/vote-now.js +48 -22
- package/package.json +2 -4
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
}
|
package/dist/lib/output.d.ts
CHANGED
|
@@ -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';
|
package/dist/lib/output.js
CHANGED
|
@@ -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
|
|
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 ───────────────────────────
|
package/dist/lib/passphrase.js
CHANGED
|
@@ -6,15 +6,24 @@ function fromEnv() {
|
|
|
6
6
|
return null;
|
|
7
7
|
}
|
|
8
8
|
function macosPrompt(prompt, title) {
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
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], {
|
|
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
|
-
|
|
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,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
|
+
}
|
package/dist/lib/wallet.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/wallet.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 };
|
package/dist/tools/pitch-now.js
CHANGED
|
@@ -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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
package/dist/tools/restore.d.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
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";
|
package/dist/tools/restore.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
return reply(headline(`${Badge.
|
|
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(
|
|
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
|
}
|
package/dist/tools/vote-now.js
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
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
|
},
|