@pnlmarket/mcp-server 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +216 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +83 -0
  5. package/dist/install.d.ts +1 -0
  6. package/dist/install.js +168 -0
  7. package/dist/lib/output.d.ts +95 -0
  8. package/dist/lib/output.js +175 -0
  9. package/dist/lib/passphrase.d.ts +16 -0
  10. package/dist/lib/passphrase.js +57 -0
  11. package/dist/lib/pnl-api.d.ts +65 -0
  12. package/dist/lib/pnl-api.js +89 -0
  13. package/dist/lib/sign.d.ts +40 -0
  14. package/dist/lib/sign.js +126 -0
  15. package/dist/lib/wallet.d.ts +74 -0
  16. package/dist/lib/wallet.js +405 -0
  17. package/dist/tools/browse-markets.d.ts +12 -0
  18. package/dist/tools/browse-markets.js +91 -0
  19. package/dist/tools/claim-now.d.ts +10 -0
  20. package/dist/tools/claim-now.js +113 -0
  21. package/dist/tools/claim.d.ts +10 -0
  22. package/dist/tools/claim.js +43 -0
  23. package/dist/tools/export-keypair.d.ts +10 -0
  24. package/dist/tools/export-keypair.js +25 -0
  25. package/dist/tools/get-market.d.ts +10 -0
  26. package/dist/tools/get-market.js +58 -0
  27. package/dist/tools/help.d.ts +7 -0
  28. package/dist/tools/help.js +54 -0
  29. package/dist/tools/init.d.ts +7 -0
  30. package/dist/tools/init.js +69 -0
  31. package/dist/tools/notify.d.ts +12 -0
  32. package/dist/tools/notify.js +150 -0
  33. package/dist/tools/pitch-idea.d.ts +38 -0
  34. package/dist/tools/pitch-idea.js +176 -0
  35. package/dist/tools/pitch-now.d.ts +39 -0
  36. package/dist/tools/pitch-now.js +179 -0
  37. package/dist/tools/restore.d.ts +11 -0
  38. package/dist/tools/restore.js +45 -0
  39. package/dist/tools/set-username.d.ts +10 -0
  40. package/dist/tools/set-username.js +87 -0
  41. package/dist/tools/unlock.d.ts +17 -0
  42. package/dist/tools/unlock.js +47 -0
  43. package/dist/tools/vote-now.d.ts +13 -0
  44. package/dist/tools/vote-now.js +146 -0
  45. package/dist/tools/vote.d.ts +12 -0
  46. package/dist/tools/vote.js +49 -0
  47. package/dist/tools/wallet.d.ts +7 -0
  48. package/dist/tools/wallet.js +40 -0
  49. package/package.json +64 -0
  50. package/skills/README.md +45 -0
  51. package/skills/pnl-browse/SKILL.md +30 -0
  52. package/skills/pnl-claim/SKILL.md +60 -0
  53. package/skills/pnl-claim-now/SKILL.md +67 -0
  54. package/skills/pnl-export/SKILL.md +39 -0
  55. package/skills/pnl-help/SKILL.md +17 -0
  56. package/skills/pnl-init/SKILL.md +24 -0
  57. package/skills/pnl-lock/SKILL.md +17 -0
  58. package/skills/pnl-name/SKILL.md +32 -0
  59. package/skills/pnl-notify/SKILL.md +57 -0
  60. package/skills/pnl-pitch/SKILL.md +83 -0
  61. package/skills/pnl-pitch-now/SKILL.md +88 -0
  62. package/skills/pnl-restore/SKILL.md +38 -0
  63. package/skills/pnl-unlock/SKILL.md +28 -0
  64. package/skills/pnl-vote/SKILL.md +48 -0
  65. package/skills/pnl-vote-now/SKILL.md +68 -0
  66. package/skills/pnl-wallet/SKILL.md +22 -0
