pearpad-mcp 1.0.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/.env.example +8 -0
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/abi.js +27 -0
- package/fmt.js +117 -0
- package/package.json +17 -0
- package/pearpad.js +231 -0
- package/server.js +64 -0
package/.env.example
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Copy to .env (or set these in your MCP client config). Only PEARPAD_PRIVATE_KEY is required.
|
|
2
|
+
|
|
3
|
+
# The wallet the agent trades with. Fund it with USDT0 on Stable (chain 988). NEVER commit this.
|
|
4
|
+
PEARPAD_PRIVATE_KEY=0xyourprivatekey
|
|
5
|
+
|
|
6
|
+
# Optional โ defaults to the public endpoints.
|
|
7
|
+
# PEARPAD_RPC_URL=https://rpc.stable.xyz
|
|
8
|
+
# PEARPAD_API=https://pearpad.fun/api
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pearpad
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://pearpad.fun/assets/pearpad-mcp.png" alt="pearpad" width="140" height="140">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">pearpad-mcp</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center"><strong>Give your agent a wallet and let it trade <a href="https://pearpad.fun">pearpad.fun</a>.</strong></p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<img src="https://img.shields.io/npm/v/pearpad-mcp" alt="npm">
|
|
11
|
+
<img src="https://img.shields.io/node/v/pearpad-mcp" alt="node">
|
|
12
|
+
<img src="https://img.shields.io/npm/l/pearpad-mcp" alt="license">
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
An [MCP](https://modelcontextprotocol.io) server that hands any AI agent a set of tools for pearpad tokens on Stable Mainnet: find them, quote them, buy, sell, launch new ones, and claim your creator earnings. You point it at a wallet you control and talk to your agent in plain language. It figures out whether a token still trades on its bonding curve or has graduated to a pool, and routes the trade the right way. You never touch a contract address.
|
|
16
|
+
|
|
17
|
+
## Tools
|
|
18
|
+
|
|
19
|
+
| Tool | What your agent does |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `list_tokens` | Browse tokens newest-first, or search by name or symbol |
|
|
22
|
+
| `get_token` | Read one token's stats and phase; pull its trades, holders, or candles |
|
|
23
|
+
| `quote` | See the result of a buy or sell before spending anything |
|
|
24
|
+
| `buy` | Spend USDT0, receive tokens, with a slippage floor you set |
|
|
25
|
+
| `sell` | Sell tokens for USDT0 by amount or by percent of what you hold |
|
|
26
|
+
| `launch` | Deploy a new token with a name, symbol, image, and an optional first buy |
|
|
27
|
+
| `collect_fees` | Claim the creator earnings owed to your wallet |
|
|
28
|
+
| `wallet` | Check your address, USDT0 balance, and token holdings |
|
|
29
|
+
|
|
30
|
+
Every buy and sell quotes first and rejects a fill worse than your slippage limit. The default limit is 5%. Override it per call.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
You need Node.js 20 or newer and a wallet funded with **USDT0 on Stable** (chain 988). On Stable, USDT0 is both the gas token and the trading currency, so one balance covers both.
|
|
35
|
+
|
|
36
|
+
**Claude Desktop.** Add this to `claude_desktop_config.json`:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"pearpad": {
|
|
42
|
+
"command": "npx",
|
|
43
|
+
"args": ["-y", "pearpad-mcp"],
|
|
44
|
+
"env": { "PEARPAD_PRIVATE_KEY": "0xyourkey" }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Claude Code:**
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
claude mcp add pearpad -e PEARPAD_PRIVATE_KEY=0xyourkey -- npx -y pearpad-mcp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Any MCP client works. Run the server with `npx pearpad-mcp` and set `PEARPAD_PRIVATE_KEY` in its environment.
|
|
57
|
+
|
|
58
|
+
## Talk to it
|
|
59
|
+
|
|
60
|
+
Once it's connected, ask your agent:
|
|
61
|
+
|
|
62
|
+
> "What are the newest pearpad tokens?"
|
|
63
|
+
|
|
64
|
+
> "Quote buying 50 USDT0 of PEAR, then do it if I get at least 90% of the quote."
|
|
65
|
+
|
|
66
|
+
> "Sell all my BOLT."
|
|
67
|
+
|
|
68
|
+
> "Launch a token called Pear Bot, symbol PBOT, with this image, and buy 10 USDT0 of it myself."
|
|
69
|
+
|
|
70
|
+
> "How much have I earned as a creator, and claim it."
|
|
71
|
+
|
|
72
|
+
## What it looks like
|
|
73
|
+
|
|
74
|
+
Tools return a formatted printout, so terminal clients show something readable instead of raw JSON:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
๐ PEAR ยท Pear Coin
|
|
78
|
+
0x1234abcdโฆ5678 [CURVE]
|
|
79
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
80
|
+
price 0.000003173 USDT0
|
|
81
|
+
volume 24.24 USDT0
|
|
82
|
+
trades 10
|
|
83
|
+
holders 3
|
|
84
|
+
reserves 19.15 USDT0 / 793,917,130 PEAR
|
|
85
|
+
|
|
86
|
+
price
|
|
87
|
+
0.000003180 โ โ
|
|
88
|
+
โ โ
|
|
89
|
+
โ โโโ
|
|
90
|
+
โ โโโโโโโโโ
|
|
91
|
+
0.000003171 โโโโโโโโโโโ
|
|
92
|
+
โโโโโโโโโโโ
|
|
93
|
+
|
|
94
|
+
top holders
|
|
95
|
+
CURVE 99.39% 0x1111โฆ1111
|
|
96
|
+
DEV 0.58% 0x2222โฆ2222
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Config
|
|
100
|
+
|
|
101
|
+
| Variable | Required | Default |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| `PEARPAD_PRIVATE_KEY` | Yes, for any transaction | none |
|
|
104
|
+
| `PEARPAD_RPC_URL` | No | `https://rpc.stable.xyz` |
|
|
105
|
+
| `PEARPAD_API` | No | `https://pearpad.fun/api` |
|
|
106
|
+
|
|
107
|
+
The read-only tools (`list_tokens`, `get_token`, `quote`) run without a key.
|
|
108
|
+
|
|
109
|
+
## Creator earnings
|
|
110
|
+
|
|
111
|
+
Launch a token and you earn a fee on every trade against it. Your earnings accrue to your wallet on-chain. Ask your agent to run `collect_fees` and it claims whatever you're owed in one transaction.
|
|
112
|
+
|
|
113
|
+
## Safety
|
|
114
|
+
|
|
115
|
+
- **Your key stays on your machine.** The server reads it from the environment, signs locally, and never sends it anywhere or writes it to a log.
|
|
116
|
+
- **Slippage is always enforced.** A buy or sell that would fill past your limit reverts instead of costing you.
|
|
117
|
+
- **Fund the wallet with what you're willing to trade.** The agent spends real USDT0 from it.
|
|
118
|
+
|
|
119
|
+
## How it works
|
|
120
|
+
|
|
121
|
+
Token lists, stats, and charts come from the public pearpad data API. Trades and launches go straight to the pearpad contracts through your own RPC, so nothing sits between your agent and the chain. For each token the server reads its curve state and picks the venue: the bonding curve before graduation, the pearpad router after.
|
|
122
|
+
|
|
123
|
+
## Develop
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
git clone https://github.com/pearpad/pearpad-mcp
|
|
127
|
+
cd pearpad-mcp
|
|
128
|
+
npm install
|
|
129
|
+
npm test # read-only self-check: quotes a live token, spends nothing, needs no key
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
package/abi.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Minimal human-readable ABI fragments โ only what the tools call. Full ABIs live in the pearpad
|
|
2
|
+
// repo (pearpad-integration.md); we don't need events or admin fns here.
|
|
3
|
+
|
|
4
|
+
export const FACTORY_ABI = [
|
|
5
|
+
'function launchFee() view returns (uint256)',
|
|
6
|
+
'function launch(string name, string symbol, string metadata, uint256 maxFee) payable returns (address)',
|
|
7
|
+
'function curves(address) view returns (uint256 ethReserve, uint256 tokenReserve, address creator, bool migrated)',
|
|
8
|
+
'function getTokensOut(address token, uint256 ethIn) view returns (uint256)',
|
|
9
|
+
'function getEthOut(address token, uint256 tokensIn) view returns (uint256)',
|
|
10
|
+
'function buy(address token, uint256 minTokensOut) payable returns (uint256)',
|
|
11
|
+
'function sell(address token, uint256 tokensIn, uint256 minEthOut) returns (uint256)',
|
|
12
|
+
'function feesOwed(address) view returns (uint256)',
|
|
13
|
+
'function claimFees()',
|
|
14
|
+
'event Launched(address indexed token, address indexed creator, string metadata)',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export const ROUTER_ABI = [
|
|
18
|
+
'function buy(address token, uint256 amountOutMin) payable returns (uint256)',
|
|
19
|
+
'function sell(address token, uint256 amountIn, uint256 amountOutMin) returns (uint256)',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
export const ERC20_ABI = [
|
|
23
|
+
'function balanceOf(address) view returns (uint256)',
|
|
24
|
+
'function approve(address,uint256) returns (bool)',
|
|
25
|
+
'function allowance(address,address) view returns (uint256)',
|
|
26
|
+
'function symbol() view returns (string)',
|
|
27
|
+
]
|
package/fmt.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// fmt.js โ turns tool data into a cute monospace printout for terminal MCP clients. No deps; just
|
|
2
|
+
// block-drawing chars. Everything an agent needs (full addresses, tx hashes) stays in the text.
|
|
3
|
+
|
|
4
|
+
const BLOCKS = 'โโโโโ
โโโ'
|
|
5
|
+
|
|
6
|
+
const price = (n) => {
|
|
7
|
+
n = Number(n)
|
|
8
|
+
if (!Number.isFinite(n) || n === 0) return '0'
|
|
9
|
+
return n >= 1 ? n.toFixed(4) : n.toPrecision(4)
|
|
10
|
+
}
|
|
11
|
+
const num = (n) => {
|
|
12
|
+
n = Number(n)
|
|
13
|
+
return Number.isFinite(n) ? n.toLocaleString('en-US', { maximumFractionDigits: 0 }) : '?'
|
|
14
|
+
}
|
|
15
|
+
const money = (n) => {
|
|
16
|
+
n = Number(n)
|
|
17
|
+
return Number.isFinite(n) ? n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '?'
|
|
18
|
+
}
|
|
19
|
+
const time = (t) => new Date(Number(t) * 1000).toISOString().slice(5, 16).replace('T', ' ')
|
|
20
|
+
export const wei = (w) => Number(w) / 1e18
|
|
21
|
+
|
|
22
|
+
// inline one-line sparkline
|
|
23
|
+
function sparkline(series) {
|
|
24
|
+
const v = (series || []).map(Number).filter(Number.isFinite)
|
|
25
|
+
if (v.length < 2) return ''
|
|
26
|
+
const min = Math.min(...v), max = Math.max(...v), span = max - min || 1
|
|
27
|
+
return v.map((n) => BLOCKS[Math.min(7, Math.floor(((n - min) / span) * 8))]).join('')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// small area chart with a price axis
|
|
31
|
+
function chart(series, { h = 7, w = 44 } = {}) {
|
|
32
|
+
let v = (series || []).map(Number).filter(Number.isFinite)
|
|
33
|
+
if (v.length < 2) return ' (not enough price data yet)'
|
|
34
|
+
if (v.length > w) { const s = v.length / w; v = Array.from({ length: w }, (_, i) => v[Math.floor(i * s)]) }
|
|
35
|
+
const min = Math.min(...v), max = Math.max(...v), span = max - min || Math.abs(max) || 1
|
|
36
|
+
const rows = []
|
|
37
|
+
for (let r = h - 1; r >= 0; r--) {
|
|
38
|
+
let line = ''
|
|
39
|
+
for (const n of v) {
|
|
40
|
+
const level = ((n - min) / span) * (h - 1)
|
|
41
|
+
line += level >= r + 0.5 ? 'โ' : level >= r ? 'โ' : r === 0 ? 'ยท' : ' '
|
|
42
|
+
}
|
|
43
|
+
const lbl = r === h - 1 ? price(max) : r === 0 ? price(min) : ''
|
|
44
|
+
rows.push(lbl.padStart(12) + ' โ' + line)
|
|
45
|
+
}
|
|
46
|
+
rows.push(' '.repeat(12) + ' โ' + 'โ'.repeat(v.length))
|
|
47
|
+
return rows.join('\n')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function tokenList(tokens) {
|
|
51
|
+
if (!tokens.length) return 'No tokens found.'
|
|
52
|
+
const out = ['๐ PEARPAD TOKENS', '']
|
|
53
|
+
tokens.forEach((t, i) => {
|
|
54
|
+
out.push(`${String(i + 1).padStart(2)}. ${t.symbol} ยท ${t.name} [${t.phase}]`)
|
|
55
|
+
out.push(` ${t.address}`)
|
|
56
|
+
out.push(` price ${price(t.price)} ยท vol ${money(wei(t.volume))} ยท ${num(t.holders)} holders ${sparkline(t.spark)}`)
|
|
57
|
+
out.push('')
|
|
58
|
+
})
|
|
59
|
+
return out.join('\n')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function tokenCard(t) {
|
|
63
|
+
const bar = 'โ'.repeat(46)
|
|
64
|
+
const out = [
|
|
65
|
+
`๐ ${t.symbol} ยท ${t.name}`,
|
|
66
|
+
`${t.address} [${t.phase.toUpperCase()}]`,
|
|
67
|
+
bar,
|
|
68
|
+
`price ${price(t.price)} USDT0`,
|
|
69
|
+
`volume ${money(wei(t.volume))} USDT0`,
|
|
70
|
+
`trades ${num(t.trades)}`,
|
|
71
|
+
`holders ${num(t.holders)}`,
|
|
72
|
+
`reserves ${money(t.reserves.usdt0)} USDT0 / ${num(t.reserves.tokens)} ${t.symbol}`,
|
|
73
|
+
'',
|
|
74
|
+
'price',
|
|
75
|
+
chart(t.spark),
|
|
76
|
+
]
|
|
77
|
+
if (t.topHolders?.length) {
|
|
78
|
+
out.push('', 'top holders')
|
|
79
|
+
for (const h of t.topHolders) out.push(` ${(h.tag || '').padEnd(6)} ${h.pct.toFixed(2).padStart(6)}% ${h.address}`)
|
|
80
|
+
}
|
|
81
|
+
if (t.recentTrades?.length) {
|
|
82
|
+
out.push('', 'recent trades')
|
|
83
|
+
for (const x of t.recentTrades) {
|
|
84
|
+
out.push(` ${time(x.time)} ${x.side.toUpperCase().padEnd(4)} ${num(wei(x.amount_token)).padStart(14)} ${t.symbol} ${wei(x.amount_eth).toFixed(2).padStart(9)} USDT0`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out.join('\n')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function wallet(w) {
|
|
91
|
+
const out = [`๐ WALLET ${w.address}`, `balance ${w.usdt0} USDT0`, '']
|
|
92
|
+
if (!w.holdings.length) { out.push('holdings (none yet)'); return out.join('\n') }
|
|
93
|
+
out.push('holdings')
|
|
94
|
+
for (const h of w.holdings) out.push(` ${h.symbol.padEnd(10)} ${num(h.balance).padStart(16)} ${h.address}`)
|
|
95
|
+
return out.join('\n')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function quote(q) {
|
|
99
|
+
if (q.side === 'buy') return `Quote ยท BUY [${q.migrated ? 'pool' : 'curve'}]\n spend ${q.spendUsdt0} USDT0 โ ~${num(q.tokensOut)} tokens`
|
|
100
|
+
if (q.usdt0Out == null) return `Quote ยท SELL [pool]\n ${q.note}`
|
|
101
|
+
return `Quote ยท SELL [${q.migrated ? 'pool' : 'curve'}]\n sell ${num(q.sellTokens)} tokens โ ~${q.usdt0Out} USDT0`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function bought(r) {
|
|
105
|
+
return `โ BOUGHT ~${num(r.expectedTokens)} tokens (min ${num(r.minTokens)}, ${r.migrated ? 'pool' : 'curve'})\n tx ${r.txHash}`
|
|
106
|
+
}
|
|
107
|
+
export function sold(r) {
|
|
108
|
+
return `โ SOLD ${num(r.soldTokens)} tokens โ ~${r.expectedUsdt0} USDT0 (min ${r.minUsdt0}, ${r.migrated ? 'pool' : 'curve'})\n tx ${r.txHash}`
|
|
109
|
+
}
|
|
110
|
+
export function launched(r) {
|
|
111
|
+
return `๐ LAUNCHED\n token ${r.token}\n dev buy ${r.devBuy} USDT0 (launch fee ${r.launchFee})\n tx ${r.txHash}`
|
|
112
|
+
}
|
|
113
|
+
export function fees(r) {
|
|
114
|
+
return r.claimed
|
|
115
|
+
? `โ CLAIMED ${r.owed} USDT0 creator earnings\n tx ${r.txHash}`
|
|
116
|
+
: `No creator earnings owed yet.`
|
|
117
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pearpad-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for trading, deploying, and managing pearpad.fun tokens on Stable (chain 988) from any agent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "pearpad-mcp": "server.js" },
|
|
7
|
+
"files": ["server.js", "pearpad.js", "fmt.js", "abi.js", "README.md", ".env.example"],
|
|
8
|
+
"scripts": { "start": "node server.js", "test": "node test.js" },
|
|
9
|
+
"keywords": ["mcp", "pearpad", "stable", "defi", "bonding-curve", "agent"],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"engines": { "node": ">=20" },
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
14
|
+
"ethers": "^6.13.0",
|
|
15
|
+
"zod": "^3.23.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
package/pearpad.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// pearpad.js โ all chain + data-API logic for the MCP server. Pure of MCP wiring so test.js can
|
|
2
|
+
// import and exercise the read paths without a wallet. Patterns (fee math, phase routing, legacy
|
|
3
|
+
// gas) lifted from the live pearpad volume bots.
|
|
4
|
+
import { ethers } from 'ethers'
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import { FACTORY_ABI, ROUTER_ABI, ERC20_ABI } from './abi.js'
|
|
7
|
+
|
|
8
|
+
export const FACTORY = '0xF2fe7D1eC701f69aEc294CA625d8520D4C2340c4'
|
|
9
|
+
export const ROUTER = '0xf403be0A82d6400E5cb018A89FDeC6201AF02d98'
|
|
10
|
+
export const API = process.env.PEARPAD_API || 'https://pearpad.fun/api'
|
|
11
|
+
const IPFS_API = `${API}/ipfs`
|
|
12
|
+
const RPC = process.env.PEARPAD_RPC_URL || 'https://rpc.stable.xyz'
|
|
13
|
+
const ZERO = '0x0000000000000000000000000000000000000000'
|
|
14
|
+
|
|
15
|
+
export const provider = new ethers.JsonRpcProvider(RPC, 988, { staticNetwork: true })
|
|
16
|
+
const factory = new ethers.Contract(FACTORY, FACTORY_ABI, provider)
|
|
17
|
+
|
|
18
|
+
// Wallet built lazily โ read-only tools and the self-test never need a key.
|
|
19
|
+
let _signer
|
|
20
|
+
export function getSigner() {
|
|
21
|
+
if (_signer) return _signer
|
|
22
|
+
const key = process.env.PEARPAD_PRIVATE_KEY
|
|
23
|
+
if (!key) throw new Error('PEARPAD_PRIVATE_KEY not set โ required for buy/sell/launch/collect_fees/wallet')
|
|
24
|
+
_signer = new ethers.Wallet(key.startsWith('0x') ? key : '0x' + key, provider)
|
|
25
|
+
return _signer
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Stable pays NO priority tip โ a tip is lost funds. Every send is legacy (type-0) with explicit
|
|
29
|
+
// gasPrice. gasPrice is re-read per call (cheap, keeps up with the chain).
|
|
30
|
+
async function txOpts(extra = {}) {
|
|
31
|
+
const gasPrice = (await provider.getFeeData()).gasPrice
|
|
32
|
+
return { type: 0, gasPrice, ...extra }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const parseUSDT0 = (s) => ethers.parseEther(String(s)) // 18-dec native
|
|
36
|
+
export const fmtUSDT0 = (w) => ethers.formatEther(w)
|
|
37
|
+
export const parseTokens = (s) => ethers.parseUnits(String(s), 18)
|
|
38
|
+
export const fmtTokens = (w) => ethers.formatUnits(w, 18)
|
|
39
|
+
|
|
40
|
+
// Sent on every pearpad API call so usage is greppable in nginx logs โ free adoption visibility, no keys.
|
|
41
|
+
const UA = 'pearpad-mcp/1.0'
|
|
42
|
+
|
|
43
|
+
async function api(path) {
|
|
44
|
+
const res = await fetch(`${API}${path}`, { headers: { 'user-agent': UA } })
|
|
45
|
+
if (!res.ok) throw new Error(`GET ${path} -> ${res.status}`)
|
|
46
|
+
return res.json()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- data (indexer) ----
|
|
50
|
+
export async function listTokens({ search, sort = 'newest', limit = 20 } = {}) {
|
|
51
|
+
let { tokens } = await api('/tokens')
|
|
52
|
+
if (search) {
|
|
53
|
+
const q = search.toLowerCase()
|
|
54
|
+
tokens = tokens.filter((t) => `${t.name} ${t.symbol}`.toLowerCase().includes(q))
|
|
55
|
+
}
|
|
56
|
+
tokens.sort((a, b) => (sort === 'oldest' ? a.deploy_block - b.deploy_block : b.deploy_block - a.deploy_block))
|
|
57
|
+
return tokens.slice(0, limit).map((t) => ({
|
|
58
|
+
address: t.address, name: t.name, symbol: t.symbol,
|
|
59
|
+
migrated: t.migrated_block != null, phase: t.migrated_block != null ? 'migrated' : 'curve',
|
|
60
|
+
price: t.last_price_eth, volume: t.volume_eth, trades: t.trade_count,
|
|
61
|
+
holders: t.holders_count, creator: t.creator, spark: t.spark || [],
|
|
62
|
+
}))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const tagOf = (addr, t) => {
|
|
66
|
+
const a = String(addr).toLowerCase()
|
|
67
|
+
if (a === FACTORY.toLowerCase()) return 'CURVE'
|
|
68
|
+
if (t.pool && a === String(t.pool).toLowerCase()) return 'POOL'
|
|
69
|
+
if (t.creator && a === String(t.creator).toLowerCase()) return 'DEV'
|
|
70
|
+
return ''
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function getToken(address, include = []) {
|
|
74
|
+
const { token: t } = await api(`/tokens/${address}`)
|
|
75
|
+
const ph = await phaseOf(address)
|
|
76
|
+
const out = {
|
|
77
|
+
address: t.address, symbol: t.symbol, name: t.name,
|
|
78
|
+
phase: ph.migrated ? 'migrated' : 'curve', reserves: ph.reserves,
|
|
79
|
+
price: t.last_price_eth, volume: t.volume_eth, trades: t.trade_count,
|
|
80
|
+
holders: t.holders_count, creator: t.creator, pool: t.pool, spark: t.spark || [],
|
|
81
|
+
}
|
|
82
|
+
if (include.includes('holders')) {
|
|
83
|
+
const hs = (await api(`/tokens/${address}/holders?limit=20`)).holders
|
|
84
|
+
out.topHolders = hs.map((h) => ({ address: h.address, pct: (Number(h.balance) / 1e18 / 1e9) * 100, tag: tagOf(h.address, t) }))
|
|
85
|
+
}
|
|
86
|
+
if (include.includes('trades')) out.recentTrades = (await api(`/tokens/${address}/trades?limit=10`)).trades
|
|
87
|
+
if (include.includes('candles')) out.candles = (await api(`/tokens/${address}/bars?res=3600`)).bars
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- phase ----
|
|
92
|
+
export async function phaseOf(token) {
|
|
93
|
+
const c = await factory.curves(token)
|
|
94
|
+
if (c.creator === ZERO) throw new Error(`${token} is not a pearpad token`)
|
|
95
|
+
return {
|
|
96
|
+
creator: c.creator, migrated: c.migrated,
|
|
97
|
+
reserves: { usdt0: fmtUSDT0(c.ethReserve), tokens: fmtTokens(c.tokenReserve) },
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- quotes ----
|
|
102
|
+
// Buy: how many tokens `valueWei` USDT0 buys. Curve strips the 1.3% fee before quoting; router is
|
|
103
|
+
// simulated (its 1% fee is applied internally, so simulate on the full value).
|
|
104
|
+
export async function quoteBuy(token, valueWei) {
|
|
105
|
+
const { migrated } = await phaseOf(token)
|
|
106
|
+
if (!migrated) {
|
|
107
|
+
const ethIn = (valueWei * 10000n) / 10130n
|
|
108
|
+
return { migrated, tokensOut: await factory.getTokensOut(token, ethIn) }
|
|
109
|
+
}
|
|
110
|
+
const router = new ethers.Contract(ROUTER, ROUTER_ABI, provider)
|
|
111
|
+
const from = _signer?.address
|
|
112
|
+
const tokensOut = await router.buy.staticCall(token, 0n, { value: valueWei, from })
|
|
113
|
+
return { migrated, tokensOut }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Sell: how much USDT0 `amountWei` tokens returns. Curve is a pure view. Router has no on-chain
|
|
117
|
+
// quoter โ it must be simulated, which needs an allowance (transferFrom runs inside). If the token
|
|
118
|
+
// isn't approved yet we can't simulate; the sell tool approves first, so it always gets an exact
|
|
119
|
+
// quote. Here we flag it instead of guessing.
|
|
120
|
+
export async function quoteSell(token, amountWei) {
|
|
121
|
+
const { migrated } = await phaseOf(token)
|
|
122
|
+
if (!migrated) {
|
|
123
|
+
const gross = await factory.getEthOut(token, amountWei)
|
|
124
|
+
return { migrated, usdt0Out: gross - (gross * 130n) / 10000n }
|
|
125
|
+
}
|
|
126
|
+
const from = _signer?.address
|
|
127
|
+
if (from) {
|
|
128
|
+
const t = new ethers.Contract(token, ERC20_ABI, provider)
|
|
129
|
+
if ((await t.allowance(from, ROUTER)) >= amountWei) {
|
|
130
|
+
const router = new ethers.Contract(ROUTER, ROUTER_ABI, provider)
|
|
131
|
+
return { migrated, usdt0Out: await router.sell.staticCall(token, amountWei, 0n, { from }) }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { migrated, usdt0Out: null, note: 'migrated sell โ exact quote needs a one-time token approval; run `sell` (it approves, quotes, and slippage-guards in one step)' }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const minus = (x, pct) => (x * BigInt(Math.round((100 - pct) * 100))) / 10000n
|
|
138
|
+
|
|
139
|
+
// ---- trades ----
|
|
140
|
+
export async function buy(token, valueWei, slippagePct = 5) {
|
|
141
|
+
const w = getSigner()
|
|
142
|
+
const { migrated, tokensOut } = await quoteBuy(token, valueWei)
|
|
143
|
+
const minOut = minus(tokensOut, slippagePct)
|
|
144
|
+
const venue = migrated ? new ethers.Contract(ROUTER, ROUTER_ABI, w) : factory.connect(w)
|
|
145
|
+
// NO gasLimit on a curve buy: the buy that crosses TARGET triggers migration (~4M gas). Let ethers
|
|
146
|
+
// estimate so a near-graduation buy can't revert on a too-tight limit.
|
|
147
|
+
const rc = await (await venue.buy(token, minOut, await txOpts({ value: valueWei }))).wait()
|
|
148
|
+
return { txHash: rc.hash, expectedTokens: fmtTokens(tokensOut), minTokens: fmtTokens(minOut), migrated }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// amount = decimal token string, OR percent (0-100) of the wallet's balance.
|
|
152
|
+
export async function sell(token, { amount, percent } = {}, slippagePct = 5) {
|
|
153
|
+
const w = getSigner()
|
|
154
|
+
const { migrated } = await phaseOf(token)
|
|
155
|
+
const t = new ethers.Contract(token, ERC20_ABI, w)
|
|
156
|
+
let amountWei
|
|
157
|
+
if (percent != null) {
|
|
158
|
+
const bal = await t.balanceOf(w.address)
|
|
159
|
+
amountWei = (bal * BigInt(Math.round(percent * 100))) / 10000n
|
|
160
|
+
} else if (amount != null) {
|
|
161
|
+
amountWei = parseTokens(amount)
|
|
162
|
+
} else throw new Error('sell needs `amount` (tokens) or `percent` (0-100)')
|
|
163
|
+
if (amountWei === 0n) throw new Error('nothing to sell')
|
|
164
|
+
|
|
165
|
+
const venue = migrated ? ROUTER : FACTORY
|
|
166
|
+
if ((await t.allowance(w.address, venue)) < amountWei) {
|
|
167
|
+
await (await t.approve(venue, ethers.MaxUint256, await txOpts({ gasLimit: 100000n }))).wait()
|
|
168
|
+
}
|
|
169
|
+
const q = await quoteSell(token, amountWei)
|
|
170
|
+
const minOut = minus(q.usdt0Out, slippagePct)
|
|
171
|
+
const seller = migrated ? new ethers.Contract(ROUTER, ROUTER_ABI, w) : factory.connect(w)
|
|
172
|
+
const rc = await (await seller.sell(token, amountWei, minOut, await txOpts({ gasLimit: 500000n }))).wait()
|
|
173
|
+
return { txHash: rc.hash, soldTokens: fmtTokens(amountWei), expectedUsdt0: fmtUSDT0(q.usdt0Out), minUsdt0: fmtUSDT0(minOut), migrated }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- launch ----
|
|
177
|
+
async function uploadImage(image) {
|
|
178
|
+
const form = new FormData()
|
|
179
|
+
const blob = /^https?:\/\//.test(image)
|
|
180
|
+
? await (await fetch(image)).blob()
|
|
181
|
+
: new Blob([fs.readFileSync(image)])
|
|
182
|
+
form.append('file', blob, 'token.png')
|
|
183
|
+
const up = await fetch(`${IPFS_API}/launch/upload-image`, { method: 'POST', body: form, headers: { 'user-agent': UA } })
|
|
184
|
+
if (!up.ok) throw new Error(`image upload ${up.status}`)
|
|
185
|
+
return (await up.json()).cid
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function launch({ name, symbol, description = '', image, devBuy = '0' }) {
|
|
189
|
+
const w = getSigner()
|
|
190
|
+
let imageCid = ''
|
|
191
|
+
if (image) imageCid = await uploadImage(image)
|
|
192
|
+
const prep = await fetch(`${IPFS_API}/launch/prepare`, {
|
|
193
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': UA },
|
|
194
|
+
body: JSON.stringify({ name, symbol, description: description.slice(0, 280), imageCid }),
|
|
195
|
+
})
|
|
196
|
+
if (!prep.ok) throw new Error(`prepare ${prep.status}: ${await prep.text()}`)
|
|
197
|
+
const { uri } = await prep.json()
|
|
198
|
+
|
|
199
|
+
const fee = await factory.launchFee()
|
|
200
|
+
const value = fee + parseUSDT0(devBuy)
|
|
201
|
+
const tx = await factory.connect(w).launch(name, symbol, uri, fee, await txOpts({ value, gasLimit: 4000000n }))
|
|
202
|
+
const rc = await tx.wait()
|
|
203
|
+
const token = rc.logs
|
|
204
|
+
.map((l) => { try { return factory.interface.parseLog(l) } catch { return null } })
|
|
205
|
+
.find((l) => l?.name === 'Launched')?.args?.token
|
|
206
|
+
if (!token) throw new Error(`no Launched event in tx ${tx.hash} โ dev buy may be stranded`)
|
|
207
|
+
return { token, txHash: tx.hash, metadata: uri, devBuy, launchFee: fmtUSDT0(fee) }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- creator fees ----
|
|
211
|
+
export async function collectFees() {
|
|
212
|
+
const w = getSigner()
|
|
213
|
+
const owed = await factory.feesOwed(w.address)
|
|
214
|
+
if (owed === 0n) return { owed: '0', claimed: false, message: 'no creator earnings owed' }
|
|
215
|
+
const rc = await (await factory.connect(w).claimFees(await txOpts())).wait()
|
|
216
|
+
return { owed: fmtUSDT0(owed), claimed: true, txHash: rc.hash }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---- wallet ----
|
|
220
|
+
// ponytail: balances via one balanceOf per launched token โ fine at dozens of tokens; swap to
|
|
221
|
+
// Multicall3 (0xcA11โฆCA11 is live on 988) if the token count grows into the hundreds.
|
|
222
|
+
export async function walletInfo() {
|
|
223
|
+
const w = getSigner()
|
|
224
|
+
const usdt0 = fmtUSDT0(await provider.getBalance(w.address))
|
|
225
|
+
const { tokens } = await api('/tokens')
|
|
226
|
+
const bals = await Promise.all(tokens.map(async (t) => {
|
|
227
|
+
const bal = await new ethers.Contract(t.address, ERC20_ABI, provider).balanceOf(w.address).catch(() => 0n)
|
|
228
|
+
return bal > 0n ? { address: t.address, symbol: t.symbol, balance: fmtTokens(bal) } : null
|
|
229
|
+
}))
|
|
230
|
+
return { address: w.address, usdt0, holdings: bals.filter(Boolean) }
|
|
231
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pearpad-mcp โ MCP server exposing pearpad.fun trading to any agent. Wallet key from
|
|
3
|
+
// PEARPAD_PRIVATE_KEY (env). Reads use the public pearpad API; trades go straight on-chain.
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import * as pp from './pearpad.js'
|
|
8
|
+
import * as fmt from './fmt.js'
|
|
9
|
+
|
|
10
|
+
const server = new McpServer({ name: 'pearpad-mcp', version: '1.0.0' })
|
|
11
|
+
|
|
12
|
+
// Wrap a handler so any throw comes back as a clean tool error instead of crashing the process.
|
|
13
|
+
// fn returns a pre-formatted string (the cute printout the client shows).
|
|
14
|
+
const tool = (name, desc, shape, fn) =>
|
|
15
|
+
server.tool(name, desc, shape, async (args) => {
|
|
16
|
+
try {
|
|
17
|
+
return { content: [{ type: 'text', text: await fn(args) }] }
|
|
18
|
+
} catch (e) {
|
|
19
|
+
return { content: [{ type: 'text', text: `Error: ${e.shortMessage || e.message}` }], isError: true }
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const usdt0 = z.string().describe('USDT0 amount as a decimal string, e.g. "50" for 50 USDT0')
|
|
24
|
+
|
|
25
|
+
tool('list_tokens', 'List pearpad tokens (newest first by default). Optionally search by name/symbol.',
|
|
26
|
+
{ search: z.string().optional(), sort: z.enum(['newest', 'oldest']).optional(), limit: z.number().int().optional() },
|
|
27
|
+
async (a) => fmt.tokenList(await pp.listTokens(a)))
|
|
28
|
+
|
|
29
|
+
tool('get_token', 'Get one token: stats, phase (curve vs migrated), reserves, price chart. Add include for trades/holders/candles.',
|
|
30
|
+
{ address: z.string(), include: z.array(z.enum(['trades', 'holders', 'candles'])).optional() },
|
|
31
|
+
async (a) => fmt.tokenCard(await pp.getToken(a.address, a.include || [])))
|
|
32
|
+
|
|
33
|
+
tool('quote', 'Preview a trade before doing it. Buy: how many tokens `usdt0` buys. Sell: how much USDT0 `amount` tokens returns. Auto-routes curve vs migrated pool.',
|
|
34
|
+
{ token: z.string(), side: z.enum(['buy', 'sell']), usdt0: usdt0.optional(), amount: z.string().optional().describe('token amount, for sell') },
|
|
35
|
+
async (a) => {
|
|
36
|
+
if (a.side === 'buy') {
|
|
37
|
+
if (!a.usdt0) throw new Error('buy quote needs `usdt0`')
|
|
38
|
+
const q = await pp.quoteBuy(a.token, pp.parseUSDT0(a.usdt0))
|
|
39
|
+
return fmt.quote({ side: 'buy', migrated: q.migrated, spendUsdt0: a.usdt0, tokensOut: pp.fmtTokens(q.tokensOut) })
|
|
40
|
+
}
|
|
41
|
+
if (!a.amount) throw new Error('sell quote needs `amount`')
|
|
42
|
+
const q = await pp.quoteSell(a.token, pp.parseTokens(a.amount))
|
|
43
|
+
return fmt.quote({ side: 'sell', migrated: q.migrated, sellTokens: a.amount, usdt0Out: q.usdt0Out == null ? null : pp.fmtUSDT0(q.usdt0Out), note: q.note })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
tool('buy', 'Buy a token with USDT0 from the configured wallet. Quotes first and enforces slippage. Auto-routes curve vs pool.',
|
|
47
|
+
{ token: z.string(), usdt0, slippage_pct: z.number().optional().describe('default 5') },
|
|
48
|
+
async (a) => fmt.bought(await pp.buy(a.token, pp.parseUSDT0(a.usdt0), a.slippage_pct ?? 5)))
|
|
49
|
+
|
|
50
|
+
tool('sell', 'Sell a token back to USDT0. Give `amount` (tokens) OR `percent` (0-100) of holdings. Auto-approves, quotes, slippage-guards, auto-routes.',
|
|
51
|
+
{ token: z.string(), amount: z.string().optional(), percent: z.number().optional(), slippage_pct: z.number().optional() },
|
|
52
|
+
async (a) => fmt.sold(await pp.sell(a.token, { amount: a.amount, percent: a.percent }, a.slippage_pct ?? 5)))
|
|
53
|
+
|
|
54
|
+
tool('launch', 'Deploy a new pearpad token. image = URL or local file path (optional). dev_buy = USDT0 to buy of your own token in the same tx (optional).',
|
|
55
|
+
{ name: z.string(), symbol: z.string(), description: z.string().optional(), image: z.string().optional(), dev_buy: usdt0.optional() },
|
|
56
|
+
async (a) => fmt.launched(await pp.launch({ name: a.name, symbol: a.symbol, description: a.description, image: a.image, devBuy: a.dev_buy || '0' })))
|
|
57
|
+
|
|
58
|
+
tool('collect_fees', 'Claim creator earnings owed to the configured wallet from tokens it launched.',
|
|
59
|
+
{}, async () => fmt.fees(await pp.collectFees()))
|
|
60
|
+
|
|
61
|
+
tool('wallet', 'Show the configured wallet: address, USDT0 balance, and pearpad token holdings.',
|
|
62
|
+
{}, async () => fmt.wallet(await pp.walletInfo()))
|
|
63
|
+
|
|
64
|
+
await server.connect(new StdioServerTransport())
|