nansen-cli 1.26.1 → 1.27.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/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/skills/nansen-alerts-webhook-listener/SKILL.md +386 -0
- package/skills/nansen-trading/SKILL.md +12 -1
- package/src/api.js +59 -14
- package/src/cli.js +27 -10
- package/src/index.js +1 -1
- package/src/schema.json +39 -6
- package/src/trade-validation.js +31 -0
- package/src/transfer.js +13 -10
- package/src/wallet.js +12 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.27.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#403](https://github.com/nansen-ai/nansen-cli/pull/403) [`fe53dbe`](https://github.com/nansen-ai/nansen-cli/commit/fe53dbe8cf743b970e145e7ad7a00470f16f28df) Thanks [@marius-reed](https://github.com/marius-reed)! - Add prediction market filtering (order_by, volume/liquidity/OI/trader/price/date filters, neg_risk, tags) and address-summary endpoint
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- [#402](https://github.com/nansen-ai/nansen-cli/pull/402) [`cfd94ce`](https://github.com/nansen-ai/nansen-cli/commit/cfd94ce1e1880e36fb0c97d0ecfe37e614898006) Thanks [@TimNooren](https://github.com/TimNooren)! - Enforce USDC or native token on one side of every swap
|
|
12
|
+
|
|
3
13
|
## 1.26.1
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nansen-alerts-webhook-listener
|
|
3
|
+
description: Set up a local webhook server to receive Nansen smart alerts in real-time with HMAC signature verification and public tunneling. Use when a user wants to listen for alerts on their local machine.
|
|
4
|
+
metadata:
|
|
5
|
+
openclaw:
|
|
6
|
+
requires:
|
|
7
|
+
env:
|
|
8
|
+
- NANSEN_API_KEY
|
|
9
|
+
bins:
|
|
10
|
+
- nansen
|
|
11
|
+
- node
|
|
12
|
+
primaryEnv: NANSEN_API_KEY
|
|
13
|
+
install:
|
|
14
|
+
- kind: node
|
|
15
|
+
package: nansen-cli
|
|
16
|
+
bins: [nansen]
|
|
17
|
+
allowed-tools: Bash(nansen:*), Bash(node:*), Bash(npx:*), Bash(ngrok:*), Write
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# Alert Webhook Listener
|
|
21
|
+
|
|
22
|
+
Set up a local HTTP server to receive Nansen smart alert webhook payloads in real-time.
|
|
23
|
+
|
|
24
|
+
## How It Works
|
|
25
|
+
|
|
26
|
+
Nansen smart alerts support a **webhook** channel type. When an alert fires, Nansen sends an HTTP POST with a JSON payload to your webhook URL. This skill sets up:
|
|
27
|
+
|
|
28
|
+
1. A local HTTP server (Node.js, zero external dependencies) that receives and displays alert payloads
|
|
29
|
+
2. HMAC-SHA256 signature verification so only authentic Nansen payloads are accepted
|
|
30
|
+
3. A public tunnel so Nansen's servers can reach your local machine
|
|
31
|
+
|
|
32
|
+
**This skill does NOT create or modify alerts.** It sets up the listener infrastructure and then provides a summary of what the user needs to do to start receiving alerts.
|
|
33
|
+
|
|
34
|
+
**OpenClaw users:** If OpenClaw is running locally on the same machine, the webhook server can forward verified alert payloads to OpenClaw's Gateway (`/hooks/agent`), triggering an agent turn for each alert. Set the `OPENCLAW_GATEWAY_URL` env var to enable this. See the **OpenClaw Integration** section below.
|
|
35
|
+
|
|
36
|
+
## Security Warning
|
|
37
|
+
|
|
38
|
+
**Before proceeding, inform the user:**
|
|
39
|
+
|
|
40
|
+
> This skill starts an HTTP server on your machine and exposes it to the internet via a tunnel (ngrok or localtunnel). While the server only binds to localhost (`127.0.0.1`) — meaning no one on your local network can access it directly — the tunnel creates a public URL that **anyone on the internet** can send requests to.
|
|
41
|
+
>
|
|
42
|
+
> **Mitigations in place:**
|
|
43
|
+
> - HMAC-SHA256 signature verification rejects all requests not signed by Nansen
|
|
44
|
+
> - 1 MB body size limit prevents memory abuse
|
|
45
|
+
> - Only `POST /webhook` and `GET /health` are accepted; everything else returns 404
|
|
46
|
+
>
|
|
47
|
+
> **You should be aware that:**
|
|
48
|
+
> - The tunnel URL is publicly discoverable (ngrok URLs can be enumerated)
|
|
49
|
+
> - Unsigned requests still reach your machine — they're rejected, but the connection is made
|
|
50
|
+
> - Stop the tunnel when you're done to close the public endpoint
|
|
51
|
+
|
|
52
|
+
Wait for the user to confirm they want to proceed before continuing.
|
|
53
|
+
|
|
54
|
+
## Execution Plan
|
|
55
|
+
|
|
56
|
+
Follow these steps **in order**. Do not skip signature verification — it is mandatory.
|
|
57
|
+
|
|
58
|
+
### Step 0: Choose a tunnel provider
|
|
59
|
+
|
|
60
|
+
Before starting, ask the user which tunnel provider they want to use:
|
|
61
|
+
|
|
62
|
+
| | **ngrok** (recommended) | **localtunnel** |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| Stability | Stable — persistent connections with keepalive | Flaky — free relay drops idle connections without warning, tunnels die randomly |
|
|
65
|
+
| Install | `brew install ngrok` + free account at ngrok.com | Zero install (`npx localtunnel`) |
|
|
66
|
+
| HTTPS | Yes | Yes |
|
|
67
|
+
| Auth required | Yes (free authtoken from ngrok.com) | No |
|
|
68
|
+
|
|
69
|
+
**Recommend ngrok.** localtunnel is convenient but unreliable — in testing, tunnels silently exit after minutes, causing alerts to fail with "503 Tunnel Unavailable". ngrok maintains stable connections.
|
|
70
|
+
|
|
71
|
+
Check if ngrok is available:
|
|
72
|
+
```bash
|
|
73
|
+
which ngrok && ngrok version
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
If not installed, tell the user:
|
|
77
|
+
1. `brew install ngrok` (or download from ngrok.com)
|
|
78
|
+
2. Create a free account at ngrok.com and copy the authtoken
|
|
79
|
+
3. `ngrok config add-authtoken <token>`
|
|
80
|
+
|
|
81
|
+
If the user prefers localtunnel or can't install ngrok, proceed with localtunnel but warn them that the tunnel may drop and they'll need to restart it and update their alert's webhook URL.
|
|
82
|
+
|
|
83
|
+
### Step 1: Generate a webhook secret
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Store the output — you need it for both the server and the alert configuration. **Never log or echo the secret after this point.**
|
|
90
|
+
|
|
91
|
+
### Step 2: Write the webhook receiver script
|
|
92
|
+
|
|
93
|
+
Create `nansen-webhook-server.mjs` in the current working directory. Use **only** Node.js built-in modules (`node:http`, `node:crypto`). No `npm install` required.
|
|
94
|
+
|
|
95
|
+
**Requirements — do not deviate:**
|
|
96
|
+
|
|
97
|
+
| Requirement | Detail |
|
|
98
|
+
|---|---|
|
|
99
|
+
| Bind address | `127.0.0.1` only — **never** `0.0.0.0` |
|
|
100
|
+
| Default port | `9477` (override via `PORT` env var) |
|
|
101
|
+
| Webhook path | `POST /webhook` — reject all other method/path combos with 404 |
|
|
102
|
+
| Health check | `GET /health` → 200 `{"status":"ok"}` |
|
|
103
|
+
| Signature verification | Verify `x-nansen-signature` header using HMAC-SHA256 with timing-safe comparison. Reject 401 on mismatch. |
|
|
104
|
+
| Secret validation | Exit on startup if `WEBHOOK_SECRET` env var is missing or < 16 chars |
|
|
105
|
+
| Payload logging | Pretty-print valid JSON payloads to stdout with ISO timestamp |
|
|
106
|
+
| Request size limit | Reject bodies > 1 MB (413) to prevent memory abuse |
|
|
107
|
+
| Graceful shutdown | Handle `SIGINT` and `SIGTERM` — close server, then exit |
|
|
108
|
+
| OpenClaw forwarding | If `OPENCLAW_GATEWAY_URL` env var is set, forward verified payloads to `<url>/hooks/agent` via POST. Include `OPENCLAW_AUTH_TOKEN` as Bearer token if set. Log forward success/failure. |
|
|
109
|
+
| No dependencies | Only `node:http`, `node:https`, and `node:crypto` — nothing from npm |
|
|
110
|
+
|
|
111
|
+
**Signature verification — use timing-safe comparison:**
|
|
112
|
+
|
|
113
|
+
```javascript
|
|
114
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
115
|
+
|
|
116
|
+
function verifySignature(rawBody, signatureHeader, secret) {
|
|
117
|
+
if (!signatureHeader || !secret) return false;
|
|
118
|
+
// Nansen sends "sha256=<hex>" — strip the prefix before comparing
|
|
119
|
+
const sig = signatureHeader.startsWith('sha256=') ? signatureHeader.slice(7) : signatureHeader;
|
|
120
|
+
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
|
|
121
|
+
try {
|
|
122
|
+
return timingSafeEqual(Buffer.from(sig, 'utf8'), Buffer.from(expected, 'utf8'));
|
|
123
|
+
} catch {
|
|
124
|
+
return false; // length mismatch
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Full server template:**
|
|
130
|
+
|
|
131
|
+
```javascript
|
|
132
|
+
import { createServer } from 'node:http';
|
|
133
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
134
|
+
|
|
135
|
+
const PORT = parseInt(process.env.PORT || '9477', 10);
|
|
136
|
+
const SECRET = process.env.WEBHOOK_SECRET;
|
|
137
|
+
const MAX_BODY = 1_048_576; // 1 MB
|
|
138
|
+
|
|
139
|
+
// Optional: forward verified payloads to a local OpenClaw Gateway
|
|
140
|
+
const OPENCLAW_URL = process.env.OPENCLAW_GATEWAY_URL; // e.g. http://localhost:3000
|
|
141
|
+
const OPENCLAW_TOKEN = process.env.OPENCLAW_AUTH_TOKEN;
|
|
142
|
+
|
|
143
|
+
if (!SECRET || SECRET.length < 16) {
|
|
144
|
+
console.error('WEBHOOK_SECRET env var required (minimum 16 characters).');
|
|
145
|
+
console.error('Generate one: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function verifySignature(rawBody, signatureHeader) {
|
|
150
|
+
if (!signatureHeader) return false;
|
|
151
|
+
// Nansen sends "sha256=<hex>" — strip the prefix before comparing
|
|
152
|
+
const sig = signatureHeader.startsWith('sha256=') ? signatureHeader.slice(7) : signatureHeader;
|
|
153
|
+
const expected = createHmac('sha256', SECRET).update(rawBody).digest('hex');
|
|
154
|
+
try {
|
|
155
|
+
return timingSafeEqual(Buffer.from(sig, 'utf8'), Buffer.from(expected, 'utf8'));
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function forwardToOpenClaw(payload) {
|
|
162
|
+
if (!OPENCLAW_URL) return;
|
|
163
|
+
const url = `${OPENCLAW_URL.replace(/\/+$/, '')}/hooks/agent`;
|
|
164
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
165
|
+
if (OPENCLAW_TOKEN) headers['Authorization'] = `Bearer ${OPENCLAW_TOKEN}`;
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch(url, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers,
|
|
170
|
+
body: JSON.stringify(payload),
|
|
171
|
+
});
|
|
172
|
+
if (res.ok) {
|
|
173
|
+
console.log(`[${ts()}] Forwarded to OpenClaw (${res.status})`);
|
|
174
|
+
} else {
|
|
175
|
+
console.error(`[${ts()}] OpenClaw forward failed (${res.status})`);
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.error(`[${ts()}] OpenClaw forward error: ${err.message}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function ts() { return new Date().toISOString(); }
|
|
183
|
+
|
|
184
|
+
const server = createServer((req, res) => {
|
|
185
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
186
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
187
|
+
return res.end('{"status":"ok"}');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (req.method !== 'POST' || req.url !== '/webhook') {
|
|
191
|
+
res.writeHead(404);
|
|
192
|
+
return res.end();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let size = 0;
|
|
196
|
+
const chunks = [];
|
|
197
|
+
|
|
198
|
+
req.on('data', (chunk) => {
|
|
199
|
+
size += chunk.length;
|
|
200
|
+
if (size > MAX_BODY) {
|
|
201
|
+
res.writeHead(413);
|
|
202
|
+
res.end('{"error":"Payload too large"}');
|
|
203
|
+
req.destroy();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
chunks.push(chunk);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
req.on('end', () => {
|
|
210
|
+
if (res.writableEnded) return;
|
|
211
|
+
|
|
212
|
+
const rawBody = Buffer.concat(chunks).toString('utf8');
|
|
213
|
+
const signature = req.headers['x-nansen-signature'];
|
|
214
|
+
|
|
215
|
+
if (!verifySignature(rawBody, signature)) {
|
|
216
|
+
console.error(`[${ts()}] REJECTED — invalid signature`);
|
|
217
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
218
|
+
return res.end('{"error":"Invalid signature"}');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let payload;
|
|
222
|
+
try {
|
|
223
|
+
payload = JSON.parse(rawBody);
|
|
224
|
+
console.log(`\n[${ts()}] Alert received:`);
|
|
225
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
226
|
+
} catch {
|
|
227
|
+
console.error(`[${ts()}] WARNING — valid signature but malformed JSON`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Forward to OpenClaw if configured (fire-and-forget — don't block response)
|
|
231
|
+
if (payload) forwardToOpenClaw(payload);
|
|
232
|
+
|
|
233
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
234
|
+
res.end('{"received":true}');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
239
|
+
process.on(sig, () => {
|
|
240
|
+
console.log(`\n${sig} — shutting down`);
|
|
241
|
+
server.close(() => process.exit(0));
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
246
|
+
console.log(`Webhook listener ready — http://127.0.0.1:${PORT}/webhook`);
|
|
247
|
+
if (OPENCLAW_URL) console.log(`OpenClaw forwarding → ${OPENCLAW_URL}/hooks/agent`);
|
|
248
|
+
console.log('Waiting for alerts… (Ctrl+C to stop)\n');
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Step 3: Start the server and tunnel
|
|
253
|
+
|
|
254
|
+
Start the server:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
WEBHOOK_SECRET='<secret>' node nansen-webhook-server.mjs
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Then start a public tunnel so Nansen's servers can reach it.
|
|
261
|
+
|
|
262
|
+
**ngrok (recommended):**
|
|
263
|
+
```bash
|
|
264
|
+
ngrok http 9477
|
|
265
|
+
```
|
|
266
|
+
Get the public URL from ngrok's output or its local API:
|
|
267
|
+
```bash
|
|
268
|
+
curl -s http://127.0.0.1:4040/api/tunnels | node -e "process.stdin.on('data',d=>console.log(JSON.parse(d).tunnels[0]?.public_url))"
|
|
269
|
+
```
|
|
270
|
+
The webhook URL is `https://<subdomain>.ngrok-free.dev/webhook`.
|
|
271
|
+
|
|
272
|
+
**localtunnel (fallback — unreliable):**
|
|
273
|
+
```bash
|
|
274
|
+
npx localtunnel --port 9477
|
|
275
|
+
```
|
|
276
|
+
Prints a URL like `https://xxx.loca.lt`. The webhook URL is `https://xxx.loca.lt/webhook`.
|
|
277
|
+
|
|
278
|
+
**Warning:** localtunnel's free relay silently drops connections after minutes. When this happens, all alerts fail with "503 Tunnel Unavailable" until you restart the tunnel and update the alert webhook URL. Use ngrok unless you have a reason not to.
|
|
279
|
+
|
|
280
|
+
**Note:** Tunnel URLs are ephemeral — they change every restart. For permanent setups, deploy the server to a host with a static URL.
|
|
281
|
+
|
|
282
|
+
### Step 4: Provide a next-steps summary
|
|
283
|
+
|
|
284
|
+
**Do NOT create or modify any alerts.** Instead, print a clear summary for the user explaining what was set up and what they need to do next.
|
|
285
|
+
|
|
286
|
+
The summary MUST include:
|
|
287
|
+
1. Confirmation of what was created (the server script path and the generated secret)
|
|
288
|
+
2. The commands to start the server and tunnel (with the actual secret filled in)
|
|
289
|
+
3. The exact `nansen alerts create` or `nansen alerts update` command they should run, with the `--webhook` and `--webhook-secret` flags filled in with the tunnel URL and secret — but leave the alert-specific flags (`--name`, `--type`, `--chains`, etc.) as placeholders for the user to fill in
|
|
290
|
+
4. A reminder that the server and tunnel must be running before the alert is created (Nansen validates the webhook endpoint on creation)
|
|
291
|
+
5. A note that tunnel URLs are ephemeral and will change on restart
|
|
292
|
+
|
|
293
|
+
Example summary format:
|
|
294
|
+
|
|
295
|
+
```
|
|
296
|
+
## Webhook listener ready
|
|
297
|
+
|
|
298
|
+
**Server script:** ./nansen-webhook-server.mjs
|
|
299
|
+
**Port:** 9477
|
|
300
|
+
|
|
301
|
+
### To start receiving alerts:
|
|
302
|
+
|
|
303
|
+
1. Start the server (keep this terminal open):
|
|
304
|
+
WEBHOOK_SECRET='<actual-secret>' node nansen-webhook-server.mjs
|
|
305
|
+
|
|
306
|
+
2. In a new terminal, start the tunnel:
|
|
307
|
+
ngrok http 9477 # recommended
|
|
308
|
+
# or: npx localtunnel --port 9477 (unreliable — tunnel drops silently)
|
|
309
|
+
|
|
310
|
+
3. Create an alert pointing to your webhook (fill in your alert details):
|
|
311
|
+
nansen alerts create \
|
|
312
|
+
--name '<your alert name>' \
|
|
313
|
+
--type <sm-token-flows|common-token-transfer|smart-contract-call> \
|
|
314
|
+
--chains <chains> \
|
|
315
|
+
--webhook 'https://<your-tunnel-url>/webhook' \
|
|
316
|
+
--webhook-secret '<actual-secret>' \
|
|
317
|
+
[type-specific flags...]
|
|
318
|
+
|
|
319
|
+
Or add the webhook to an existing alert:
|
|
320
|
+
nansen alerts update <alert-id> \
|
|
321
|
+
--webhook 'https://<your-tunnel-url>/webhook' \
|
|
322
|
+
--webhook-secret '<actual-secret>'
|
|
323
|
+
|
|
324
|
+
Note: The tunnel URL changes each time you restart. Update the alert
|
|
325
|
+
webhook URL if you restart the tunnel.
|
|
326
|
+
|
|
327
|
+
See `nansen alerts create --help` for full flag reference per alert type.
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Security Checklist
|
|
331
|
+
|
|
332
|
+
- **Always use a webhook secret** — the server refuses to start without one
|
|
333
|
+
- **Always verify signatures** — never accept unverified payloads
|
|
334
|
+
- **Bind to localhost only** — the tunnel handles public exposure; direct `0.0.0.0` binding exposes you to unauthenticated traffic
|
|
335
|
+
- **Use HTTPS** — both localtunnel and ngrok tunnel via HTTPS by default
|
|
336
|
+
- **Body size limit** — the 1 MB cap prevents memory exhaustion from oversized requests
|
|
337
|
+
- **Timing-safe comparison** — prevents timing side-channel attacks on the signature
|
|
338
|
+
|
|
339
|
+
## Troubleshooting
|
|
340
|
+
|
|
341
|
+
| Symptom | Fix |
|
|
342
|
+
|---|---|
|
|
343
|
+
| "Invalid signature" on every request | Ensure the **exact same secret** is in `WEBHOOK_SECRET` and `--webhook-secret` |
|
|
344
|
+
| "Failed to send welcome message" on alert create | Start the server and tunnel **before** creating the alert |
|
|
345
|
+
| No alerts arriving | Check `nansen alerts list --table` — is the alert enabled? Is the webhook URL correct (includes `/webhook`)? |
|
|
346
|
+
| Tunnel URL expired / tunnel died | Restart the tunnel, get the new URL, then `nansen alerts update <id> --webhook '<new-url>/webhook'`. If this keeps happening, switch from localtunnel to ngrok. |
|
|
347
|
+
| Port already in use | Set a different port: `PORT=9478 WEBHOOK_SECRET='...' node nansen-webhook-server.mjs` and update the tunnel accordingly |
|
|
348
|
+
|
|
349
|
+
## OpenClaw Integration
|
|
350
|
+
|
|
351
|
+
If the user is running OpenClaw locally on the same machine, the webhook server can forward verified alert payloads to OpenClaw's Gateway, triggering an agent turn for each alert.
|
|
352
|
+
|
|
353
|
+
**Flow:** `Nansen → ngrok → webhook server (signature check) → OpenClaw /hooks/agent`
|
|
354
|
+
|
|
355
|
+
### Additional env vars
|
|
356
|
+
|
|
357
|
+
| Var | Required | Purpose |
|
|
358
|
+
|-----|----------|---------|
|
|
359
|
+
| `OPENCLAW_GATEWAY_URL` | Yes | OpenClaw Gateway base URL (e.g. `http://localhost:3000`) |
|
|
360
|
+
| `OPENCLAW_AUTH_TOKEN` | If auth enabled | Bearer token for OpenClaw webhook endpoints |
|
|
361
|
+
|
|
362
|
+
### Start command (with OpenClaw forwarding)
|
|
363
|
+
|
|
364
|
+
```bash
|
|
365
|
+
WEBHOOK_SECRET='<secret>' \
|
|
366
|
+
OPENCLAW_GATEWAY_URL='http://localhost:3000' \
|
|
367
|
+
OPENCLAW_AUTH_TOKEN='<token>' \
|
|
368
|
+
node nansen-webhook-server.mjs
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
The server logs both the alert payload and the OpenClaw forward status. If OpenClaw is unreachable, the forward fails silently (the alert is still logged to stdout).
|
|
372
|
+
|
|
373
|
+
### Ask the user
|
|
374
|
+
|
|
375
|
+
Before enabling OpenClaw forwarding, ask:
|
|
376
|
+
1. Is OpenClaw running locally? What port?
|
|
377
|
+
2. Does their Gateway require auth? If so, what's the Bearer token?
|
|
378
|
+
|
|
379
|
+
If they don't know or aren't running OpenClaw, skip — the server works fine standalone.
|
|
380
|
+
|
|
381
|
+
## Notes
|
|
382
|
+
|
|
383
|
+
- The server uses zero npm dependencies — only Node.js built-ins
|
|
384
|
+
- One server can receive alerts from multiple Nansen alerts (as long as they share the same webhook secret)
|
|
385
|
+
- For production use, deploy to a cloud host with a static URL and run behind a reverse proxy with TLS
|
|
386
|
+
- The `x-nansen-signature` header format is `sha256=<HMAC-SHA256(secret, rawBody)>` — strip the `sha256=` prefix before comparing
|
|
@@ -33,7 +33,18 @@ nansen trade quote \
|
|
|
33
33
|
--amount 1000000000
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
Symbols resolve automatically: `SOL`, `ETH`, `USDC`, `USDT`, `WETH`. Raw addresses also work.
|
|
36
|
+
Symbols resolve automatically: `SOL`, `ETH`, `USDC`, `USDT`, `WETH`. Raw addresses also work. Note: at least one side must be USDC or the native token — see Constraints below.
|
|
37
|
+
|
|
38
|
+
## Constraints
|
|
39
|
+
|
|
40
|
+
**Swap constraint:** At least one side of every swap must be **USDC** or the chain's **native token** (SOL on Solana, ETH on Base). Arbitrary token-to-token swaps (e.g. WETH→USDT, BONK→JUP) are rejected.
|
|
41
|
+
|
|
42
|
+
- USDC (Solana): `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`
|
|
43
|
+
- USDC (Base): `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913`
|
|
44
|
+
- Native SOL: `So11111111111111111111111111111111111111112`
|
|
45
|
+
- Native ETH: `0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee`
|
|
46
|
+
|
|
47
|
+
For cross-chain swaps, each token is checked against its own chain (from vs `--chain`, to vs `--to-chain`).
|
|
37
48
|
|
|
38
49
|
## Execute
|
|
39
50
|
|
package/src/api.js
CHANGED
|
@@ -1230,11 +1230,11 @@ export class NansenAPI {
|
|
|
1230
1230
|
// ============= Prediction Market Endpoints =============
|
|
1231
1231
|
|
|
1232
1232
|
async pmOhlcv(params = {}) {
|
|
1233
|
-
const { marketId, sort, pagination } = params;
|
|
1233
|
+
const { marketId, orderBy, sort, pagination } = params;
|
|
1234
1234
|
if (!marketId) throw new NansenError('market_id is required. Run: nansen research pm market-screener --query "your search"', ErrorCode.MISSING_PARAM);
|
|
1235
1235
|
return this.request('/api/v1/prediction-market/ohlcv', {
|
|
1236
1236
|
market_id: marketId,
|
|
1237
|
-
sort,
|
|
1237
|
+
order_by: orderBy || sort,
|
|
1238
1238
|
pagination
|
|
1239
1239
|
});
|
|
1240
1240
|
}
|
|
@@ -1249,71 +1249,105 @@ export class NansenAPI {
|
|
|
1249
1249
|
}
|
|
1250
1250
|
|
|
1251
1251
|
async pmTopHolders(params = {}) {
|
|
1252
|
-
const { marketId, sort, pagination } = params;
|
|
1252
|
+
const { marketId, orderBy, sort, pagination } = params;
|
|
1253
1253
|
if (!marketId) throw new NansenError('market_id is required. Run: nansen research pm market-screener --query "your search"', ErrorCode.MISSING_PARAM);
|
|
1254
1254
|
return this.request('/api/v1/prediction-market/top-holders', {
|
|
1255
1255
|
market_id: marketId,
|
|
1256
|
-
sort,
|
|
1256
|
+
order_by: orderBy || sort,
|
|
1257
1257
|
pagination
|
|
1258
1258
|
});
|
|
1259
1259
|
}
|
|
1260
1260
|
|
|
1261
1261
|
async pmTradesByMarket(params = {}) {
|
|
1262
|
-
const { marketId, pagination } = params;
|
|
1262
|
+
const { marketId, orderBy, pagination } = params;
|
|
1263
1263
|
if (!marketId) throw new NansenError('market_id is required. Run: nansen research pm market-screener --query "your search"', ErrorCode.MISSING_PARAM);
|
|
1264
1264
|
return this.request('/api/v1/prediction-market/trades-by-market', {
|
|
1265
1265
|
market_id: marketId,
|
|
1266
|
+
order_by: orderBy,
|
|
1266
1267
|
pagination
|
|
1267
1268
|
});
|
|
1268
1269
|
}
|
|
1269
1270
|
|
|
1270
1271
|
async pmTradesByAddress(params = {}) {
|
|
1271
|
-
const { address, pagination } = params;
|
|
1272
|
+
const { address, orderBy, pagination } = params;
|
|
1272
1273
|
// Polymarket runs exclusively on Polygon
|
|
1273
1274
|
const validation = validateAddress(address, 'polygon');
|
|
1274
1275
|
if (!validation.valid) throw new NansenError(validation.error, validation.code);
|
|
1275
1276
|
return this.request('/api/v1/prediction-market/trades-by-address', {
|
|
1276
1277
|
address,
|
|
1278
|
+
order_by: orderBy,
|
|
1277
1279
|
pagination
|
|
1278
1280
|
});
|
|
1279
1281
|
}
|
|
1280
1282
|
|
|
1281
1283
|
async pmMarketScreener(params = {}) {
|
|
1282
|
-
const { sortBy = 'volume_24hr', query = '', status = '', pagination } = params;
|
|
1283
|
-
|
|
1284
|
+
const { orderBy, sortBy = 'volume_24hr', query = '', status = '', tags, minLiquidity, maxLiquidity, minUniqueTraders24h, maxUniqueTraders24h, minVolume24hr, maxVolume24hr, negRisk, minOpenInterest, maxOpenInterest, endDateBefore, endDateAfter, minPrice, maxPrice, pagination } = params;
|
|
1285
|
+
const body = {
|
|
1284
1286
|
sort_by: sortBy,
|
|
1285
1287
|
query,
|
|
1286
1288
|
status,
|
|
1287
1289
|
pagination
|
|
1288
|
-
}
|
|
1290
|
+
};
|
|
1291
|
+
if (orderBy) body.order_by = orderBy;
|
|
1292
|
+
if (tags && tags.length) body.tags = tags;
|
|
1293
|
+
if (minLiquidity != null) body.min_liquidity = minLiquidity;
|
|
1294
|
+
if (maxLiquidity != null) body.max_liquidity = maxLiquidity;
|
|
1295
|
+
if (minUniqueTraders24h != null) body.min_unique_traders_24h = minUniqueTraders24h;
|
|
1296
|
+
if (maxUniqueTraders24h != null) body.max_unique_traders_24h = maxUniqueTraders24h;
|
|
1297
|
+
if (minVolume24hr != null) body.min_volume_24hr = minVolume24hr;
|
|
1298
|
+
if (maxVolume24hr != null) body.max_volume_24hr = maxVolume24hr;
|
|
1299
|
+
if (negRisk != null) body.neg_risk = negRisk;
|
|
1300
|
+
if (minOpenInterest != null) body.min_open_interest = minOpenInterest;
|
|
1301
|
+
if (maxOpenInterest != null) body.max_open_interest = maxOpenInterest;
|
|
1302
|
+
if (endDateBefore) body.end_date_before = endDateBefore;
|
|
1303
|
+
if (endDateAfter) body.end_date_after = endDateAfter;
|
|
1304
|
+
if (minPrice != null) body.min_price = minPrice;
|
|
1305
|
+
if (maxPrice != null) body.max_price = maxPrice;
|
|
1306
|
+
return this.request('/api/v1/prediction-market/market-screener', body);
|
|
1289
1307
|
}
|
|
1290
1308
|
|
|
1291
1309
|
async pmEventScreener(params = {}) {
|
|
1292
|
-
const { sortBy = 'volume_24hr', query = '', status = '', pagination } = params;
|
|
1293
|
-
|
|
1310
|
+
const { orderBy, sortBy = 'volume_24hr', query = '', status = '', tags, minLiquidity, maxLiquidity, minUniqueTraders24h, maxUniqueTraders24h, minVolume24hr, maxVolume24hr, negRisk, minOpenInterest, maxOpenInterest, endDateBefore, endDateAfter, pagination } = params;
|
|
1311
|
+
const body = {
|
|
1294
1312
|
sort_by: sortBy,
|
|
1295
1313
|
query,
|
|
1296
1314
|
status,
|
|
1297
1315
|
pagination
|
|
1298
|
-
}
|
|
1316
|
+
};
|
|
1317
|
+
if (orderBy) body.order_by = orderBy;
|
|
1318
|
+
if (tags && tags.length) body.tags = tags;
|
|
1319
|
+
if (minLiquidity != null) body.min_liquidity = minLiquidity;
|
|
1320
|
+
if (maxLiquidity != null) body.max_liquidity = maxLiquidity;
|
|
1321
|
+
if (minUniqueTraders24h != null) body.min_unique_traders_24h = minUniqueTraders24h;
|
|
1322
|
+
if (maxUniqueTraders24h != null) body.max_unique_traders_24h = maxUniqueTraders24h;
|
|
1323
|
+
if (minVolume24hr != null) body.min_volume_24hr = minVolume24hr;
|
|
1324
|
+
if (maxVolume24hr != null) body.max_volume_24hr = maxVolume24hr;
|
|
1325
|
+
if (negRisk != null) body.neg_risk = negRisk;
|
|
1326
|
+
if (minOpenInterest != null) body.min_open_interest = minOpenInterest;
|
|
1327
|
+
if (maxOpenInterest != null) body.max_open_interest = maxOpenInterest;
|
|
1328
|
+
if (endDateBefore) body.end_date_before = endDateBefore;
|
|
1329
|
+
if (endDateAfter) body.end_date_after = endDateAfter;
|
|
1330
|
+
return this.request('/api/v1/prediction-market/event-screener', body);
|
|
1299
1331
|
}
|
|
1300
1332
|
|
|
1301
1333
|
async pmPnlByMarket(params = {}) {
|
|
1302
|
-
const { marketId, pagination } = params;
|
|
1334
|
+
const { marketId, orderBy, pagination } = params;
|
|
1303
1335
|
if (!marketId) throw new NansenError('market_id is required. Run: nansen research pm market-screener --query "your search"', ErrorCode.MISSING_PARAM);
|
|
1304
1336
|
return this.request('/api/v1/prediction-market/pnl-by-market', {
|
|
1305
1337
|
market_id: marketId,
|
|
1338
|
+
order_by: orderBy,
|
|
1306
1339
|
pagination
|
|
1307
1340
|
});
|
|
1308
1341
|
}
|
|
1309
1342
|
|
|
1310
1343
|
async pmPnlByAddress(params = {}) {
|
|
1311
|
-
const { address, pagination } = params;
|
|
1344
|
+
const { address, orderBy, pagination } = params;
|
|
1312
1345
|
// Polymarket runs exclusively on Polygon
|
|
1313
1346
|
const validation = validateAddress(address, 'polygon');
|
|
1314
1347
|
if (!validation.valid) throw new NansenError(validation.error, validation.code);
|
|
1315
1348
|
return this.request('/api/v1/prediction-market/pnl-by-address', {
|
|
1316
1349
|
address,
|
|
1350
|
+
order_by: orderBy,
|
|
1317
1351
|
pagination
|
|
1318
1352
|
});
|
|
1319
1353
|
}
|
|
@@ -1334,6 +1368,17 @@ export class NansenAPI {
|
|
|
1334
1368
|
});
|
|
1335
1369
|
}
|
|
1336
1370
|
|
|
1371
|
+
async pmAddressSummary(params = {}) {
|
|
1372
|
+
const { address, pagination } = params;
|
|
1373
|
+
// Polymarket runs exclusively on Polygon
|
|
1374
|
+
const validation = validateAddress(address, 'polygon');
|
|
1375
|
+
if (!validation.valid) throw new NansenError(validation.error, validation.code);
|
|
1376
|
+
return this.request('/api/v1/prediction-market/address-summary', {
|
|
1377
|
+
address,
|
|
1378
|
+
pagination
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1337
1382
|
// ============= Points Endpoints =============
|
|
1338
1383
|
|
|
1339
1384
|
async pointsLeaderboard(params = {}) {
|
package/src/cli.js
CHANGED
|
@@ -1407,23 +1407,40 @@ export function buildCommands(deps = {}) {
|
|
|
1407
1407
|
const sortBy = options['sort-by'];
|
|
1408
1408
|
const query = options.query;
|
|
1409
1409
|
const status = options.status;
|
|
1410
|
-
const
|
|
1410
|
+
const orderBy = parseSort(options.sort, options['order-by']);
|
|
1411
1411
|
const pagination = buildPagination(options);
|
|
1412
1412
|
|
|
1413
|
+
// Screener-specific filter options
|
|
1414
|
+
const tags = options.tags ? options.tags.split(',').map(t => t.trim()) : undefined;
|
|
1415
|
+
const minLiquidity = options['min-liquidity'] != null ? Number(options['min-liquidity']) : undefined;
|
|
1416
|
+
const maxLiquidity = options['max-liquidity'] != null ? Number(options['max-liquidity']) : undefined;
|
|
1417
|
+
const minUniqueTraders24h = options['min-unique-traders-24h'] != null ? Number(options['min-unique-traders-24h']) : undefined;
|
|
1418
|
+
const maxUniqueTraders24h = options['max-unique-traders-24h'] != null ? Number(options['max-unique-traders-24h']) : undefined;
|
|
1419
|
+
const minVolume24hr = options['min-volume-24hr'] != null ? Number(options['min-volume-24hr']) : undefined;
|
|
1420
|
+
const maxVolume24hr = options['max-volume-24hr'] != null ? Number(options['max-volume-24hr']) : undefined;
|
|
1421
|
+
const negRisk = options['neg-risk'] != null ? options['neg-risk'] === 'true' : undefined;
|
|
1422
|
+
const minOpenInterest = options['min-open-interest'] != null ? Number(options['min-open-interest']) : undefined;
|
|
1423
|
+
const maxOpenInterest = options['max-open-interest'] != null ? Number(options['max-open-interest']) : undefined;
|
|
1424
|
+
const endDateBefore = options['end-date-before'];
|
|
1425
|
+
const endDateAfter = options['end-date-after'];
|
|
1426
|
+
const minPrice = options['min-price'] != null ? Number(options['min-price']) : undefined;
|
|
1427
|
+
const maxPrice = options['max-price'] != null ? Number(options['max-price']) : undefined;
|
|
1428
|
+
|
|
1413
1429
|
const handlers = {
|
|
1414
|
-
'ohlcv': () => apiInstance.pmOhlcv({ marketId,
|
|
1430
|
+
'ohlcv': () => apiInstance.pmOhlcv({ marketId, orderBy, pagination }),
|
|
1415
1431
|
'orderbook': () => apiInstance.pmOrderbook({ marketId, pagination }),
|
|
1416
|
-
'top-holders': () => apiInstance.pmTopHolders({ marketId,
|
|
1417
|
-
'trades-by-market': () => apiInstance.pmTradesByMarket({ marketId, pagination }),
|
|
1418
|
-
'trades-by-address': () => apiInstance.pmTradesByAddress({ address, pagination }),
|
|
1419
|
-
'market-screener': () => apiInstance.pmMarketScreener({ sortBy, query, status, pagination }),
|
|
1420
|
-
'event-screener': () => apiInstance.pmEventScreener({ sortBy, query, status, pagination }),
|
|
1421
|
-
'pnl-by-market': () => apiInstance.pmPnlByMarket({ marketId, pagination }),
|
|
1422
|
-
'pnl-by-address': () => apiInstance.pmPnlByAddress({ address, pagination }),
|
|
1432
|
+
'top-holders': () => apiInstance.pmTopHolders({ marketId, orderBy, pagination }),
|
|
1433
|
+
'trades-by-market': () => apiInstance.pmTradesByMarket({ marketId, orderBy, pagination }),
|
|
1434
|
+
'trades-by-address': () => apiInstance.pmTradesByAddress({ address, orderBy, pagination }),
|
|
1435
|
+
'market-screener': () => apiInstance.pmMarketScreener({ orderBy, sortBy, query, status, tags, minLiquidity, maxLiquidity, minUniqueTraders24h, maxUniqueTraders24h, minVolume24hr, maxVolume24hr, negRisk, minOpenInterest, maxOpenInterest, endDateBefore, endDateAfter, minPrice, maxPrice, pagination }),
|
|
1436
|
+
'event-screener': () => apiInstance.pmEventScreener({ orderBy, sortBy, query, status, tags, minLiquidity, maxLiquidity, minUniqueTraders24h, maxUniqueTraders24h, minVolume24hr, maxVolume24hr, negRisk, minOpenInterest, maxOpenInterest, endDateBefore, endDateAfter, pagination }),
|
|
1437
|
+
'pnl-by-market': () => apiInstance.pmPnlByMarket({ marketId, orderBy, pagination }),
|
|
1438
|
+
'pnl-by-address': () => apiInstance.pmPnlByAddress({ address, orderBy, pagination }),
|
|
1423
1439
|
'position-detail': () => apiInstance.pmPositionDetail({ marketId, pagination }),
|
|
1424
1440
|
'categories': () => apiInstance.pmCategories({ pagination }),
|
|
1441
|
+
'address-summary': () => apiInstance.pmAddressSummary({ address, pagination }),
|
|
1425
1442
|
'help': () => ({
|
|
1426
|
-
commands: ['ohlcv', 'orderbook', 'top-holders', 'trades-by-market', 'trades-by-address', 'market-screener', 'event-screener', 'pnl-by-market', 'pnl-by-address', 'position-detail', 'categories'],
|
|
1443
|
+
commands: ['ohlcv', 'orderbook', 'top-holders', 'trades-by-market', 'trades-by-address', 'market-screener', 'event-screener', 'pnl-by-market', 'pnl-by-address', 'position-detail', 'categories', 'address-summary'],
|
|
1427
1444
|
description: 'Polymarket prediction market analytics',
|
|
1428
1445
|
example: 'nansen research pm market-screener --sort-by volume_24hr --limit 20'
|
|
1429
1446
|
})
|
package/src/index.js
CHANGED
package/src/schema.json
CHANGED
|
@@ -588,18 +588,42 @@
|
|
|
588
588
|
"endpoint": "/api/v1/prediction-market/market-screener",
|
|
589
589
|
"description": "Get Prediction Market Screener",
|
|
590
590
|
"options": {
|
|
591
|
-
"query": {
|
|
592
|
-
|
|
593
|
-
}
|
|
591
|
+
"query": { "default": "" },
|
|
592
|
+
"sort-by": { "description": "Deprecated: use --sort field:dir instead" },
|
|
593
|
+
"tags": {},
|
|
594
|
+
"min-liquidity": {},
|
|
595
|
+
"max-liquidity": {},
|
|
596
|
+
"min-unique-traders-24h": {},
|
|
597
|
+
"max-unique-traders-24h": {},
|
|
598
|
+
"min-volume-24hr": {},
|
|
599
|
+
"max-volume-24hr": {},
|
|
600
|
+
"neg-risk": {},
|
|
601
|
+
"min-open-interest": {},
|
|
602
|
+
"max-open-interest": {},
|
|
603
|
+
"end-date-before": {},
|
|
604
|
+
"end-date-after": {},
|
|
605
|
+
"min-price": {},
|
|
606
|
+
"max-price": {}
|
|
594
607
|
}
|
|
595
608
|
},
|
|
596
609
|
"event-screener": {
|
|
597
610
|
"endpoint": "/api/v1/prediction-market/event-screener",
|
|
598
611
|
"description": "Get Prediction Market Event Screener",
|
|
599
612
|
"options": {
|
|
600
|
-
"query": {
|
|
601
|
-
|
|
602
|
-
}
|
|
613
|
+
"query": { "default": "" },
|
|
614
|
+
"sort-by": { "description": "Deprecated: use --sort field:dir instead" },
|
|
615
|
+
"tags": {},
|
|
616
|
+
"min-liquidity": {},
|
|
617
|
+
"max-liquidity": {},
|
|
618
|
+
"min-unique-traders-24h": {},
|
|
619
|
+
"max-unique-traders-24h": {},
|
|
620
|
+
"min-volume-24hr": {},
|
|
621
|
+
"max-volume-24hr": {},
|
|
622
|
+
"neg-risk": {},
|
|
623
|
+
"min-open-interest": {},
|
|
624
|
+
"max-open-interest": {},
|
|
625
|
+
"end-date-before": {},
|
|
626
|
+
"end-date-after": {}
|
|
603
627
|
}
|
|
604
628
|
},
|
|
605
629
|
"pnl-by-market": {
|
|
@@ -641,6 +665,15 @@
|
|
|
641
665
|
"categories": {
|
|
642
666
|
"endpoint": "/api/v1/prediction-market/categories",
|
|
643
667
|
"description": "Get Prediction Market Categories"
|
|
668
|
+
},
|
|
669
|
+
"address-summary": {
|
|
670
|
+
"endpoint": "/api/v1/prediction-market/address-summary",
|
|
671
|
+
"description": "Get wallet-level PnL summary for a Polymarket address",
|
|
672
|
+
"options": {
|
|
673
|
+
"address": {
|
|
674
|
+
"required": true
|
|
675
|
+
}
|
|
676
|
+
}
|
|
644
677
|
}
|
|
645
678
|
},
|
|
646
679
|
"description": "Polymarket prediction market analytics"
|
package/src/trade-validation.js
CHANGED
|
@@ -61,6 +61,16 @@ export function validateQuoteInput({ chain, toChain, from, to, amount }) {
|
|
|
61
61
|
);
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
// 5. At least one side must be USDC or the native token
|
|
66
|
+
const fromIsAnchor = isUsdcOrNative(from, normalizedChain);
|
|
67
|
+
const toIsAnchor = isUsdcOrNative(to, normalizedToChain);
|
|
68
|
+
if (!fromIsAnchor && !toIsAnchor) {
|
|
69
|
+
const anchorDesc = normalizedChain === normalizedToChain
|
|
70
|
+
? `USDC or the native token (${NATIVE_SYMBOLS[normalizedChain] ?? normalizedChain})`
|
|
71
|
+
: `USDC or the native token on either side (${NATIVE_SYMBOLS[normalizedChain] ?? normalizedChain} on ${normalizedChain}, ${NATIVE_SYMBOLS[normalizedToChain] ?? normalizedToChain} on ${normalizedToChain})`;
|
|
72
|
+
throw new Error(`Invalid swap: at least one token must be ${anchorDesc}. Got: ${from} → ${to}.`);
|
|
73
|
+
}
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
// Native token decimals per chain (for converting balance from base units)
|
|
@@ -112,6 +122,12 @@ const NATIVE_TOKEN_ADDRESSES = {
|
|
|
112
122
|
base: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
|
|
113
123
|
};
|
|
114
124
|
|
|
125
|
+
// USDC contract addresses per chain.
|
|
126
|
+
const USDC_ADDRESSES = {
|
|
127
|
+
solana: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
|
|
128
|
+
base: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
|
|
129
|
+
};
|
|
130
|
+
|
|
115
131
|
// Native token symbols for error messages.
|
|
116
132
|
const NATIVE_SYMBOLS = { solana: 'SOL', base: 'ETH' };
|
|
117
133
|
|
|
@@ -120,6 +136,21 @@ const FEE_BUFFER = { solana: 0.005, base: 0.00004 };
|
|
|
120
136
|
const HIGH_PERCENTAGE_THRESHOLD = 95;
|
|
121
137
|
const AUTO_ADJUST_THRESHOLD_PERCENT = 2;
|
|
122
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Check if an address is USDC or the native token for a chain (case-insensitive for EVM).
|
|
141
|
+
*/
|
|
142
|
+
function isUsdcOrNative(address, chain) {
|
|
143
|
+
const usdc = USDC_ADDRESSES[chain];
|
|
144
|
+
const native = NATIVE_TOKEN_ADDRESSES[chain];
|
|
145
|
+
if (!usdc && !native) return false;
|
|
146
|
+
if (chain === 'solana') {
|
|
147
|
+
return address === usdc || address === native;
|
|
148
|
+
}
|
|
149
|
+
// EVM: case-insensitive
|
|
150
|
+
const lower = address.toLowerCase();
|
|
151
|
+
return (usdc && lower === usdc.toLowerCase()) || (native && lower === native.toLowerCase());
|
|
152
|
+
}
|
|
153
|
+
|
|
123
154
|
/**
|
|
124
155
|
* Check if an address is the native token for a chain (case-insensitive for EVM).
|
|
125
156
|
*/
|
package/src/transfer.js
CHANGED
|
@@ -21,6 +21,16 @@ const ATA_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL';
|
|
|
21
21
|
// Alias: buildEvmTransaction uses 'evm' as a generic fallback
|
|
22
22
|
const CHAIN_IDS = { ...EVM_CHAIN_IDS, evm: 1 };
|
|
23
23
|
|
|
24
|
+
// ============= Address Derivation =============
|
|
25
|
+
|
|
26
|
+
function deriveEvmAddress(privateKeyHex) {
|
|
27
|
+
const privBuf = Buffer.from(privateKeyHex, 'hex');
|
|
28
|
+
const ecdh = crypto.createECDH('secp256k1');
|
|
29
|
+
ecdh.setPrivateKey(privBuf);
|
|
30
|
+
const pubKey = ecdh.getPublicKey(null, 'uncompressed');
|
|
31
|
+
return '0x' + keccak256(pubKey.subarray(1)).subarray(12).toString('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
// ============= Address Validation =============
|
|
25
35
|
|
|
26
36
|
function validateEvmAddress(address) {
|
|
@@ -106,12 +116,9 @@ async function buildEvmTransaction({ to, amount, token, privateKey, chain, max =
|
|
|
106
116
|
const rpcUrl = CHAIN_RPCS[chain] || CHAIN_RPCS.evm;
|
|
107
117
|
const chainId = CHAIN_IDS[chain] || 1;
|
|
108
118
|
|
|
109
|
-
// Derive address
|
|
119
|
+
// Derive address and buffer for signing
|
|
110
120
|
const privBuf = Buffer.from(privateKey, 'hex');
|
|
111
|
-
const
|
|
112
|
-
ecdh.setPrivateKey(privBuf);
|
|
113
|
-
const pubKey = ecdh.getPublicKey(null, 'uncompressed');
|
|
114
|
-
const from = '0x' + keccak256(pubKey.subarray(1)).subarray(12).toString('hex');
|
|
121
|
+
const from = deriveEvmAddress(privateKey);
|
|
115
122
|
|
|
116
123
|
// Nonce
|
|
117
124
|
const nonceHex = await rpcCall(rpcUrl, 'eth_getTransactionCount', [from, 'latest']);
|
|
@@ -735,11 +742,7 @@ export async function sendTokens({ to, amount, chain, token = null, wallet = nul
|
|
|
735
742
|
|
|
736
743
|
if (max && token) {
|
|
737
744
|
// Max ERC-20: full token balance
|
|
738
|
-
const
|
|
739
|
-
const ecdh = crypto.createECDH('secp256k1');
|
|
740
|
-
ecdh.setPrivateKey(privBuf);
|
|
741
|
-
const pubKey = ecdh.getPublicKey(null, 'uncompressed');
|
|
742
|
-
const from = '0x' + keccak256(pubKey.subarray(1)).subarray(12).toString('hex');
|
|
745
|
+
const from = deriveEvmAddress(walletData.evm.privateKey);
|
|
743
746
|
const balResult = await rpcCall(rpcUrl, 'eth_call', [{
|
|
744
747
|
to: token, data: '0x70a08231' + from.slice(2).padStart(64, '0'),
|
|
745
748
|
}, 'latest']);
|
package/src/wallet.js
CHANGED
|
@@ -247,6 +247,14 @@ function getWalletFile(name) {
|
|
|
247
247
|
return path.join(getWalletsDir(), `${name}.json`);
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
function requireWalletFile(name) {
|
|
251
|
+
const walletFile = getWalletFile(name);
|
|
252
|
+
if (!fs.existsSync(walletFile)) {
|
|
253
|
+
throw new Error(`Wallet "${name}" not found`);
|
|
254
|
+
}
|
|
255
|
+
return walletFile;
|
|
256
|
+
}
|
|
257
|
+
|
|
250
258
|
/**
|
|
251
259
|
* Verify the global password against stored hash.
|
|
252
260
|
*/
|
|
@@ -466,10 +474,7 @@ export function createWallet(name, password) {
|
|
|
466
474
|
* Show wallet details (addresses only, no keys).
|
|
467
475
|
*/
|
|
468
476
|
export function showWallet(name) {
|
|
469
|
-
const walletFile =
|
|
470
|
-
if (!fs.existsSync(walletFile)) {
|
|
471
|
-
throw new Error(`Wallet "${name}" not found`);
|
|
472
|
-
}
|
|
477
|
+
const walletFile = requireWalletFile(name);
|
|
473
478
|
|
|
474
479
|
const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
|
|
475
480
|
const config = getWalletConfig();
|
|
@@ -495,10 +500,7 @@ export function showWallet(name) {
|
|
|
495
500
|
* Export private keys for a wallet (requires password).
|
|
496
501
|
*/
|
|
497
502
|
export function exportWallet(name, password) {
|
|
498
|
-
const walletFile =
|
|
499
|
-
if (!fs.existsSync(walletFile)) {
|
|
500
|
-
throw new Error(`Wallet "${name}" not found`);
|
|
501
|
-
}
|
|
503
|
+
const walletFile = requireWalletFile(name);
|
|
502
504
|
|
|
503
505
|
const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
|
|
504
506
|
if (data.provider && data.provider !== 'local') {
|
|
@@ -527,10 +529,7 @@ export function exportWallet(name, password) {
|
|
|
527
529
|
* Set the default wallet.
|
|
528
530
|
*/
|
|
529
531
|
export function setDefaultWallet(name) {
|
|
530
|
-
|
|
531
|
-
if (!fs.existsSync(walletFile)) {
|
|
532
|
-
throw new Error(`Wallet "${name}" not found`);
|
|
533
|
-
}
|
|
532
|
+
requireWalletFile(name);
|
|
534
533
|
|
|
535
534
|
const config = getWalletConfig();
|
|
536
535
|
config.defaultWallet = name;
|
|
@@ -543,10 +542,7 @@ export function setDefaultWallet(name) {
|
|
|
543
542
|
* Delete a wallet.
|
|
544
543
|
*/
|
|
545
544
|
export async function deleteWallet(name, password) {
|
|
546
|
-
const walletFile =
|
|
547
|
-
if (!fs.existsSync(walletFile)) {
|
|
548
|
-
throw new Error(`Wallet "${name}" not found`);
|
|
549
|
-
}
|
|
545
|
+
const walletFile = requireWalletFile(name);
|
|
550
546
|
|
|
551
547
|
const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
|
|
552
548
|
const config = getWalletConfig();
|