nansen-cli 1.13.1 → 1.15.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 +28 -0
- package/README.md +1 -1
- package/package.json +2 -1
- package/src/api.js +107 -64
- package/src/chain-ids.js +2 -3
- package/src/cli.js +31 -23
- package/src/keychain.js +229 -0
- package/src/privy.js +359 -0
- package/src/schema.json +42 -2
- package/src/trading.js +264 -118
- package/src/transfer.js +150 -25
- package/src/wallet.js +354 -70
- package/src/x402-svm.js +43 -24
- package/src/x402.js +2 -2
package/src/privy.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privy Server Wallet Integration
|
|
3
|
+
*
|
|
4
|
+
* Two concerns:
|
|
5
|
+
* 1. PrivyClient - thin REST wrapper for Privy's server wallet API
|
|
6
|
+
* 2. createPrivyPaymentSignatures - x402 auto-payment via Privy signing
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { parsePaymentRequirements } from "./x402.js";
|
|
12
|
+
import { isEvmNetwork } from "./x402-evm.js";
|
|
13
|
+
import {
|
|
14
|
+
isSvmNetwork,
|
|
15
|
+
getSolanaRpcUrl,
|
|
16
|
+
fetchRecentBlockhash,
|
|
17
|
+
buildUnsignedSvmTransaction,
|
|
18
|
+
} from "./x402-svm.js";
|
|
19
|
+
import {
|
|
20
|
+
buildEIP712TypedData,
|
|
21
|
+
buildPaymentSignatureHeader,
|
|
22
|
+
} from "./walletconnect-x402.js";
|
|
23
|
+
|
|
24
|
+
// ============= Constants =============
|
|
25
|
+
|
|
26
|
+
const PRIVY_BASE_URL = "https://api.privy.io/v1";
|
|
27
|
+
|
|
28
|
+
// ============= PrivyClient =============
|
|
29
|
+
|
|
30
|
+
export class PrivyClient {
|
|
31
|
+
constructor(appId, appSecret) {
|
|
32
|
+
if (!appId || !appSecret) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"Privy credentials required. Set PRIVY_APP_ID and PRIVY_APP_SECRET environment variables. Get them at https://dashboard.privy.io"
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
this.appId = appId;
|
|
38
|
+
this.appSecret = appSecret;
|
|
39
|
+
this.baseUrl = PRIVY_BASE_URL;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async _request(method, endpoint, body = null) {
|
|
43
|
+
const auth = Buffer.from(`${this.appId}:${this.appSecret}`).toString(
|
|
44
|
+
"base64"
|
|
45
|
+
);
|
|
46
|
+
const headers = {
|
|
47
|
+
Authorization: `Basic ${auth}`,
|
|
48
|
+
"privy-app-id": this.appId,
|
|
49
|
+
"Content-Type": "application/json",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const opts = { method, headers };
|
|
53
|
+
if (body) opts.body = JSON.stringify(body);
|
|
54
|
+
|
|
55
|
+
const response = await fetch(`${this.baseUrl}${endpoint}`, opts);
|
|
56
|
+
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
let msg = `Privy API error: ${response.status}`;
|
|
59
|
+
try {
|
|
60
|
+
const data = await response.json();
|
|
61
|
+
msg = data.message || data.error || msg;
|
|
62
|
+
} catch { /* non-JSON error response (e.g. 502 from CDN) */ }
|
|
63
|
+
throw new Error(msg);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return await response.json();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async createWallet(chainType = "ethereum") {
|
|
70
|
+
return this._request("POST", "/wallets", { chain_type: chainType });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async listWallets() {
|
|
74
|
+
return this._request("GET", "/wallets");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async getWallet(walletId) {
|
|
78
|
+
return this._request("GET", `/wallets/${walletId}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async deleteWallet(walletId) {
|
|
82
|
+
return this._request("DELETE", `/wallets/${walletId}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async sendTransaction(walletId, { to, value, chainId, data: txData }) {
|
|
86
|
+
const caip2 = `eip155:${chainId}`;
|
|
87
|
+
return this._request("POST", `/wallets/${walletId}/rpc`, {
|
|
88
|
+
method: "eth_sendTransaction",
|
|
89
|
+
caip2,
|
|
90
|
+
params: {
|
|
91
|
+
transaction: {
|
|
92
|
+
to,
|
|
93
|
+
value,
|
|
94
|
+
...(txData ? { data: txData } : {}),
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async ethSignTypedDataV4(walletId, typedData) {
|
|
101
|
+
// Privy uses snake_case "primary_type" instead of "primaryType"
|
|
102
|
+
const privyTypedData = { ...typedData };
|
|
103
|
+
if (privyTypedData.primaryType && !privyTypedData.primary_type) {
|
|
104
|
+
privyTypedData.primary_type = privyTypedData.primaryType;
|
|
105
|
+
delete privyTypedData.primaryType;
|
|
106
|
+
}
|
|
107
|
+
return this._request("POST", `/wallets/${walletId}/rpc`, {
|
|
108
|
+
method: "eth_signTypedData_v4",
|
|
109
|
+
params: { typed_data: privyTypedData },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async signEvmTransaction(walletId, transaction) {
|
|
114
|
+
return this._request("POST", `/wallets/${walletId}/rpc`, {
|
|
115
|
+
method: "eth_signTransaction",
|
|
116
|
+
params: { transaction },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async signSolanaTransaction(walletId, transactionBase64) {
|
|
121
|
+
return this._request("POST", `/wallets/${walletId}/rpc`, {
|
|
122
|
+
method: "signTransaction",
|
|
123
|
+
chain_type: "solana",
|
|
124
|
+
params: { transaction: transactionBase64, encoding: "base64" },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ============= Helpers =============
|
|
131
|
+
|
|
132
|
+
function getClient() {
|
|
133
|
+
return new PrivyClient(process.env.PRIVY_APP_ID, process.env.PRIVY_APP_SECRET);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Create both an EVM and Solana wallet via Privy and store a local reference file.
|
|
138
|
+
* Mirrors createWallet() in wallet.js but for Privy server wallets.
|
|
139
|
+
*/
|
|
140
|
+
export async function createPrivyWalletPair(name) {
|
|
141
|
+
const WALLET_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
142
|
+
if (!name || !WALLET_NAME_RE.test(name)) {
|
|
143
|
+
throw new Error("Wallet name must be 1-64 characters: letters, numbers, hyphens, underscores only");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const walletsDir = path.join(process.env.HOME || process.env.USERPROFILE || "", ".nansen", "wallets");
|
|
147
|
+
const walletFile = path.join(walletsDir, `${name}.json`);
|
|
148
|
+
|
|
149
|
+
if (fs.existsSync(walletFile)) {
|
|
150
|
+
throw new Error(`Wallet "${name}" already exists`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const client = getClient();
|
|
154
|
+
// Create wallets sequentially so we can clean up on partial failure
|
|
155
|
+
const evmResult = await client.createWallet("ethereum");
|
|
156
|
+
let solanaResult;
|
|
157
|
+
try {
|
|
158
|
+
solanaResult = await client.createWallet("solana");
|
|
159
|
+
} catch (err) {
|
|
160
|
+
// Clean up the EVM wallet we just created to avoid orphans
|
|
161
|
+
try { await client.deleteWallet(evmResult.id); } catch { /* best effort */ }
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const walletData = {
|
|
166
|
+
name,
|
|
167
|
+
provider: "privy",
|
|
168
|
+
evm: { privyWalletId: evmResult.id, address: evmResult.address },
|
|
169
|
+
solana: { privyWalletId: solanaResult.id, address: solanaResult.address },
|
|
170
|
+
createdAt: new Date().toISOString(),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
if (!fs.existsSync(walletsDir)) {
|
|
174
|
+
fs.mkdirSync(walletsDir, { mode: 0o700, recursive: true });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Write config before wallet file so a crash doesn't leave an orphan without a default entry
|
|
178
|
+
const configPath = path.join(walletsDir, "config.json");
|
|
179
|
+
let config = { defaultWallet: null, passwordHash: null };
|
|
180
|
+
if (fs.existsSync(configPath)) {
|
|
181
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
182
|
+
}
|
|
183
|
+
if (!config.defaultWallet) {
|
|
184
|
+
config.defaultWallet = name;
|
|
185
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
fs.writeFileSync(walletFile, JSON.stringify(walletData, null, 2), { mode: 0o600 });
|
|
189
|
+
|
|
190
|
+
return walletData;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ============= x402 Payment Signing =============
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Resolve the EVM wallet for x402 payments.
|
|
197
|
+
* Priority: PRIVY_WALLET_ID env > default local wallet's privyWalletId > first Privy EVM wallet.
|
|
198
|
+
*/
|
|
199
|
+
async function getPrivyEvmWallet(client) {
|
|
200
|
+
if (process.env.PRIVY_WALLET_ID) {
|
|
201
|
+
return client.getWallet(process.env.PRIVY_WALLET_ID);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Prefer the wallet referenced by the local default wallet file
|
|
205
|
+
try {
|
|
206
|
+
const walletsDir = path.join(process.env.HOME || process.env.USERPROFILE || "", ".nansen", "wallets");
|
|
207
|
+
const configPath = path.join(walletsDir, "config.json");
|
|
208
|
+
if (fs.existsSync(configPath)) {
|
|
209
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
210
|
+
if (config.defaultWallet) {
|
|
211
|
+
const walletFile = path.join(walletsDir, `${config.defaultWallet}.json`);
|
|
212
|
+
if (fs.existsSync(walletFile)) {
|
|
213
|
+
const data = JSON.parse(fs.readFileSync(walletFile, "utf8"));
|
|
214
|
+
if (data.provider === "privy" && data.evm?.privyWalletId) {
|
|
215
|
+
return client.getWallet(data.evm.privyWalletId);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
// Fall through to list-based detection
|
|
222
|
+
if (process.env.DEBUG) console.error(`[x402] Default wallet lookup failed: ${err.message}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const result = await client.listWallets();
|
|
226
|
+
const wallets = result.data || result.wallets || result;
|
|
227
|
+
if (!Array.isArray(wallets)) return null;
|
|
228
|
+
return wallets.find((w) => w.chain_type === "ethereum") || null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolve the Solana wallet for x402 payments.
|
|
233
|
+
* Priority: default local wallet's solana.privyWalletId > first Privy Solana wallet.
|
|
234
|
+
*/
|
|
235
|
+
async function getPrivySolanaWallet(client) {
|
|
236
|
+
try {
|
|
237
|
+
const walletsDir = path.join(process.env.HOME || process.env.USERPROFILE || "", ".nansen", "wallets");
|
|
238
|
+
const configPath = path.join(walletsDir, "config.json");
|
|
239
|
+
if (fs.existsSync(configPath)) {
|
|
240
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
241
|
+
if (config.defaultWallet) {
|
|
242
|
+
const walletFile = path.join(walletsDir, `${config.defaultWallet}.json`);
|
|
243
|
+
if (fs.existsSync(walletFile)) {
|
|
244
|
+
const data = JSON.parse(fs.readFileSync(walletFile, "utf8"));
|
|
245
|
+
if (data.provider === "privy" && data.solana?.privyWalletId) {
|
|
246
|
+
return client.getWallet(data.solana.privyWalletId);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
if (process.env.DEBUG) console.error(`[x402] Solana wallet lookup failed: ${err.message}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const result = await client.listWallets();
|
|
256
|
+
const wallets = result.data || result.wallets || result;
|
|
257
|
+
if (!Array.isArray(wallets)) return null;
|
|
258
|
+
return wallets.find((w) => w.chain_type === "solana") || null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Generate payment signatures for x402 using Privy server wallets.
|
|
263
|
+
* Same yield contract as createPaymentSignatures() in x402.js: { signature, network }
|
|
264
|
+
*
|
|
265
|
+
* @param {Response} response - The 402 HTTP response
|
|
266
|
+
* @param {string} url - The original request URL
|
|
267
|
+
* @returns {AsyncGenerator<{ signature: string, network: string }>}
|
|
268
|
+
*/
|
|
269
|
+
export async function* createPrivyPaymentSignatures(response, url) {
|
|
270
|
+
const requirements = parsePaymentRequirements(response);
|
|
271
|
+
if (!requirements || requirements.length === 0) return;
|
|
272
|
+
|
|
273
|
+
const client = getClient();
|
|
274
|
+
|
|
275
|
+
// EVM requirements
|
|
276
|
+
const evmRequirements = requirements.filter((r) => isEvmNetwork(r.network));
|
|
277
|
+
if (evmRequirements.length > 0) {
|
|
278
|
+
const evmWallet = await getPrivyEvmWallet(client);
|
|
279
|
+
if (evmWallet) {
|
|
280
|
+
for (const requirement of evmRequirements) {
|
|
281
|
+
try {
|
|
282
|
+
const typedData = buildEIP712TypedData({
|
|
283
|
+
fromAddress: evmWallet.address,
|
|
284
|
+
requirement,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const signResult = await client.ethSignTypedDataV4(
|
|
288
|
+
evmWallet.id,
|
|
289
|
+
typedData
|
|
290
|
+
);
|
|
291
|
+
const signature = signResult.data?.signature || signResult.signature;
|
|
292
|
+
|
|
293
|
+
const authorization = {
|
|
294
|
+
from: evmWallet.address,
|
|
295
|
+
to: requirement.payTo,
|
|
296
|
+
value: (requirement.amount || requirement.maxAmountRequired).toString(),
|
|
297
|
+
validAfter: typedData.message.validAfter.toString(),
|
|
298
|
+
validBefore: typedData.message.validBefore.toString(),
|
|
299
|
+
nonce: typedData.message.nonce,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const header = buildPaymentSignatureHeader({
|
|
303
|
+
signature,
|
|
304
|
+
authorization,
|
|
305
|
+
resource: { url, description: "", mimeType: "" },
|
|
306
|
+
accepted: requirement,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
yield { signature: header, network: requirement.network };
|
|
310
|
+
} catch (err) {
|
|
311
|
+
console.error(`[x402] Privy EVM signing failed for ${requirement.network}: ${err.message}`);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
console.error('[x402] No Privy EVM wallet found for payment signing');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Solana requirements
|
|
321
|
+
const svmRequirements = requirements.filter((r) => isSvmNetwork(r.network));
|
|
322
|
+
if (svmRequirements.length > 0) {
|
|
323
|
+
const solWallet = await getPrivySolanaWallet(client);
|
|
324
|
+
if (solWallet) {
|
|
325
|
+
for (const requirement of svmRequirements) {
|
|
326
|
+
try {
|
|
327
|
+
const rpcUrl = getSolanaRpcUrl(requirement.network);
|
|
328
|
+
const recentBlockhash = await fetchRecentBlockhash(rpcUrl);
|
|
329
|
+
|
|
330
|
+
const { txBase64 } = buildUnsignedSvmTransaction(
|
|
331
|
+
requirement,
|
|
332
|
+
solWallet.address,
|
|
333
|
+
recentBlockhash,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const signResult = await client.signSolanaTransaction(solWallet.id, txBase64);
|
|
337
|
+
const signedTx = signResult.data?.signed_transaction || signResult.signed_transaction;
|
|
338
|
+
|
|
339
|
+
const payload = {
|
|
340
|
+
x402Version: 2,
|
|
341
|
+
payload: { transaction: signedTx },
|
|
342
|
+
accepted: requirement,
|
|
343
|
+
};
|
|
344
|
+
if (url) {
|
|
345
|
+
payload.resource = { url };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const header = Buffer.from(JSON.stringify(payload)).toString("base64");
|
|
349
|
+
yield { signature: header, network: requirement.network };
|
|
350
|
+
} catch (err) {
|
|
351
|
+
console.error(`[x402] Privy Solana signing failed for ${requirement.network}: ${err.message}`);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
console.error('[x402] No Privy Solana wallet found for payment signing');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
package/src/schema.json
CHANGED
|
@@ -1831,6 +1831,48 @@
|
|
|
1831
1831
|
}
|
|
1832
1832
|
}
|
|
1833
1833
|
}
|
|
1834
|
+
},
|
|
1835
|
+
"wallet": {
|
|
1836
|
+
"description": "Wallet management (local or Privy server wallets)",
|
|
1837
|
+
"options": {
|
|
1838
|
+
"provider": {
|
|
1839
|
+
"type": "string",
|
|
1840
|
+
"enum": ["local", "privy"],
|
|
1841
|
+
"description": "Wallet provider (default: local). Use 'privy' for server-managed wallets."
|
|
1842
|
+
}
|
|
1843
|
+
},
|
|
1844
|
+
"subcommands": {
|
|
1845
|
+
"create": {
|
|
1846
|
+
"description": "Create a new wallet",
|
|
1847
|
+
"options": {
|
|
1848
|
+
"name": { "type": "string", "description": "Wallet name (default: 'default')" }
|
|
1849
|
+
}
|
|
1850
|
+
},
|
|
1851
|
+
"list": { "description": "List all wallets" },
|
|
1852
|
+
"show": {
|
|
1853
|
+
"description": "Show wallet details",
|
|
1854
|
+
"options": {
|
|
1855
|
+
"name": { "type": "string", "description": "Wallet name" }
|
|
1856
|
+
}
|
|
1857
|
+
},
|
|
1858
|
+
"delete": {
|
|
1859
|
+
"description": "Delete a wallet",
|
|
1860
|
+
"options": {
|
|
1861
|
+
"name": { "type": "string", "description": "Wallet name" }
|
|
1862
|
+
}
|
|
1863
|
+
},
|
|
1864
|
+
"send": {
|
|
1865
|
+
"description": "Send tokens or native currency",
|
|
1866
|
+
"options": {
|
|
1867
|
+
"to": { "type": "string", "required": true, "description": "Recipient address" },
|
|
1868
|
+
"amount": { "type": "string", "description": "Amount to send" },
|
|
1869
|
+
"chain": { "type": "string", "required": true, "description": "Blockchain to use" }
|
|
1870
|
+
}
|
|
1871
|
+
},
|
|
1872
|
+
"export": { "description": "Export private keys (local only, requires password)" },
|
|
1873
|
+
"default": { "description": "Set default wallet (local only)" },
|
|
1874
|
+
"help": { "description": "Show wallet help" }
|
|
1875
|
+
}
|
|
1834
1876
|
}
|
|
1835
1877
|
},
|
|
1836
1878
|
"globalOptions": {
|
|
@@ -1879,13 +1921,11 @@
|
|
|
1879
1921
|
"avalanche",
|
|
1880
1922
|
"linea",
|
|
1881
1923
|
"scroll",
|
|
1882
|
-
"zksync",
|
|
1883
1924
|
"mantle",
|
|
1884
1925
|
"ronin",
|
|
1885
1926
|
"sei",
|
|
1886
1927
|
"plasma",
|
|
1887
1928
|
"sonic",
|
|
1888
|
-
"unichain",
|
|
1889
1929
|
"monad",
|
|
1890
1930
|
"hyperevm",
|
|
1891
1931
|
"iotaevm"
|