@@ -0,0 +1,405 @@
1
+ import { Connection, Keypair, LAMPORTS_PER_SOL } from '@solana/web3.js';
2
+ import * as bip39 from 'bip39';
3
+ import { derivePath } from 'ed25519-hd-key';
4
+ import bs58 from 'bs58';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from 'node:fs';
8
+ import { randomBytes, scryptSync, createCipheriv, createDecipheriv, timingSafeEqual } from 'node:crypto';
9
+ // ─── PNL local wallet — encrypted at rest, BIP39-recoverable ─────
10
+ //
11
+ // The keypair is stored on disk as an encrypted blob. Decryption
12
+ // requires the user's passphrase, which is delivered to this process
13
+ // either via the PNL_PASSPHRASE env var (set in Claude Code's mcp
14
+ // config) or via an OS-native dialog (osascript on macOS, zenity on
15
+ // Linux). The agent's chat transcript NEVER sees the passphrase.
16
+ //
17
+ // File layout:
18
+ // ~/.config/pnl/wallet.enc - encrypted secret + metadata (mode 0600)
19
+ // ~/.config/pnl/config.json - autosign cap + RPC URL (mode 0644)
20
+ // ~/.config/pnl/exports/ - timestamped backup dumps (mode 0700)
21
+ //
22
+ // Recovery model: BIP39 12-word mnemonic, derived at Solana's
23
+ // Phantom-compatible path m/44'/501'/0'/0'. Mnemonic is shown ONCE
24
+ // during pnl_init and never stored on disk — the user is responsible
25
+ // for backing it up. With the mnemonic alone (no passphrase) the user
26
+ // can restore on any new machine via pnl_restore.
27
+ const PNL_DIR = join(homedir(), '.config', 'pnl');
28
+ const WALLET_PATH = join(PNL_DIR, 'wallet.enc');
29
+ const CONFIG_PATH = join(PNL_DIR, 'config.json');
30
+ const EXPORTS_DIR = join(PNL_DIR, 'exports');
31
+ // Default RPC is the hosted MCP proxy on pnl.market, which forwards to
32
+ // our paid Helius endpoint. The public Solana mainnet RPC is heavily
33
+ // rate-limited (429s during autosign send + confirmation polling) and
34
+ // is not a viable default for the autosign create_market / vote flows.
35
+ // Power users override via PNL_RPC_URL.
36
+ const DEFAULT_RPC = 'https://pnl.market/api/mcp/rpc';
37
+ const DEFAULT_AUTOSIGN_CAP_SOL = 0.05;
38
+ /** True iff the currently active RPC URL is our hosted MCP proxy.
39
+ * Tools use this to decide whether to surface the BYO-Helius hint. */
40
+ export function isUsingHostedRpc() {
41
+ return getRpcUrl() === DEFAULT_RPC && !process.env.PNL_RPC_URL?.trim();
42
+ }
43
+ // Solana / Phantom derivation path. Matches what `solana-keygen new`
44
+ // and Phantom's "Add account" flow use, so a mnemonic generated here
45
+ // imports cleanly into Phantom and vice versa.
46
+ const DERIVATION_PATH = "m/44'/501'/0'/0'";
47
+ // scrypt parameters. N=2^17 is the value used by Filecoin / standard
48
+ // "good" client setups — ~250MB memory, ~250ms on a modern CPU. Slow
49
+ // enough to resist brute force, fast enough that interactive unlock
50
+ // feels instant.
51
+ const SCRYPT_N = 1 << 17;
52
+ const SCRYPT_R = 8;
53
+ const SCRYPT_P = 1;
54
+ const SCRYPT_KEY_LEN = 32;
55
+ // AES-256-GCM nonce is 12 bytes per NIST recommendation.
56
+ const GCM_NONCE_LEN = 12;
57
+ const GCM_TAG_LEN = 16;
58
+ let unlocked = null;
59
+ function ensureDir() {
60
+ if (!existsSync(PNL_DIR)) {
61
+ mkdirSync(PNL_DIR, { recursive: true, mode: 0o700 });
62
+ }
63
+ }
64
+ function ensureExportsDir() {
65
+ ensureDir();
66
+ if (!existsSync(EXPORTS_DIR)) {
67
+ mkdirSync(EXPORTS_DIR, { recursive: true, mode: 0o700 });
68
+ }
69
+ }
70
+ // ─── Crypto primitives ───────────────────────────────────────────
71
+ function deriveKey(passphrase, salt) {
72
+ return scryptSync(Buffer.from(passphrase, 'utf8'), salt, SCRYPT_KEY_LEN, {
73
+ N: SCRYPT_N,
74
+ r: SCRYPT_R,
75
+ p: SCRYPT_P,
76
+ // scrypt's default maxmem is 32MB which is too small for N=2^17.
77
+ maxmem: 256 * 1024 * 1024,
78
+ });
79
+ }
80
+ function encryptSecret(secret, passphrase) {
81
+ const salt = randomBytes(16);
82
+ const nonce = randomBytes(GCM_NONCE_LEN);
83
+ const key = deriveKey(passphrase, salt);
84
+ const cipher = createCipheriv('aes-256-gcm', key, nonce);
85
+ const ciphertext = Buffer.concat([cipher.update(Buffer.from(secret)), cipher.final()]);
86
+ const tag = cipher.getAuthTag();
87
+ // Wipe the derived key from memory.
88
+ key.fill(0);
89
+ return {
90
+ salt: salt.toString('base64'),
91
+ nonce: nonce.toString('base64'),
92
+ ciphertext: ciphertext.toString('base64'),
93
+ tag: tag.toString('base64'),
94
+ };
95
+ }
96
+ function decryptSecret(blob, passphrase) {
97
+ const salt = Buffer.from(blob.salt, 'base64');
98
+ const nonce = Buffer.from(blob.nonce, 'base64');
99
+ const ciphertext = Buffer.from(blob.ciphertext, 'base64');
100
+ const tag = Buffer.from(blob.tag, 'base64');
101
+ // Param sanity — defends against a corrupted file that swaps params
102
+ // to bypass cost (the GCM auth tag would catch it too but bail early).
103
+ if (blob.scrypt.N !== SCRYPT_N ||
104
+ blob.scrypt.r !== SCRYPT_R ||
105
+ blob.scrypt.p !== SCRYPT_P ||
106
+ blob.scrypt.keyLen !== SCRYPT_KEY_LEN) {
107
+ throw new Error('wallet file has unexpected scrypt parameters — possibly tampered. Refusing to decrypt.');
108
+ }
109
+ const key = deriveKey(passphrase, salt);
110
+ const decipher = createDecipheriv('aes-256-gcm', key, nonce);
111
+ decipher.setAuthTag(tag);
112
+ try {
113
+ const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
114
+ key.fill(0);
115
+ return new Uint8Array(plain);
116
+ }
117
+ catch (e) {
118
+ key.fill(0);
119
+ // GCM auth-tag failure is the common case: wrong passphrase.
120
+ // Use a generic message so we don't leak whether the failure was
121
+ // tag mismatch vs something else (timing safety hygiene).
122
+ throw new Error('passphrase is incorrect (or the wallet file is corrupted)');
123
+ }
124
+ }
125
+ // ─── Mnemonic / keypair derivation ───────────────────────────────
126
+ export function generateMnemonic() {
127
+ // bip39 uses crypto.randomBytes under the hood. 128 bits of entropy
128
+ // → 12 words. Standard.
129
+ return bip39.generateMnemonic(128);
130
+ }
131
+ export function isValidMnemonic(mnemonic) {
132
+ return bip39.validateMnemonic(mnemonic);
133
+ }
134
+ /** Returns a Solana Keypair derived from the BIP39 mnemonic at the
135
+ * Phantom-compatible path m/44'/501'/0'/0'. */
136
+ export function keypairFromMnemonic(mnemonic) {
137
+ const cleaned = mnemonic.trim().split(/\s+/).join(' ').toLowerCase();
138
+ if (!bip39.validateMnemonic(cleaned)) {
139
+ throw new Error('mnemonic is not a valid BIP39 phrase — check spelling and word count (must be 12 or 24 words)');
140
+ }
141
+ const seed = bip39.mnemonicToSeedSync(cleaned); // 64 bytes
142
+ const { key } = derivePath(DERIVATION_PATH, seed.toString('hex'));
143
+ // ed25519-hd-key returns a 32-byte seed; Solana wants 64 bytes
144
+ // (seed || pubkey). Keypair.fromSeed handles that for us.
145
+ return Keypair.fromSeed(key);
146
+ }
147
+ // ─── State queries ───────────────────────────────────────────────
148
+ export function hasWallet() {
149
+ return existsSync(WALLET_PATH);
150
+ }
151
+ function readWalletFile() {
152
+ const raw = readFileSync(WALLET_PATH, 'utf8');
153
+ const parsed = JSON.parse(raw);
154
+ if (parsed.version !== 1) {
155
+ throw new Error(`wallet file version ${parsed.version} is not supported`);
156
+ }
157
+ return parsed;
158
+ }
159
+ export function getAddress() {
160
+ return readWalletFile().address;
161
+ }
162
+ // ─── Lock / unlock ───────────────────────────────────────────────
163
+ export function isUnlocked() {
164
+ if (!unlocked)
165
+ return false;
166
+ if (Date.now() > unlocked.unlockedUntil) {
167
+ lock();
168
+ return false;
169
+ }
170
+ return true;
171
+ }
172
+ export function unlockWith(passphrase, ttlMinutes = 5) {
173
+ if (!hasWallet()) {
174
+ throw new Error('No PNL wallet on this machine. Run pnl_init first.');
175
+ }
176
+ const blob = readWalletFile();
177
+ const secret = decryptSecret(blob, passphrase);
178
+ unlocked = {
179
+ secret,
180
+ unlockedAt: Date.now(),
181
+ unlockedUntil: Date.now() + ttlMinutes * 60 * 1000,
182
+ };
183
+ return { address: blob.address };
184
+ }
185
+ export function lock() {
186
+ if (unlocked) {
187
+ // Zero out the cached secret before releasing the reference.
188
+ unlocked.secret.fill(0);
189
+ unlocked = null;
190
+ }
191
+ }
192
+ /** Returns the in-memory Keypair if the wallet is currently unlocked.
193
+ * Throws with a "wallet locked" message otherwise. */
194
+ export function requireUnlockedKeypair() {
195
+ if (!isUnlocked() || !unlocked) {
196
+ throw new Error('Wallet is locked. Call pnl_unlock first — passphrase is read from your PNL_PASSPHRASE env var (set in Claude Code mcp config) or via an OS-native dialog. Never type it directly in chat.');
197
+ }
198
+ return Keypair.fromSecretKey(unlocked.secret);
199
+ }
200
+ export function unlockStatus() {
201
+ if (!isUnlocked() || !unlocked)
202
+ return { unlocked: false, secondsRemaining: 0 };
203
+ return {
204
+ unlocked: true,
205
+ secondsRemaining: Math.max(0, Math.floor((unlocked.unlockedUntil - Date.now()) / 1000)),
206
+ };
207
+ }
208
+ /** Generate a fresh BIP39 mnemonic + keypair, encrypt the secret with
209
+ * the user's passphrase, write to disk. Returns the mnemonic so the
210
+ * agent can display it once for the user to write down. */
211
+ export function createWallet(passphrase) {
212
+ if (hasWallet()) {
213
+ throw new Error(`A PNL wallet already exists at ${WALLET_PATH}. Use pnl_export_keypair to back it up before deleting and regenerating.`);
214
+ }
215
+ if (!passphrase || passphrase.length < 8) {
216
+ throw new Error('passphrase must be at least 8 characters');
217
+ }
218
+ ensureDir();
219
+ const mnemonic = generateMnemonic();
220
+ const keypair = keypairFromMnemonic(mnemonic);
221
+ const enc = encryptSecret(keypair.secretKey, passphrase);
222
+ const blob = {
223
+ version: 1,
224
+ address: keypair.publicKey.toBase58(),
225
+ kdf: 'scrypt',
226
+ scrypt: { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, keyLen: SCRYPT_KEY_LEN },
227
+ ...enc,
228
+ createdAt: new Date().toISOString(),
229
+ };
230
+ writeFileSync(WALLET_PATH, JSON.stringify(blob, null, 2), { mode: 0o600 });
231
+ try {
232
+ chmodSync(WALLET_PATH, 0o600);
233
+ }
234
+ catch {
235
+ /* non-fatal */
236
+ }
237
+ return { address: blob.address, mnemonic };
238
+ }
239
+ /** Restore a wallet from an existing BIP39 mnemonic. The user's
240
+ * passphrase encrypts the derived secret on disk. */
241
+ export function restoreWallet(mnemonic, passphrase, opts = {}) {
242
+ if (hasWallet() && !opts.allowOverwrite) {
243
+ throw new Error(`A PNL wallet already exists at ${WALLET_PATH}. Pass allowOverwrite: true if you really mean to replace it (back it up first with pnl_export_keypair).`);
244
+ }
245
+ if (!passphrase || passphrase.length < 8) {
246
+ throw new Error('passphrase must be at least 8 characters');
247
+ }
248
+ ensureDir();
249
+ const keypair = keypairFromMnemonic(mnemonic);
250
+ const enc = encryptSecret(keypair.secretKey, passphrase);
251
+ const blob = {
252
+ version: 1,
253
+ address: keypair.publicKey.toBase58(),
254
+ kdf: 'scrypt',
255
+ scrypt: { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, keyLen: SCRYPT_KEY_LEN },
256
+ ...enc,
257
+ createdAt: new Date().toISOString(),
258
+ };
259
+ writeFileSync(WALLET_PATH, JSON.stringify(blob, null, 2), { mode: 0o600 });
260
+ try {
261
+ chmodSync(WALLET_PATH, 0o600);
262
+ }
263
+ catch {
264
+ /* non-fatal */
265
+ }
266
+ lock(); // make user re-unlock with the new passphrase
267
+ return { address: blob.address };
268
+ }
269
+ // ─── Export to file (never to chat) ──────────────────────────────
270
+ /** Writes the (currently unlocked) secret to a timestamped file under
271
+ * ~/.config/pnl/exports/ with mode 0600 and returns the path. The
272
+ * caller relays just the path to the agent — the secret never enters
273
+ * the conversation transcript. */
274
+ export function exportToFile() {
275
+ const kp = requireUnlockedKeypair();
276
+ ensureExportsDir();
277
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
278
+ const path = join(EXPORTS_DIR, `keypair-${ts}.txt`);
279
+ const base58 = bs58.encode(kp.secretKey);
280
+ const jsonArray = JSON.stringify(Array.from(kp.secretKey));
281
+ const body = [
282
+ '# PNL keypair backup',
283
+ `# Generated: ${new Date().toISOString()}`,
284
+ `# Address: ${kp.publicKey.toBase58()}`,
285
+ '#',
286
+ '# TREAT THIS LIKE A PASSWORD. Anyone with this key can spend the SOL',
287
+ '# on this wallet. After moving the contents to a password manager,',
288
+ '# DELETE THIS FILE: rm "' + path + '"',
289
+ '',
290
+ '## Phantom / Solflare / Backpack ("Import Private Key"):',
291
+ base58,
292
+ '',
293
+ '## Solana CLI (save as ~/.config/solana/id.json):',
294
+ jsonArray,
295
+ '',
296
+ ].join('\n');
297
+ writeFileSync(path, body, { mode: 0o600 });
298
+ try {
299
+ chmodSync(path, 0o600);
300
+ }
301
+ catch {
302
+ /* non-fatal */
303
+ }
304
+ return { path, address: kp.publicKey.toBase58() };
305
+ }
306
+ /** Writes a freshly-generated BIP39 mnemonic to a 0600 file under
307
+ * ~/.config/pnl/exports/ and returns the path. The mnemonic itself
308
+ * is intentionally NOT returned in the function result — it never
309
+ * enters the agent's reply transcript (which would flow through the
310
+ * LLM API). The caller passes the user the path; the user `cat`s
311
+ * the file locally and moves it to their password manager.
312
+ *
313
+ * Same security model as exportToFile() for the secret key. */
314
+ export function writeMnemonicToFile(mnemonic, address) {
315
+ ensureExportsDir();
316
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
317
+ const path = join(EXPORTS_DIR, `mnemonic-${ts}.txt`);
318
+ const body = [
319
+ '# PNL wallet recovery phrase',
320
+ `# Generated: ${new Date().toISOString()}`,
321
+ `# Address: ${address}`,
322
+ '#',
323
+ '# THIS IS THE 12-WORD RECOVERY PHRASE FOR YOUR PNL WALLET.',
324
+ '# Anyone with these words can spend the SOL on this wallet.',
325
+ '# Treat it like a password.',
326
+ '#',
327
+ '# After moving these words to a password manager / paper backup,',
328
+ '# DELETE THIS FILE: rm "' + path + '"',
329
+ '#',
330
+ '# This phrase imports cleanly into Phantom / Solflare / Backpack',
331
+ '# (BIP39, Solana derivation path m/44\'/501\'/0\'/0\').',
332
+ '',
333
+ mnemonic,
334
+ '',
335
+ ].join('\n');
336
+ writeFileSync(path, body, { mode: 0o600 });
337
+ try {
338
+ chmodSync(path, 0o600);
339
+ }
340
+ catch {
341
+ /* non-fatal */
342
+ }
343
+ return { path };
344
+ }
345
+ // ─── Config (autosign cap, RPC URL) ──────────────────────────────
346
+ export function loadConfig() {
347
+ ensureDir();
348
+ if (!existsSync(CONFIG_PATH)) {
349
+ return { autosignCapSol: DEFAULT_AUTOSIGN_CAP_SOL, rpcUrl: DEFAULT_RPC };
350
+ }
351
+ try {
352
+ const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
353
+ return {
354
+ autosignCapSol: typeof parsed.autosignCapSol === 'number' && parsed.autosignCapSol >= 0
355
+ ? parsed.autosignCapSol
356
+ : DEFAULT_AUTOSIGN_CAP_SOL,
357
+ rpcUrl: typeof parsed.rpcUrl === 'string' && parsed.rpcUrl.length > 0
358
+ ? parsed.rpcUrl
359
+ : DEFAULT_RPC,
360
+ };
361
+ }
362
+ catch {
363
+ return { autosignCapSol: DEFAULT_AUTOSIGN_CAP_SOL, rpcUrl: DEFAULT_RPC };
364
+ }
365
+ }
366
+ export function saveConfig(updates) {
367
+ const current = loadConfig();
368
+ const next = { ...current, ...updates };
369
+ ensureDir();
370
+ writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2));
371
+ return next;
372
+ }
373
+ export function getRpcUrl() {
374
+ return process.env.PNL_RPC_URL?.trim() || loadConfig().rpcUrl;
375
+ }
376
+ export function getConnection() {
377
+ return new Connection(getRpcUrl(), 'confirmed');
378
+ }
379
+ export async function getBalanceSol(pubkey) {
380
+ const conn = getConnection();
381
+ const lamports = await conn.getBalance(pubkey, 'confirmed');
382
+ return lamports / LAMPORTS_PER_SOL;
383
+ }
384
+ // ─── Clear cache on process exit (defense in depth) ──────────────
385
+ process.on('exit', () => {
386
+ try {
387
+ lock();
388
+ }
389
+ catch {
390
+ /* ignore */
391
+ }
392
+ });
393
+ // Best-effort constant-time check exported for tools that need it
394
+ // (signature verification of e.g. challenge responses).
395
+ export function constantTimeEqual(a, b) {
396
+ if (a.length !== b.length)
397
+ return false;
398
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
399
+ }
400
+ export const WALLET_PATHS = {
401
+ dir: PNL_DIR,
402
+ wallet: WALLET_PATH,
403
+ config: CONFIG_PATH,
404
+ exports: EXPORTS_DIR,
405
+ };
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+ export declare const browseMarketsInputSchema: {
3
+ readonly status: z.ZodOptional<z.ZodEnum<["active", "yesWins", "noWins", "expired", "refund", "all"]>>;
4
+ readonly limit: z.ZodOptional<z.ZodNumber>;
5
+ readonly page: z.ZodOptional<z.ZodNumber>;
6
+ };
7
+ export declare function callBrowseMarkets(rawInput: unknown): Promise<{
8
+ content: Array<{
9
+ type: "text";
10
+ text: string;
11
+ }>;
12
+ }>;
@@ -0,0 +1,91 @@
1
+ import { z } from 'zod';
2
+ import { browseMarkets, marketUrl } from '../lib/pnl-api.js';
3
+ import { Badge, headline, table, next, reply } from '../lib/output.js';
4
+ // ─── pnl_browse_markets ──────────────────────────────────────────
5
+ export const browseMarketsInputSchema = {
6
+ status: z
7
+ .enum(['active', 'yesWins', 'noWins', 'expired', 'refund', 'all'])
8
+ .optional()
9
+ .describe("Which markets to include. 'active' = currently open for voting (default). 'yesWins'/'noWins' = resolved markets. 'expired' = past their deadline but not yet resolved. 'refund' = full-refund outcomes. 'all' = no filter."),
10
+ limit: z
11
+ .number()
12
+ .int()
13
+ .min(1)
14
+ .max(50)
15
+ .optional()
16
+ .describe('How many markets to return. Default 10. Max 50.'),
17
+ page: z
18
+ .number()
19
+ .int()
20
+ .min(1)
21
+ .optional()
22
+ .describe('1-indexed page number for pagination. Default 1.'),
23
+ };
24
+ const BrowseMarketsInput = z.object(browseMarketsInputSchema);
25
+ function fmtPool(m) {
26
+ const yes = m.totalYesStake ?? m.yesPool ?? null;
27
+ const no = m.totalNoStake ?? m.noPool ?? null;
28
+ let total = null;
29
+ if (yes != null || no != null) {
30
+ total = (yes ?? 0) + (no ?? 0);
31
+ }
32
+ else if (m.poolBalance != null) {
33
+ const n = typeof m.poolBalance === 'string' ? Number(m.poolBalance) : m.poolBalance;
34
+ if (Number.isFinite(n))
35
+ total = n;
36
+ }
37
+ if (total == null)
38
+ return '—';
39
+ const sol = total / 1e9;
40
+ if (sol < 0.001)
41
+ return '< 0.001';
42
+ if (sol < 1)
43
+ return sol.toFixed(3);
44
+ return sol.toFixed(2);
45
+ }
46
+ function fmtYes(m) {
47
+ return m.yesPercentage != null ? `${Math.round(m.yesPercentage)}%` : '—';
48
+ }
49
+ function fmtStatus(m) {
50
+ const s = (m.displayStatus || m.status || '').toLowerCase();
51
+ if (s.includes('active'))
52
+ return Badge.live;
53
+ if (s.includes('yes'))
54
+ return 'YES';
55
+ if (s.includes('no'))
56
+ return 'NO';
57
+ if (s.includes('refund'))
58
+ return 'refund';
59
+ if (s.includes('expired') || s.includes('awaiting'))
60
+ return Badge.pending;
61
+ return s || '—';
62
+ }
63
+ export async function callBrowseMarkets(rawInput) {
64
+ const input = BrowseMarketsInput.parse(rawInput ?? {});
65
+ const status = input.status ?? 'active';
66
+ const limit = input.limit ?? 10;
67
+ const page = input.page ?? 1;
68
+ const data = await browseMarkets({ status, limit, page });
69
+ const markets = data.markets ?? [];
70
+ if (markets.length === 0) {
71
+ return reply(headline(`No ${status === 'all' ? '' : status + ' '}markets on page ${page}.`), next(status !== 'all' ? 'Try `status: "all"` to include resolved + expired.' : 'No markets on PNL yet — be the first to plant one with `/pnl-pitch`.'));
72
+ }
73
+ const tableRows = markets.map((m) => {
74
+ const symbol = m.tokenSymbol ? `$${m.tokenSymbol}` : '—';
75
+ const founder = m.founderDisplayName || m.founderUsername || '—';
76
+ return [
77
+ `**${m.name}**${m.name.length > 28 ? '' : ''}`,
78
+ symbol,
79
+ fmtStatus(m),
80
+ fmtYes(m),
81
+ fmtPool(m) + ' SOL',
82
+ String(m.totalParticipants ?? 0),
83
+ founder,
84
+ ];
85
+ });
86
+ const urls = markets
87
+ .map((m) => `- \`${m.name}\` → ${marketUrl(m.id)}`)
88
+ .join('\n');
89
+ const headerLine = `${markets.length} ${status === 'all' ? '' : status + ' '}market${markets.length === 1 ? '' : 's'}${data.total ? ` of ${data.total}` : ''} · page ${page}`;
90
+ return reply(headline(headerLine), table(['Market', 'Ticker', 'Status', 'YES', 'Pool', 'Votes', 'Founder'], tableRows), urls, data.hasMore ? `_More available — call again with \`page: ${page + 1}\`._` : null, next('`/pnl-get <id>` (or ask) for full detail, `/pnl-pitch` to post your own.'));
91
+ }
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+ export declare const claimNowInputSchema: {
3
+ readonly marketId: z.ZodString;
4
+ };
5
+ export declare function callClaimNow(rawInput: unknown): Promise<{
6
+ content: Array<{
7
+ type: "text";
8
+ text: string;
9
+ }>;
10
+ }>;
@@ -0,0 +1,113 @@
1
+ import { z } from 'zod';
2
+ import { PublicKey } from '@solana/web3.js';
3
+ import { requireUnlockedKeypair, getConnection } from '../lib/wallet.js';
4
+ import { sendAndConfirm, freshNonce, signChallenge, challenge, signedRequestHash, } from '../lib/sign.js';
5
+ import { getMarket } from '../lib/pnl-api.js';
6
+ import { Badge, headline, code, kvTable, inline, next, reply, hr } from '../lib/output.js';
7
+ // ─── pnl_claim_now ───────────────────────────────────────────────
8
+ //
9
+ // Autosign claim_rewards. Resolves marketId → on-chain market
10
+ // address, asks the backend to build the unsigned claim tx, signs
11
+ // locally, sends, and posts the result via /api/mcp/markets/complete-claim.
12
+ //
13
+ // Unlike pitch_now and vote_now, there is NO autosign cap on claims:
14
+ // claiming is a withdrawal of funds the user is already owed by the
15
+ // program — it doesn't spend anything besides the ~0.000005 SOL tx
16
+ // fee. Capping it would gate the user from their own money.
17
+ export const claimNowInputSchema = {
18
+ marketId: z
19
+ .string()
20
+ .min(1)
21
+ .describe("Market id (Mongo id from pnl_browse_markets, or the on-chain market address). The market must be resolved and the wallet must hold an unclaimed position."),
22
+ };
23
+ const ClaimNowInput = z.object(claimNowInputSchema);
24
+ function getApiBase() {
25
+ const raw = process.env.PNL_API_BASE_URL?.trim();
26
+ if (!raw)
27
+ return 'https://pnl.market';
28
+ return raw.endsWith('/') ? raw.slice(0, -1) : raw;
29
+ }
30
+ export async function callClaimNow(rawInput) {
31
+ const { marketId } = ClaimNowInput.parse(rawInput ?? {});
32
+ const keypair = requireUnlockedKeypair();
33
+ const walletAddress = keypair.publicKey.toBase58();
34
+ const base = getApiBase();
35
+ // Resolve marketId → on-chain marketAddress + Mongo id.
36
+ let marketAddress;
37
+ let onchainId;
38
+ let marketName = marketId;
39
+ try {
40
+ new PublicKey(marketId);
41
+ // Caller gave us the on-chain address. We still need the Mongo id
42
+ // for the complete-claim payload, so resolve via the public API.
43
+ const m = await getMarket(marketId);
44
+ marketAddress = marketId;
45
+ onchainId = m.id ?? marketId;
46
+ marketName = m.name ?? marketId;
47
+ }
48
+ catch {
49
+ const m = await getMarket(marketId);
50
+ if (!m.marketAddress)
51
+ throw new Error(`market ${marketId} has no marketAddress`);
52
+ marketAddress = m.marketAddress;
53
+ onchainId = m.id ?? marketId;
54
+ marketName = m.name ?? marketId;
55
+ }
56
+ // 1. build-claim-tx
57
+ const buildRes = await fetch(`${base}/api/mcp/markets/build-claim-tx`, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
60
+ body: JSON.stringify({ walletAddress, marketAddress }),
61
+ });
62
+ const buildJson = (await buildRes.json());
63
+ if (!buildRes.ok || !buildJson.success || !buildJson.data) {
64
+ throw new Error(`build-claim-tx failed (${buildRes.status}): ${buildJson.error || 'unknown error'}`);
65
+ }
66
+ const built = buildJson.data;
67
+ // 2. sign locally — claim returns a VersionedTransaction, so we
68
+ // can't reuse signSerializedTx (which uses legacy Transaction.from).
69
+ // Decode v0, sign with keypair, send raw.
70
+ const { VersionedTransaction } = await import('@solana/web3.js');
71
+ const txBuf = Buffer.from(built.tx, 'base64');
72
+ const tx = VersionedTransaction.deserialize(txBuf);
73
+ tx.sign([keypair]);
74
+ const rawTx = tx.serialize();
75
+ // 3. send + confirm
76
+ const { signature: txSignature } = await sendAndConfirm(Buffer.from(rawTx), getConnection(), { confirmTimeoutMs: 90_000 });
77
+ // 4. sig-auth complete-claim — challenge binds the body so an
78
+ // attacker can't rewrite marketId on the persisted claim row.
79
+ const nonce = freshNonce();
80
+ const completeBodyCore = {
81
+ txSignature,
82
+ marketId: onchainId,
83
+ };
84
+ const payloadHash = signedRequestHash(completeBodyCore);
85
+ const sig = signChallenge(challenge('complete-claim', txSignature, nonce, payloadHash), keypair);
86
+ const completeRes = await fetch(`${base}/api/mcp/markets/complete-claim`, {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
89
+ body: JSON.stringify({
90
+ walletAddress,
91
+ nonce,
92
+ signature: sig,
93
+ ...completeBodyCore,
94
+ }),
95
+ });
96
+ const completeJson = (await completeRes.json());
97
+ if (!completeRes.ok || !completeJson.success || !completeJson.data) {
98
+ throw new Error(`Tx confirmed on-chain (${txSignature}) but complete-claim failed (${completeRes.status}): ${completeJson.error || 'unknown error'}. Your rewards are already in your wallet — only the off-chain "claimed" flag failed to update. Safe to re-run pnl_claim_now (idempotent on tx signature).`);
99
+ }
100
+ const done = completeJson.data;
101
+ const resHint = built.resolutionType === 'YesWins'
102
+ ? 'YES won — your tokens were minted into your wallet (Token2022 ATA created if needed).'
103
+ : built.resolutionType === 'NoWins'
104
+ ? "NO won — you've been paid your share of the pool in SOL."
105
+ : 'Refund — pool returned to voters proportionally.';
106
+ return reply(headline(`${Badge.live} Claimed · ${marketName}`), kvTable([
107
+ ['Market', `${base}/market/${onchainId}`],
108
+ ['Resolution', built.resolutionType],
109
+ ['Wallet', inline(walletAddress)],
110
+ ['Tx', `[${txSignature.slice(0, 8)}…${txSignature.slice(-6)}](${done.solscan})`],
111
+ ['Profile', `${base}/profile/${walletAddress}`],
112
+ ]), hr, resHint, code(`Tx: ${txSignature}`), next(`See your updated position at ${inline(`${base}/profile/${walletAddress}`)}.`));
113
+ }
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+ export declare const claimInputSchema: {
3
+ readonly marketId: z.ZodString;
4
+ };
5
+ export declare function callClaim(rawInput: unknown): Promise<{
6
+ content: Array<{
7
+ type: "text";
8
+ text: string;
9
+ }>;
10
+ }>;