@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,175 @@
1
+ // ─── PNL MCP — terminal output helpers ───────────────────────────
2
+ //
3
+ // Every tool returns text that Claude Code renders as part of its
4
+ // reply. Plain console.log-style output reads like a log file in
5
+ // that context. This module gives every tool a consistent visual
6
+ // rhythm:
7
+ //
8
+ // - Bold one-line answer first (so the user can stop reading early
9
+ // if that's all they wanted)
10
+ // - Markdown tables for structured data (Claude Code renders them)
11
+ // - Truncated addresses with full base58 in a code block for copy
12
+ // - ASCII status badges ([ok], [!], [locked], [live]) — no emoji
13
+ // per the standing brand rule
14
+ // - A "→ Next:" hint at the end so the agent and user both know
15
+ // the typical follow-up
16
+ //
17
+ // All helpers return strings. Compose with template literals or
18
+ // section() + join('\n\n').
19
+ // ─── Address / hash formatting ───────────────────────────────────
20
+ /**
21
+ * Shorten a base58 pubkey for display. Keeps the first 6 and last 4
22
+ * characters — enough to be visually distinguishable without taking
23
+ * a full line.
24
+ *
25
+ * truncAddress("9ot5o7tbtUit8j75ivdjxoUCGaY7uCcUDootdKuVhECH")
26
+ * // → "9ot5o7…hECH"
27
+ */
28
+ export function truncAddress(addr, leading = 6, trailing = 4) {
29
+ if (!addr || addr.length <= leading + trailing + 1)
30
+ return addr;
31
+ return `${addr.slice(0, leading)}…${addr.slice(-trailing)}`;
32
+ }
33
+ /**
34
+ * Format SOL from lamports with reasonable precision. Returns null
35
+ * when the amount is below display threshold so callers can omit
36
+ * the row entirely.
37
+ */
38
+ export function formatSol(lamports) {
39
+ if (lamports == null)
40
+ return null;
41
+ const n = typeof lamports === 'string' ? Number(lamports) : lamports;
42
+ if (!Number.isFinite(n))
43
+ return null;
44
+ const sol = n / 1e9;
45
+ if (sol < 0.0001)
46
+ return null;
47
+ if (sol < 1)
48
+ return `${sol.toFixed(3)} SOL`;
49
+ if (sol < 100)
50
+ return `${sol.toFixed(2)} SOL`;
51
+ return `${sol.toFixed(1)} SOL`;
52
+ }
53
+ // ─── Badges & status markers ─────────────────────────────────────
54
+ //
55
+ // Plain-ASCII bracketed tokens read as visual cues without being
56
+ // emoji. They survive copy-paste, terminals without unicode font
57
+ // fallback, and screen readers.
58
+ export const Badge = {
59
+ ok: '[ok]',
60
+ warn: '[!]',
61
+ err: '[err]',
62
+ locked: '[locked]',
63
+ unlocked: '[unlocked]',
64
+ live: '[live]',
65
+ ended: '[ended]',
66
+ pending: '[pending]',
67
+ draft: '[draft]',
68
+ };
69
+ // ─── Structural primitives ───────────────────────────────────────
70
+ /**
71
+ * Render a one-line bold "headline" answer. The user can read just
72
+ * this and stop. Subsequent text is "if you want details".
73
+ */
74
+ export function headline(text) {
75
+ return `**${text}**`;
76
+ }
77
+ /**
78
+ * Two-column key/value table. Used by pnl_wallet, pnl_get_market,
79
+ * etc. for structured "here's what we know" responses.
80
+ */
81
+ export function kvTable(rows) {
82
+ const filtered = rows.filter((r) => r[1] != null && String(r[1]).length > 0);
83
+ if (filtered.length === 0)
84
+ return '';
85
+ return [
86
+ '| | |',
87
+ '|---|---|',
88
+ ...filtered.map(([k, v]) => `| **${k}** | ${v} |`),
89
+ ].join('\n');
90
+ }
91
+ /**
92
+ * Generic markdown table with headers + rows. Pads cells with one
93
+ * space on each side so the source reads cleanly in case the user
94
+ * is on a renderer that doesn't pretty-print markdown tables.
95
+ */
96
+ export function table(headers, rows) {
97
+ const separator = headers.map(() => '---');
98
+ const all = [headers, separator, ...rows];
99
+ return all.map((r) => `| ${r.join(' | ')} |`).join('\n');
100
+ }
101
+ /**
102
+ * Code block — useful for pubkeys, tx signatures, IPFS CIDs,
103
+ * mnemonics, file paths. Claude Code renders these as monospace
104
+ * with a copy button.
105
+ */
106
+ export function code(content, lang = '') {
107
+ return `\`\`\`${lang}\n${content}\n\`\`\``;
108
+ }
109
+ /**
110
+ * Inline code span — for short identifiers in the middle of a
111
+ * sentence.
112
+ */
113
+ export function inline(content) {
114
+ return `\`${content}\``;
115
+ }
116
+ /**
117
+ * Blockquote — used for market descriptions, manifesto-style
118
+ * pull-quotes, important warnings.
119
+ */
120
+ export function quote(text) {
121
+ return text
122
+ .trim()
123
+ .split('\n')
124
+ .map((line) => `> ${line}`)
125
+ .join('\n');
126
+ }
127
+ /**
128
+ * Markdown heading — H3 by default since tool output isn't a doc.
129
+ */
130
+ export function heading(text, level = 3) {
131
+ return `${'#'.repeat(level)} ${text}`;
132
+ }
133
+ /**
134
+ * Horizontal rule. Use sparingly to separate big sections.
135
+ */
136
+ export const hr = '---';
137
+ /**
138
+ * Action hint at the bottom of an output. Standardized so users
139
+ * recognize the pattern.
140
+ */
141
+ export function next(hint) {
142
+ return `→ ${hint}`;
143
+ }
144
+ /**
145
+ * Wrap a tool result in the MCP content-block shape so callers
146
+ * don't have to repeat the boilerplate. Joins parts with two
147
+ * newlines so each section gets a paragraph break.
148
+ */
149
+ export function reply(...parts) {
150
+ const text = parts
151
+ .filter((p) => typeof p === 'string' && p.length > 0)
152
+ .join('\n\n');
153
+ return { content: [{ type: 'text', text }] };
154
+ }
155
+ // ─── Common domain-specific formatters ───────────────────────────
156
+ /**
157
+ * Render a Solana address: truncated for the eye, plus a code block
158
+ * with the full thing for copy. Optionally include a Solscan link.
159
+ */
160
+ export function addressBlock(addr, opts = {}) {
161
+ const label = opts.label ?? 'Address';
162
+ const lines = [`**${label}:** \`${truncAddress(addr)}\``];
163
+ lines.push(code(addr));
164
+ if (opts.solscan) {
165
+ lines.push(`[View on Solscan](https://solscan.io/account/${addr})`);
166
+ }
167
+ return lines.join('\n');
168
+ }
169
+ /**
170
+ * Render a market URL with its truncated id and the full URL on a
171
+ * separate line for copy.
172
+ */
173
+ export function marketLink(marketId, baseUrl) {
174
+ return `${baseUrl}/market/${marketId}`;
175
+ }
@@ -0,0 +1,16 @@
1
+ export interface PromptOptions {
2
+ /** Title shown on the dialog */
3
+ title?: string;
4
+ /** Prompt text inside the dialog */
5
+ prompt?: string;
6
+ /** If true, ask twice and require they match (for setup) */
7
+ confirm?: boolean;
8
+ }
9
+ /**
10
+ * Request the wallet passphrase from the user, never via the agent.
11
+ *
12
+ * Throws with a helpful message if no path is available so the agent
13
+ * can relay the failure clearly ("set PNL_PASSPHRASE in your Claude
14
+ * Code mcp config and restart").
15
+ */
16
+ export declare function promptPassphrase(opts?: PromptOptions): string;
@@ -0,0 +1,57 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ function fromEnv() {
3
+ const raw = process.env.PNL_PASSPHRASE;
4
+ if (raw && raw.length > 0)
5
+ return raw;
6
+ return null;
7
+ }
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, '\\"')}"
12
+ return text returned of result`;
13
+ const out = execFileSync('osascript', ['-e', script], { encoding: 'utf8' });
14
+ return out.trim();
15
+ }
16
+ function linuxPrompt(prompt, _title) {
17
+ const out = execFileSync('zenity', ['--password', '--title', _title], { encoding: 'utf8' });
18
+ return out.trim();
19
+ }
20
+ /**
21
+ * Request the wallet passphrase from the user, never via the agent.
22
+ *
23
+ * Throws with a helpful message if no path is available so the agent
24
+ * can relay the failure clearly ("set PNL_PASSPHRASE in your Claude
25
+ * Code mcp config and restart").
26
+ */
27
+ export function promptPassphrase(opts = {}) {
28
+ const envValue = fromEnv();
29
+ if (envValue)
30
+ return envValue;
31
+ const title = opts.title || 'PNL Wallet';
32
+ const prompt = opts.prompt || 'Enter your PNL wallet passphrase:';
33
+ try {
34
+ if (process.platform === 'darwin') {
35
+ const first = macosPrompt(prompt, title);
36
+ if (opts.confirm) {
37
+ const again = macosPrompt('Re-enter to confirm:', title);
38
+ if (first !== again)
39
+ throw new Error('passphrases do not match');
40
+ }
41
+ return first;
42
+ }
43
+ if (process.platform === 'linux') {
44
+ const first = linuxPrompt(prompt, title);
45
+ if (opts.confirm) {
46
+ const again = linuxPrompt('Re-enter to confirm:', title);
47
+ if (first !== again)
48
+ throw new Error('passphrases do not match');
49
+ }
50
+ return first;
51
+ }
52
+ }
53
+ catch (e) {
54
+ throw new Error(`Couldn't open the native passphrase dialog (${e instanceof Error ? e.message : String(e)}). Set the PNL_PASSPHRASE env var in your Claude Code mcp config and restart — see apps/mcp/README.md for the snippet.`);
55
+ }
56
+ throw new Error(`Native passphrase dialog isn't supported on ${process.platform} yet. Set the PNL_PASSPHRASE env var in your Claude Code mcp config and restart.`);
57
+ }
@@ -0,0 +1,65 @@
1
+ export type MarketStatus = 'active' | 'yesWins' | 'noWins' | 'expired' | 'refund' | 'all';
2
+ export interface MarketSummary {
3
+ id: string;
4
+ name: string;
5
+ description?: string;
6
+ category?: string;
7
+ stage?: string;
8
+ tokenSymbol?: string;
9
+ targetPool?: string;
10
+ founderDisplayName?: string;
11
+ founderUsername?: string;
12
+ founderWallet?: string;
13
+ totalParticipants?: number;
14
+ yesPercentage?: number | null;
15
+ noPercentage?: number | null;
16
+ yesPool?: number | null;
17
+ noPool?: number | null;
18
+ totalYesStake?: number | null;
19
+ totalNoStake?: number | null;
20
+ poolBalance?: string | number | null;
21
+ poolProgressPercentage?: number;
22
+ status?: string;
23
+ displayStatus?: string;
24
+ phase?: string;
25
+ resolution?: string;
26
+ timeLeft?: string;
27
+ expiryTime?: string;
28
+ marketAddress?: string;
29
+ pumpFunTokenAddress?: string | null;
30
+ tokenMint?: string | null;
31
+ projectImageUrl?: string | null;
32
+ }
33
+ export interface MarketListResponse {
34
+ markets: MarketSummary[];
35
+ total?: number;
36
+ page?: number;
37
+ limit?: number;
38
+ hasMore?: boolean;
39
+ totalPages?: number;
40
+ }
41
+ /**
42
+ * Browse live (or historical) conviction markets.
43
+ *
44
+ * Wraps GET /api/markets/list with the documented query params. Returns
45
+ * the same payload the API returns — the tool layer formats it for the
46
+ * agent. Public, no auth, IP-rate-limited 60/min by the server.
47
+ */
48
+ export declare function browseMarkets(params: {
49
+ status?: MarketStatus;
50
+ page?: number;
51
+ limit?: number;
52
+ }): Promise<MarketListResponse>;
53
+ /**
54
+ * Fetch a single market by id.
55
+ *
56
+ * Wraps GET /api/markets/<id>. Returns the full market object — name,
57
+ * description, founder, pools, YES%, status, expiry, the on-chain market
58
+ * address, and any computed display fields.
59
+ */
60
+ export declare function getMarket(id: string): Promise<MarketSummary>;
61
+ /**
62
+ * Public URL the user can open in a browser to view a market. Used to
63
+ * thread back into the tool output so agents can hand the user a link.
64
+ */
65
+ export declare function marketUrl(id: string): string;
@@ -0,0 +1,89 @@
1
+ // ─── PNL public read API client ──────────────────────────────────
2
+ //
3
+ // Thin wrappers over the two public read endpoints documented at
4
+ // https://docs.pnl.market/docs/build/public-api. The MCP server never
5
+ // holds keys, so this module is fetch-only.
6
+ //
7
+ // Base URL is configurable via PNL_API_BASE_URL — defaults to the live
8
+ // site. Tests / staging / devnet pointed deployments can override.
9
+ const DEFAULT_BASE_URL = 'https://pnl.market';
10
+ function getBaseUrl() {
11
+ const raw = process.env.PNL_API_BASE_URL?.trim();
12
+ if (!raw)
13
+ return DEFAULT_BASE_URL;
14
+ // Strip trailing slash so we can append paths cleanly.
15
+ return raw.endsWith('/') ? raw.slice(0, -1) : raw;
16
+ }
17
+ async function fetchJson(url) {
18
+ const res = await fetch(url, {
19
+ headers: {
20
+ Accept: 'application/json',
21
+ // Identify ourselves so the rate limiter can see who's calling.
22
+ 'User-Agent': 'pnl-mcp-server/0.1.0 (+https://docs.pnl.market)',
23
+ },
24
+ });
25
+ if (!res.ok) {
26
+ let body = '';
27
+ try {
28
+ body = await res.text();
29
+ }
30
+ catch {
31
+ /* ignore */
32
+ }
33
+ throw new Error(`PNL API ${res.status} ${res.statusText} for ${url}${body ? ` — ${body.slice(0, 200)}` : ''}`);
34
+ }
35
+ const json = (await res.json());
36
+ // The API wraps everything in {success, data}. If we see that shape,
37
+ // unwrap; otherwise pass through (for future endpoints that don't wrap).
38
+ if (typeof json === 'object' &&
39
+ json !== null &&
40
+ 'success' in json &&
41
+ 'data' in json) {
42
+ const env = json;
43
+ if (!env.success) {
44
+ throw new Error(`PNL API returned success=false for ${url}${env.error ? ` — ${env.error}` : ''}`);
45
+ }
46
+ return env.data;
47
+ }
48
+ return json;
49
+ }
50
+ /**
51
+ * Browse live (or historical) conviction markets.
52
+ *
53
+ * Wraps GET /api/markets/list with the documented query params. Returns
54
+ * the same payload the API returns — the tool layer formats it for the
55
+ * agent. Public, no auth, IP-rate-limited 60/min by the server.
56
+ */
57
+ export async function browseMarkets(params) {
58
+ const qs = new URLSearchParams();
59
+ if (params.status)
60
+ qs.set('status', params.status);
61
+ if (params.page != null)
62
+ qs.set('page', String(params.page));
63
+ if (params.limit != null)
64
+ qs.set('limit', String(params.limit));
65
+ const url = `${getBaseUrl()}/api/markets/list${qs.toString() ? `?${qs.toString()}` : ''}`;
66
+ return fetchJson(url);
67
+ }
68
+ /**
69
+ * Fetch a single market by id.
70
+ *
71
+ * Wraps GET /api/markets/<id>. Returns the full market object — name,
72
+ * description, founder, pools, YES%, status, expiry, the on-chain market
73
+ * address, and any computed display fields.
74
+ */
75
+ export async function getMarket(id) {
76
+ if (!id)
77
+ throw new Error('marketId is required');
78
+ // Encode just in case someone passes a market address that contains
79
+ // characters that need encoding (unlikely for base58 but cheap to be safe).
80
+ const url = `${getBaseUrl()}/api/markets/${encodeURIComponent(id)}`;
81
+ return fetchJson(url);
82
+ }
83
+ /**
84
+ * Public URL the user can open in a browser to view a market. Used to
85
+ * thread back into the tool output so agents can hand the user a link.
86
+ */
87
+ export function marketUrl(id) {
88
+ return `${getBaseUrl()}/market/${encodeURIComponent(id)}`;
89
+ }
@@ -0,0 +1,40 @@
1
+ import { Connection, Keypair, TransactionSignature, SendOptions } from '@solana/web3.js';
2
+ /** Decode a base64-encoded unsigned tx, partial-sign with the local
3
+ * Keypair, and return the fully-serialized signed transaction ready
4
+ * to be sent via Connection.sendRawTransaction.
5
+ *
6
+ * Uses partialSign rather than sign so the tx can carry additional
7
+ * signers later (none today, but the shape generalizes). */
8
+ export declare function signSerializedTx(txBase64: string, keypair: Keypair): Buffer;
9
+ export interface SendAndConfirmOptions extends SendOptions {
10
+ /** Block-height based timeout for confirmation. Defaults to 60s.
11
+ * Solana blockhashes expire after ~60-90s, so this is the
12
+ * hard-stop for "will the tx still land". */
13
+ confirmTimeoutMs?: number;
14
+ }
15
+ export interface SendResult {
16
+ signature: TransactionSignature;
17
+ /** Slot the tx landed in, if confirmation succeeded. */
18
+ slot?: number;
19
+ }
20
+ /** Send a raw (already-signed) transaction and wait for it to confirm.
21
+ * Throws if the tx fails on-chain or if confirmation times out. */
22
+ export declare function sendAndConfirm(rawTx: Buffer | Uint8Array, connection?: Connection, opts?: SendAndConfirmOptions): Promise<SendResult>;
23
+ /** Build a fresh nonce in the canonical "<unix-ms>-<hex>" format the
24
+ * /api/mcp/* endpoints expect. */
25
+ export declare function freshNonce(): string;
26
+ /** Sign a UTF-8 challenge string with the local Keypair and return a
27
+ * base58 signature suitable for the {walletAddress, nonce, signature}
28
+ * payload sent to /api/mcp/profile, /api/mcp/markets/complete-*. */
29
+ export declare function signChallenge(challenge: string, keypair: Keypair): string;
30
+ /** Re-export the canonical challenge string format so the MCP and the
31
+ * backend stay in lockstep. Mirrors apps/web/src/lib/mcp-auth.ts.
32
+ * When payloadHash is supplied, the sig binds to the request body —
33
+ * see signedRequestHash() below. */
34
+ export declare function challenge(kind: 'build-create' | 'build-vote' | 'build-claim' | 'complete-create' | 'complete-vote' | 'complete-claim' | 'profile', fingerprint: string, nonce: string, payloadHash?: string): string;
35
+ /** SHA-256 of the request body minus auth fields (walletAddress,
36
+ * nonce, signature) — first 16 hex chars. Both MCP-side (before
37
+ * signing) and backend (verifying) compute the same hash so the sig
38
+ * is bound to the exact payload. Tampering with any payload field
39
+ * invalidates the sig. */
40
+ export declare function signedRequestHash(body: Record<string, unknown>): string;
@@ -0,0 +1,126 @@
1
+ import { Transaction, } from '@solana/web3.js';
2
+ import nacl from 'tweetnacl';
3
+ import bs58 from 'bs58';
4
+ import { createHash } from 'node:crypto';
5
+ import { getConnection } from './wallet.js';
6
+ // ─── PNL MCP — transaction signing + send helpers ─────────────────
7
+ //
8
+ // The autosign create / vote flows fetch an unsigned transaction from
9
+ // the PNL backend (base64-encoded), sign it locally with the user's
10
+ // Keypair, then send it to the Solana cluster via our RPC.
11
+ //
12
+ // Keeping the surface small: one helper for partial-signing a
13
+ // base64 transaction, one for send-and-confirm, one for signing a
14
+ // raw challenge string (used by the sig-auth endpoints).
15
+ /** Decode a base64-encoded unsigned tx, partial-sign with the local
16
+ * Keypair, and return the fully-serialized signed transaction ready
17
+ * to be sent via Connection.sendRawTransaction.
18
+ *
19
+ * Uses partialSign rather than sign so the tx can carry additional
20
+ * signers later (none today, but the shape generalizes). */
21
+ export function signSerializedTx(txBase64, keypair) {
22
+ const buf = Buffer.from(txBase64, 'base64');
23
+ const tx = Transaction.from(buf);
24
+ tx.partialSign(keypair);
25
+ return tx.serialize();
26
+ }
27
+ /** Send a raw (already-signed) transaction and wait for it to confirm.
28
+ * Throws if the tx fails on-chain or if confirmation times out. */
29
+ export async function sendAndConfirm(rawTx, connection = getConnection(), opts = {}) {
30
+ const { confirmTimeoutMs = 60_000, ...sendOpts } = opts;
31
+ const signature = await connection.sendRawTransaction(rawTx, {
32
+ skipPreflight: false,
33
+ preflightCommitment: 'confirmed',
34
+ maxRetries: 3,
35
+ ...sendOpts,
36
+ });
37
+ // Use the latest blockhash + lastValidBlockHeight so confirmation
38
+ // gives up exactly when the tx becomes unlandable, rather than
39
+ // burning the full timeout on an already-expired tx.
40
+ const latest = await connection.getLatestBlockhash('confirmed');
41
+ const ctrl = new AbortController();
42
+ const timer = setTimeout(() => ctrl.abort(), confirmTimeoutMs);
43
+ try {
44
+ const result = await connection.confirmTransaction({
45
+ signature,
46
+ blockhash: latest.blockhash,
47
+ lastValidBlockHeight: latest.lastValidBlockHeight,
48
+ abortSignal: ctrl.signal,
49
+ }, 'confirmed');
50
+ if (result.value.err) {
51
+ throw new Error(`transaction failed on-chain: ${JSON.stringify(result.value.err)}`);
52
+ }
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ }
57
+ // getSignatureStatuses for the landed slot — best-effort; never
58
+ // block the caller on this.
59
+ let slot;
60
+ try {
61
+ const status = await connection.getSignatureStatuses([signature]);
62
+ slot = status.value[0]?.slot;
63
+ }
64
+ catch {
65
+ /* ignore */
66
+ }
67
+ return { signature, slot };
68
+ }
69
+ /** Build a fresh nonce in the canonical "<unix-ms>-<hex>" format the
70
+ * /api/mcp/* endpoints expect. */
71
+ export function freshNonce() {
72
+ const ms = Date.now();
73
+ const random = nacl.randomBytes(8);
74
+ const hex = Buffer.from(random).toString('hex');
75
+ return `${ms}-${hex}`;
76
+ }
77
+ /** Sign a UTF-8 challenge string with the local Keypair and return a
78
+ * base58 signature suitable for the {walletAddress, nonce, signature}
79
+ * payload sent to /api/mcp/profile, /api/mcp/markets/complete-*. */
80
+ export function signChallenge(challenge, keypair) {
81
+ const message = new TextEncoder().encode(challenge);
82
+ const signature = nacl.sign.detached(message, keypair.secretKey);
83
+ return bs58.encode(signature);
84
+ }
85
+ /** Re-export the canonical challenge string format so the MCP and the
86
+ * backend stay in lockstep. Mirrors apps/web/src/lib/mcp-auth.ts.
87
+ * When payloadHash is supplied, the sig binds to the request body —
88
+ * see signedRequestHash() below. */
89
+ export function challenge(kind, fingerprint, nonce, payloadHash) {
90
+ if (payloadHash) {
91
+ return `pnl-mcp:${kind}:${fingerprint}:${payloadHash}:${nonce}`;
92
+ }
93
+ return `pnl-mcp:${kind}:${fingerprint}:${nonce}`;
94
+ }
95
+ /** Canonical JSON: keys sorted, no whitespace, recursive. Matches
96
+ * apps/web/src/lib/mcp-auth.ts (and JSON.stringify's handling of
97
+ * `undefined`) so both sides hash identical bytes regardless of
98
+ * whether the body has been through JSON serialization yet. */
99
+ function canonicalJson(value) {
100
+ if (value === undefined)
101
+ return 'null';
102
+ if (value === null || typeof value !== 'object')
103
+ return JSON.stringify(value);
104
+ if (Array.isArray(value)) {
105
+ return '[' + value.map(canonicalJson).join(',') + ']';
106
+ }
107
+ const obj = value;
108
+ // Drop undefined-valued keys (mirrors JSON.stringify) so the body
109
+ // we hash before sending matches the body the backend receives.
110
+ const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort();
111
+ return ('{' +
112
+ keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') +
113
+ '}');
114
+ }
115
+ /** SHA-256 of the request body minus auth fields (walletAddress,
116
+ * nonce, signature) — first 16 hex chars. Both MCP-side (before
117
+ * signing) and backend (verifying) compute the same hash so the sig
118
+ * is bound to the exact payload. Tampering with any payload field
119
+ * invalidates the sig. */
120
+ export function signedRequestHash(body) {
121
+ const { walletAddress: _w, nonce: _n, signature: _s, ...payload } = body;
122
+ void _w;
123
+ void _n;
124
+ void _s;
125
+ return createHash('sha256').update(canonicalJson(payload), 'utf8').digest('hex').slice(0, 16);
126
+ }
@@ -0,0 +1,74 @@
1
+ import { Connection, Keypair, PublicKey } from '@solana/web3.js';
2
+ /** True iff the currently active RPC URL is our hosted MCP proxy.
3
+ * Tools use this to decide whether to surface the BYO-Helius hint. */
4
+ export declare function isUsingHostedRpc(): boolean;
5
+ export interface PnlConfig {
6
+ autosignCapSol: number;
7
+ rpcUrl: string;
8
+ }
9
+ export declare function generateMnemonic(): string;
10
+ export declare function isValidMnemonic(mnemonic: string): boolean;
11
+ /** Returns a Solana Keypair derived from the BIP39 mnemonic at the
12
+ * Phantom-compatible path m/44'/501'/0'/0'. */
13
+ export declare function keypairFromMnemonic(mnemonic: string): Keypair;
14
+ export declare function hasWallet(): boolean;
15
+ export declare function getAddress(): string;
16
+ export declare function isUnlocked(): boolean;
17
+ export declare function unlockWith(passphrase: string, ttlMinutes?: number): {
18
+ address: string;
19
+ };
20
+ export declare function lock(): void;
21
+ /** Returns the in-memory Keypair if the wallet is currently unlocked.
22
+ * Throws with a "wallet locked" message otherwise. */
23
+ export declare function requireUnlockedKeypair(): Keypair;
24
+ export declare function unlockStatus(): {
25
+ unlocked: boolean;
26
+ secondsRemaining: number;
27
+ };
28
+ export interface CreatedWallet {
29
+ address: string;
30
+ /** 12-word BIP39 mnemonic. Shown to user ONCE — never stored on disk. */
31
+ mnemonic: string;
32
+ }
33
+ /** Generate a fresh BIP39 mnemonic + keypair, encrypt the secret with
34
+ * the user's passphrase, write to disk. Returns the mnemonic so the
35
+ * agent can display it once for the user to write down. */
36
+ export declare function createWallet(passphrase: string): CreatedWallet;
37
+ /** Restore a wallet from an existing BIP39 mnemonic. The user's
38
+ * passphrase encrypts the derived secret on disk. */
39
+ export declare function restoreWallet(mnemonic: string, passphrase: string, opts?: {
40
+ allowOverwrite?: boolean;
41
+ }): {
42
+ address: string;
43
+ };
44
+ /** Writes the (currently unlocked) secret to a timestamped file under
45
+ * ~/.config/pnl/exports/ with mode 0600 and returns the path. The
46
+ * caller relays just the path to the agent — the secret never enters
47
+ * the conversation transcript. */
48
+ export declare function exportToFile(): {
49
+ path: string;
50
+ address: string;
51
+ };
52
+ /** Writes a freshly-generated BIP39 mnemonic to a 0600 file under
53
+ * ~/.config/pnl/exports/ and returns the path. The mnemonic itself
54
+ * is intentionally NOT returned in the function result — it never
55
+ * enters the agent's reply transcript (which would flow through the
56
+ * LLM API). The caller passes the user the path; the user `cat`s
57
+ * the file locally and moves it to their password manager.
58
+ *
59
+ * Same security model as exportToFile() for the secret key. */
60
+ export declare function writeMnemonicToFile(mnemonic: string, address: string): {
61
+ path: string;
62
+ };
63
+ export declare function loadConfig(): PnlConfig;
64
+ export declare function saveConfig(updates: Partial<PnlConfig>): PnlConfig;
65
+ export declare function getRpcUrl(): string;
66
+ export declare function getConnection(): Connection;
67
+ export declare function getBalanceSol(pubkey: PublicKey): Promise<number>;
68
+ export declare function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean;
69
+ export declare const WALLET_PATHS: {
70
+ readonly dir: string;
71
+ readonly wallet: string;
72
+ readonly config: string;
73
+ readonly exports: string;
74
+ };