@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.
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +83 -0
- package/dist/install.d.ts +1 -0
- package/dist/install.js +168 -0
- package/dist/lib/output.d.ts +95 -0
- package/dist/lib/output.js +175 -0
- package/dist/lib/passphrase.d.ts +16 -0
- package/dist/lib/passphrase.js +57 -0
- package/dist/lib/pnl-api.d.ts +65 -0
- package/dist/lib/pnl-api.js +89 -0
- package/dist/lib/sign.d.ts +40 -0
- package/dist/lib/sign.js +126 -0
- package/dist/lib/wallet.d.ts +74 -0
- package/dist/lib/wallet.js +405 -0
- package/dist/tools/browse-markets.d.ts +12 -0
- package/dist/tools/browse-markets.js +91 -0
- package/dist/tools/claim-now.d.ts +10 -0
- package/dist/tools/claim-now.js +113 -0
- package/dist/tools/claim.d.ts +10 -0
- package/dist/tools/claim.js +43 -0
- package/dist/tools/export-keypair.d.ts +10 -0
- package/dist/tools/export-keypair.js +25 -0
- package/dist/tools/get-market.d.ts +10 -0
- package/dist/tools/get-market.js +58 -0
- package/dist/tools/help.d.ts +7 -0
- package/dist/tools/help.js +54 -0
- package/dist/tools/init.d.ts +7 -0
- package/dist/tools/init.js +69 -0
- package/dist/tools/notify.d.ts +12 -0
- package/dist/tools/notify.js +150 -0
- package/dist/tools/pitch-idea.d.ts +38 -0
- package/dist/tools/pitch-idea.js +176 -0
- package/dist/tools/pitch-now.d.ts +39 -0
- package/dist/tools/pitch-now.js +179 -0
- package/dist/tools/restore.d.ts +11 -0
- package/dist/tools/restore.js +45 -0
- package/dist/tools/set-username.d.ts +10 -0
- package/dist/tools/set-username.js +87 -0
- package/dist/tools/unlock.d.ts +17 -0
- package/dist/tools/unlock.js +47 -0
- package/dist/tools/vote-now.d.ts +13 -0
- package/dist/tools/vote-now.js +146 -0
- package/dist/tools/vote.d.ts +12 -0
- package/dist/tools/vote.js +49 -0
- package/dist/tools/wallet.d.ts +7 -0
- package/dist/tools/wallet.js +40 -0
- package/package.json +64 -0
- package/skills/README.md +45 -0
- package/skills/pnl-browse/SKILL.md +30 -0
- package/skills/pnl-claim/SKILL.md +60 -0
- package/skills/pnl-claim-now/SKILL.md +67 -0
- package/skills/pnl-export/SKILL.md +39 -0
- package/skills/pnl-help/SKILL.md +17 -0
- package/skills/pnl-init/SKILL.md +24 -0
- package/skills/pnl-lock/SKILL.md +17 -0
- package/skills/pnl-name/SKILL.md +32 -0
- package/skills/pnl-notify/SKILL.md +57 -0
- package/skills/pnl-pitch/SKILL.md +83 -0
- package/skills/pnl-pitch-now/SKILL.md +88 -0
- package/skills/pnl-restore/SKILL.md +38 -0
- package/skills/pnl-unlock/SKILL.md +28 -0
- package/skills/pnl-vote/SKILL.md +48 -0
- package/skills/pnl-vote-now/SKILL.md +68 -0
- 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;
|
package/dist/lib/sign.js
ADDED
|
@@ -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
|
+
};
|