x402-proxy 0.6.0 → 0.7.1
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 +22 -1
- package/README.md +11 -2
- package/dist/bin/cli.js +76 -29
- package/dist/index.js +18 -14
- package/dist/openclaw/plugin.d.ts +55 -0
- package/dist/openclaw/plugin.js +1120 -0
- package/dist/openclaw.plugin.json +28 -0
- package/openclaw.plugin.json +28 -0
- package/package.json +26 -4
- package/skills/SKILL.md +14 -2
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
import os, { homedir } from "node:os";
|
|
2
|
+
import path, { dirname, join } from "node:path";
|
|
3
|
+
import { address, appendTransactionMessageInstructions, createKeyPairSignerFromBytes, createSolanaRpc, createTransactionMessage, getAddressEncoder, getBase64EncodedWireTransaction, getProgramDerivedAddress, partiallySignTransactionMessageWithSigners, pipe, setTransactionMessageFeePayer, setTransactionMessageLifetimeUsingBlockhash } from "@solana/kit";
|
|
4
|
+
import { decodePaymentResponseHeader, wrapFetchWithPayment, x402Client } from "@x402/fetch";
|
|
5
|
+
import { ExactSvmScheme } from "@x402/svm/exact/client";
|
|
6
|
+
import fs, { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
7
|
+
import "yaml";
|
|
8
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
9
|
+
import { base58 } from "@scure/base";
|
|
10
|
+
import "@x402/evm";
|
|
11
|
+
import "@x402/evm/exact/client";
|
|
12
|
+
import "viem";
|
|
13
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
14
|
+
import "viem/chains";
|
|
15
|
+
import { secp256k1 } from "@noble/curves/secp256k1.js";
|
|
16
|
+
import { hmac } from "@noble/hashes/hmac.js";
|
|
17
|
+
import { sha512 } from "@noble/hashes/sha2.js";
|
|
18
|
+
import { keccak_256 } from "@noble/hashes/sha3.js";
|
|
19
|
+
import { HDKey } from "@scure/bip32";
|
|
20
|
+
import { mnemonicToSeedSync } from "@scure/bip39";
|
|
21
|
+
import "@scure/bip39/wordlists/english.js";
|
|
22
|
+
import { Type } from "@sinclair/typebox";
|
|
23
|
+
|
|
24
|
+
//#region src/handler.ts
|
|
25
|
+
/**
|
|
26
|
+
* Extract the on-chain transaction signature from an x402 payment response header.
|
|
27
|
+
*/
|
|
28
|
+
function extractTxSignature(response) {
|
|
29
|
+
const x402Header = response.headers.get("PAYMENT-RESPONSE") ?? response.headers.get("X-PAYMENT-RESPONSE");
|
|
30
|
+
if (x402Header) try {
|
|
31
|
+
return decodePaymentResponseHeader(x402Header).transaction ?? void 0;
|
|
32
|
+
} catch {}
|
|
33
|
+
const mppHeader = response.headers.get("Payment-Receipt");
|
|
34
|
+
if (mppHeader) try {
|
|
35
|
+
return JSON.parse(Buffer.from(mppHeader, "base64url").toString()).reference ?? void 0;
|
|
36
|
+
} catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Create an x402 proxy handler that wraps fetch with automatic payment.
|
|
42
|
+
*/
|
|
43
|
+
function createX402ProxyHandler(opts) {
|
|
44
|
+
const { client } = opts;
|
|
45
|
+
const paymentQueue = [];
|
|
46
|
+
client.onAfterPaymentCreation(async (hookCtx) => {
|
|
47
|
+
const raw = hookCtx.selectedRequirements.amount;
|
|
48
|
+
paymentQueue.push({
|
|
49
|
+
protocol: "x402",
|
|
50
|
+
network: hookCtx.selectedRequirements.network,
|
|
51
|
+
payTo: hookCtx.selectedRequirements.payTo,
|
|
52
|
+
amount: raw,
|
|
53
|
+
asset: hookCtx.selectedRequirements.asset
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
x402Fetch: wrapFetchWithPayment(globalThis.fetch, client),
|
|
58
|
+
shiftPayment: () => paymentQueue.shift()
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/lib/config.ts
|
|
64
|
+
const APP_NAME = "x402-proxy";
|
|
65
|
+
function getConfigDir() {
|
|
66
|
+
const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
67
|
+
return path.join(xdg, APP_NAME);
|
|
68
|
+
}
|
|
69
|
+
function getWalletPath() {
|
|
70
|
+
return path.join(getConfigDir(), "wallet.json");
|
|
71
|
+
}
|
|
72
|
+
function getHistoryPath() {
|
|
73
|
+
return path.join(getConfigDir(), "history.jsonl");
|
|
74
|
+
}
|
|
75
|
+
function loadWalletFile() {
|
|
76
|
+
const p = getWalletPath();
|
|
77
|
+
if (!fs.existsSync(p)) return null;
|
|
78
|
+
try {
|
|
79
|
+
const data = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
80
|
+
if (data.version === 1 && typeof data.mnemonic === "string") return data;
|
|
81
|
+
return null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/history.ts
|
|
89
|
+
const HISTORY_MAX_LINES = 1e3;
|
|
90
|
+
const HISTORY_KEEP_LINES = 500;
|
|
91
|
+
function appendHistory(historyPath, record) {
|
|
92
|
+
try {
|
|
93
|
+
mkdirSync(dirname(historyPath), { recursive: true });
|
|
94
|
+
appendFileSync(historyPath, `${JSON.stringify(record)}\n`);
|
|
95
|
+
if (existsSync(historyPath)) {
|
|
96
|
+
if (statSync(historyPath).size > HISTORY_MAX_LINES * 200) {
|
|
97
|
+
const lines = readFileSync(historyPath, "utf-8").trimEnd().split("\n");
|
|
98
|
+
if (lines.length > HISTORY_MAX_LINES) writeFileSync(historyPath, `${lines.slice(-HISTORY_KEEP_LINES).join("\n")}\n`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
function readHistory(historyPath) {
|
|
104
|
+
try {
|
|
105
|
+
if (!existsSync(historyPath)) return [];
|
|
106
|
+
const content = readFileSync(historyPath, "utf-8").trimEnd();
|
|
107
|
+
if (!content) return [];
|
|
108
|
+
return content.split("\n").flatMap((line) => {
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(line);
|
|
111
|
+
if (typeof parsed.t !== "number" || typeof parsed.kind !== "string") return [];
|
|
112
|
+
return [parsed];
|
|
113
|
+
} catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function calcSpend(records) {
|
|
122
|
+
const todayStart = /* @__PURE__ */ new Date();
|
|
123
|
+
todayStart.setUTCHours(0, 0, 0, 0);
|
|
124
|
+
const todayMs = todayStart.getTime();
|
|
125
|
+
let today = 0;
|
|
126
|
+
let total = 0;
|
|
127
|
+
let count = 0;
|
|
128
|
+
for (const r of records) {
|
|
129
|
+
if (!r.ok || r.amount == null) continue;
|
|
130
|
+
if (r.token !== "USDC") continue;
|
|
131
|
+
total += r.amount;
|
|
132
|
+
count++;
|
|
133
|
+
if (r.t >= todayMs) today += r.amount;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
today,
|
|
137
|
+
total,
|
|
138
|
+
count
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function formatAmount(amount, token) {
|
|
142
|
+
if (token === "USDC") {
|
|
143
|
+
if (amount >= .01) return `${amount.toFixed(2)} USDC`;
|
|
144
|
+
if (amount >= .001) return `${amount.toFixed(3)} USDC`;
|
|
145
|
+
if (amount >= 1e-4) return `${amount.toFixed(4)} USDC`;
|
|
146
|
+
return `${amount.toFixed(6)} USDC`;
|
|
147
|
+
}
|
|
148
|
+
if (token === "SOL") return `${amount} SOL`;
|
|
149
|
+
return `${amount} ${token}`;
|
|
150
|
+
}
|
|
151
|
+
const KIND_LABELS = {
|
|
152
|
+
x402_inference: "inference",
|
|
153
|
+
x402_payment: "payment",
|
|
154
|
+
mpp_payment: "mpp payment",
|
|
155
|
+
transfer: "transfer",
|
|
156
|
+
buy: "buy",
|
|
157
|
+
sell: "sell",
|
|
158
|
+
mint: "mint",
|
|
159
|
+
swap: "swap"
|
|
160
|
+
};
|
|
161
|
+
function explorerUrl(net, tx) {
|
|
162
|
+
if (net.startsWith("eip155:")) {
|
|
163
|
+
const chainId = net.split(":")[1];
|
|
164
|
+
if (chainId === "4217") return `https://explore.mainnet.tempo.xyz/tx/${tx}`;
|
|
165
|
+
if (chainId === "8453") return `https://basescan.org/tx/${tx}`;
|
|
166
|
+
return `https://basescan.org/tx/${tx}`;
|
|
167
|
+
}
|
|
168
|
+
return `https://solscan.io/tx/${tx}`;
|
|
169
|
+
}
|
|
170
|
+
/** Strip provider prefix and OpenRouter date suffixes from model IDs.
|
|
171
|
+
* e.g. "minimax/minimax-m2.5-20260211" -> "minimax-m2.5"
|
|
172
|
+
* "moonshotai/kimi-k2.5-0127" -> "kimi-k2.5" */
|
|
173
|
+
function shortModel(model) {
|
|
174
|
+
const parts = model.split("/");
|
|
175
|
+
return parts[parts.length - 1].replace(/-\d{6,8}$/, "").replace(/-\d{4}$/, "");
|
|
176
|
+
}
|
|
177
|
+
function shortNetwork(net) {
|
|
178
|
+
if (net === "eip155:8453") return "base";
|
|
179
|
+
if (net === "eip155:4217") return "tempo";
|
|
180
|
+
if (net.startsWith("eip155:")) return `evm:${net.split(":")[1]}`;
|
|
181
|
+
if (net.startsWith("solana:")) return "sol";
|
|
182
|
+
return net;
|
|
183
|
+
}
|
|
184
|
+
function formatTxLine(r, opts) {
|
|
185
|
+
const time = new Date(r.t).toLocaleTimeString("en-US", {
|
|
186
|
+
hour: "2-digit",
|
|
187
|
+
minute: "2-digit",
|
|
188
|
+
hour12: false,
|
|
189
|
+
timeZone: "UTC"
|
|
190
|
+
});
|
|
191
|
+
const timeStr = r.tx ? `[${time}](${explorerUrl(r.net, r.tx)})` : time;
|
|
192
|
+
const parts = [r.kind === "x402_inference" && r.model ? shortModel(r.model) : KIND_LABELS[r.kind] ?? r.kind];
|
|
193
|
+
if (r.label) parts.push(r.label);
|
|
194
|
+
if (r.ok && r.amount != null && r.token) parts.push(formatAmount(r.amount, r.token));
|
|
195
|
+
else if (r.ok && r.kind === "sell" && r.meta?.pct != null) parts.push(`${r.meta.pct}%`);
|
|
196
|
+
parts.push(shortNetwork(r.net));
|
|
197
|
+
if (opts?.verbose && r.tx) {
|
|
198
|
+
const short = r.tx.length > 20 ? `${r.tx.slice(0, 10)}...${r.tx.slice(-6)}` : r.tx;
|
|
199
|
+
parts.push(short);
|
|
200
|
+
}
|
|
201
|
+
return ` ${timeStr} ${r.ok ? "" : "✗ "}${parts.join(" · ")}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/lib/derive.ts
|
|
206
|
+
/**
|
|
207
|
+
* Wallet derivation from BIP-39 mnemonic.
|
|
208
|
+
*
|
|
209
|
+
* A single 24-word mnemonic is the root secret. Both Solana and EVM keypairs
|
|
210
|
+
* are deterministically derived from it.
|
|
211
|
+
*
|
|
212
|
+
* Solana: SLIP-10 Ed25519 at m/44'/501'/0'/0'
|
|
213
|
+
* EVM: BIP-32 secp256k1 at m/44'/60'/0'/0/0
|
|
214
|
+
*
|
|
215
|
+
* Ported from agentbox/packages/openclaw-x402/src/wallet.ts
|
|
216
|
+
*/
|
|
217
|
+
/**
|
|
218
|
+
* SLIP-10 Ed25519 derivation at m/44'/501'/0'/0' (Phantom/Backpack compatible).
|
|
219
|
+
*/
|
|
220
|
+
const enc = new TextEncoder();
|
|
221
|
+
function deriveSolanaKeypair(mnemonic) {
|
|
222
|
+
const seed = mnemonicToSeedSync(mnemonic);
|
|
223
|
+
let I = hmac(sha512, enc.encode("ed25519 seed"), seed);
|
|
224
|
+
let key = I.slice(0, 32);
|
|
225
|
+
let chainCode = I.slice(32);
|
|
226
|
+
for (const index of [
|
|
227
|
+
2147483692,
|
|
228
|
+
2147484149,
|
|
229
|
+
2147483648,
|
|
230
|
+
2147483648
|
|
231
|
+
]) {
|
|
232
|
+
const data = new Uint8Array(37);
|
|
233
|
+
data[0] = 0;
|
|
234
|
+
data.set(key, 1);
|
|
235
|
+
data[33] = index >>> 24 & 255;
|
|
236
|
+
data[34] = index >>> 16 & 255;
|
|
237
|
+
data[35] = index >>> 8 & 255;
|
|
238
|
+
data[36] = index & 255;
|
|
239
|
+
I = hmac(sha512, chainCode, data);
|
|
240
|
+
key = I.slice(0, 32);
|
|
241
|
+
chainCode = I.slice(32);
|
|
242
|
+
}
|
|
243
|
+
const secretKey = new Uint8Array(key);
|
|
244
|
+
const publicKey = ed25519.getPublicKey(secretKey);
|
|
245
|
+
return {
|
|
246
|
+
secretKey,
|
|
247
|
+
publicKey,
|
|
248
|
+
address: base58.encode(publicKey)
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* BIP-32 secp256k1 derivation at m/44'/60'/0'/0/0.
|
|
253
|
+
*/
|
|
254
|
+
function deriveEvmKeypair(mnemonic) {
|
|
255
|
+
const seed = mnemonicToSeedSync(mnemonic);
|
|
256
|
+
const derived = HDKey.fromMasterSeed(seed).derive("m/44'/60'/0'/0/0");
|
|
257
|
+
if (!derived.privateKey) throw new Error("Failed to derive EVM private key");
|
|
258
|
+
const privateKey = `0x${Buffer.from(derived.privateKey).toString("hex")}`;
|
|
259
|
+
const hash = keccak_256(secp256k1.getPublicKey(derived.privateKey, false).slice(1));
|
|
260
|
+
return {
|
|
261
|
+
privateKey,
|
|
262
|
+
address: checksumAddress(Buffer.from(hash.slice(-20)).toString("hex"))
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function checksumAddress(addr) {
|
|
266
|
+
const hash = Buffer.from(keccak_256(enc.encode(addr))).toString("hex");
|
|
267
|
+
let out = "0x";
|
|
268
|
+
for (let i = 0; i < 40; i++) out += Number.parseInt(hash[i], 16) >= 8 ? addr[i].toUpperCase() : addr[i];
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/lib/resolve-wallet.ts
|
|
274
|
+
/**
|
|
275
|
+
* Resolve wallet keys following the priority cascade:
|
|
276
|
+
* 1. Flags (--evm-key / --solana-key as raw key strings)
|
|
277
|
+
* 2. X402_PROXY_WALLET_EVM_KEY / X402_PROXY_WALLET_SOLANA_KEY env vars
|
|
278
|
+
* 3. X402_PROXY_WALLET_MNEMONIC env var (derives both)
|
|
279
|
+
* 4. ~/.config/x402-proxy/wallet.json (mnemonic file)
|
|
280
|
+
*/
|
|
281
|
+
function resolveWallet(opts) {
|
|
282
|
+
if (opts?.evmKey || opts?.solanaKey) {
|
|
283
|
+
const result = { source: "flag" };
|
|
284
|
+
if (opts.evmKey) {
|
|
285
|
+
const hex = opts.evmKey.startsWith("0x") ? opts.evmKey : `0x${opts.evmKey}`;
|
|
286
|
+
result.evmKey = hex;
|
|
287
|
+
result.evmAddress = privateKeyToAccount(hex).address;
|
|
288
|
+
}
|
|
289
|
+
if (opts.solanaKey) {
|
|
290
|
+
result.solanaKey = parsesolanaKey(opts.solanaKey);
|
|
291
|
+
result.solanaAddress = solanaAddressFromKey(result.solanaKey);
|
|
292
|
+
}
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
const envEvm = process.env.X402_PROXY_WALLET_EVM_KEY;
|
|
296
|
+
const envSol = process.env.X402_PROXY_WALLET_SOLANA_KEY;
|
|
297
|
+
if (envEvm || envSol) {
|
|
298
|
+
const result = { source: "env" };
|
|
299
|
+
if (envEvm) {
|
|
300
|
+
const hex = envEvm.startsWith("0x") ? envEvm : `0x${envEvm}`;
|
|
301
|
+
result.evmKey = hex;
|
|
302
|
+
result.evmAddress = privateKeyToAccount(hex).address;
|
|
303
|
+
}
|
|
304
|
+
if (envSol) {
|
|
305
|
+
result.solanaKey = parsesolanaKey(envSol);
|
|
306
|
+
result.solanaAddress = solanaAddressFromKey(result.solanaKey);
|
|
307
|
+
}
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
const envMnemonic = process.env.X402_PROXY_WALLET_MNEMONIC;
|
|
311
|
+
if (envMnemonic) return resolveFromMnemonic(envMnemonic, "mnemonic-env");
|
|
312
|
+
const walletFile = loadWalletFile();
|
|
313
|
+
if (walletFile) return resolveFromMnemonic(walletFile.mnemonic, "wallet-file");
|
|
314
|
+
return { source: "none" };
|
|
315
|
+
}
|
|
316
|
+
function resolveFromMnemonic(mnemonic, source) {
|
|
317
|
+
const evm = deriveEvmKeypair(mnemonic);
|
|
318
|
+
const sol = deriveSolanaKeypair(mnemonic);
|
|
319
|
+
const solanaKey = new Uint8Array(64);
|
|
320
|
+
solanaKey.set(sol.secretKey, 0);
|
|
321
|
+
solanaKey.set(sol.publicKey, 32);
|
|
322
|
+
return {
|
|
323
|
+
evmKey: evm.privateKey,
|
|
324
|
+
evmAddress: evm.address,
|
|
325
|
+
solanaKey,
|
|
326
|
+
solanaAddress: sol.address,
|
|
327
|
+
source
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
function parsesolanaKey(input) {
|
|
331
|
+
const trimmed = input.trim();
|
|
332
|
+
if (trimmed.startsWith("[")) {
|
|
333
|
+
const arr = JSON.parse(trimmed);
|
|
334
|
+
return new Uint8Array(arr);
|
|
335
|
+
}
|
|
336
|
+
return base58.decode(trimmed);
|
|
337
|
+
}
|
|
338
|
+
function solanaAddressFromKey(keyBytes) {
|
|
339
|
+
if (keyBytes.length >= 64) return base58.encode(keyBytes.slice(32));
|
|
340
|
+
return base58.encode(ed25519.getPublicKey(keyBytes));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
//#endregion
|
|
344
|
+
//#region src/wallet.ts
|
|
345
|
+
/**
|
|
346
|
+
* Load a Solana keypair from a solana-keygen JSON file.
|
|
347
|
+
* Format: JSON array of 64 numbers [32 secret bytes + 32 public bytes].
|
|
348
|
+
*/
|
|
349
|
+
async function loadSvmWallet(keypairPath) {
|
|
350
|
+
const data = JSON.parse(readFileSync(keypairPath, "utf-8"));
|
|
351
|
+
return createKeyPairSignerFromBytes(new Uint8Array(data));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region src/openclaw/solana.ts
|
|
356
|
+
const USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
357
|
+
const TOKEN_PROGRAM = address("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
|
|
358
|
+
const ASSOCIATED_TOKEN_PROGRAM = address("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL");
|
|
359
|
+
async function findAta(mint, owner) {
|
|
360
|
+
const encoder = getAddressEncoder();
|
|
361
|
+
const [pda] = await getProgramDerivedAddress({
|
|
362
|
+
programAddress: ASSOCIATED_TOKEN_PROGRAM,
|
|
363
|
+
seeds: [
|
|
364
|
+
encoder.encode(owner),
|
|
365
|
+
encoder.encode(TOKEN_PROGRAM),
|
|
366
|
+
encoder.encode(mint)
|
|
367
|
+
]
|
|
368
|
+
});
|
|
369
|
+
return pda;
|
|
370
|
+
}
|
|
371
|
+
function transferCheckedIx(source, mint, destination, authority, amount, decimals) {
|
|
372
|
+
const data = new Uint8Array(10);
|
|
373
|
+
data[0] = 12;
|
|
374
|
+
new DataView(data.buffer).setBigUint64(1, amount, true);
|
|
375
|
+
data[9] = decimals;
|
|
376
|
+
return {
|
|
377
|
+
programAddress: TOKEN_PROGRAM,
|
|
378
|
+
accounts: [
|
|
379
|
+
{
|
|
380
|
+
address: source,
|
|
381
|
+
role: 2
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
address: mint,
|
|
385
|
+
role: 0
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
address: destination,
|
|
389
|
+
role: 2
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
address: authority.address,
|
|
393
|
+
role: 1
|
|
394
|
+
}
|
|
395
|
+
],
|
|
396
|
+
data
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
async function getTokenAccounts(rpcUrl, owner) {
|
|
400
|
+
const { value } = await createSolanaRpc(rpcUrl).getTokenAccountsByOwner(address(owner), { programId: TOKEN_PROGRAM }, { encoding: "jsonParsed" }).send();
|
|
401
|
+
return value.map((v) => {
|
|
402
|
+
const info = v.account.data.parsed.info;
|
|
403
|
+
return {
|
|
404
|
+
mint: info.mint,
|
|
405
|
+
amount: info.tokenAmount.uiAmountString,
|
|
406
|
+
decimals: info.tokenAmount.decimals
|
|
407
|
+
};
|
|
408
|
+
}).filter((t) => t.mint !== USDC_MINT && t.amount !== "0");
|
|
409
|
+
}
|
|
410
|
+
async function getUsdcBalance(rpcUrl, owner) {
|
|
411
|
+
const { value } = await createSolanaRpc(rpcUrl).getTokenAccountsByOwner(address(owner), { mint: address(USDC_MINT) }, { encoding: "jsonParsed" }).send();
|
|
412
|
+
if (value.length > 0) {
|
|
413
|
+
const ta = value[0].account.data.parsed.info.tokenAmount;
|
|
414
|
+
return {
|
|
415
|
+
raw: BigInt(ta.amount),
|
|
416
|
+
ui: ta.uiAmount !== null ? ta.uiAmount.toFixed(2) : "0.00"
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
raw: 0n,
|
|
421
|
+
ui: "0.00"
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
async function getSolBalanceLamports(rpcUrl, owner) {
|
|
425
|
+
const { value } = await createSolanaRpc(rpcUrl).getBalance(address(owner)).send();
|
|
426
|
+
return value;
|
|
427
|
+
}
|
|
428
|
+
async function getSolBalance(rpcUrl, owner) {
|
|
429
|
+
const lamports = await getSolBalanceLamports(rpcUrl, owner);
|
|
430
|
+
return (Number(lamports) / 1e9).toFixed(4);
|
|
431
|
+
}
|
|
432
|
+
async function checkAtaExists(rpcUrl, owner) {
|
|
433
|
+
const ata = await findAta(address(USDC_MINT), address(owner));
|
|
434
|
+
const { value } = await createSolanaRpc(rpcUrl).getAccountInfo(ata, { encoding: "base64" }).send();
|
|
435
|
+
return value !== null;
|
|
436
|
+
}
|
|
437
|
+
async function transferUsdc(signer, rpcUrl, dest, amountRaw) {
|
|
438
|
+
const rpc = createSolanaRpc(rpcUrl);
|
|
439
|
+
const usdcMint = address(USDC_MINT);
|
|
440
|
+
const transferIx = transferCheckedIx(await findAta(usdcMint, signer.address), usdcMint, await findAta(usdcMint, address(dest)), signer, amountRaw, 6);
|
|
441
|
+
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
|
|
442
|
+
const encoded = getBase64EncodedWireTransaction(await partiallySignTransactionMessageWithSigners(pipe(createTransactionMessage({ version: 0 }), (m) => setTransactionMessageFeePayer(signer.address, m), (m) => appendTransactionMessageInstructions([transferIx], m), (m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m))));
|
|
443
|
+
return await rpc.sendTransaction(encoded, { encoding: "base64" }).send();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
//#endregion
|
|
447
|
+
//#region src/openclaw/tools.ts
|
|
448
|
+
const SOL_MAINNET = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
|
|
449
|
+
const USDC_DECIMALS = 6;
|
|
450
|
+
const INFERENCE_RESERVE = .3;
|
|
451
|
+
const MAX_RESPONSE_CHARS = 5e4;
|
|
452
|
+
function paymentAmount(payment) {
|
|
453
|
+
if (!payment?.amount) return void 0;
|
|
454
|
+
const parsed = Number.parseFloat(payment.amount);
|
|
455
|
+
return Number.isNaN(parsed) ? void 0 : parsed / 10 ** USDC_DECIMALS;
|
|
456
|
+
}
|
|
457
|
+
function toolResult(text) {
|
|
458
|
+
return {
|
|
459
|
+
content: [{
|
|
460
|
+
type: "text",
|
|
461
|
+
text
|
|
462
|
+
}],
|
|
463
|
+
details: {}
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
async function getWalletSnapshot(rpcUrl, wallet, historyPath) {
|
|
467
|
+
const [{ ui, raw }, sol, tokens] = await Promise.all([
|
|
468
|
+
getUsdcBalance(rpcUrl, wallet),
|
|
469
|
+
getSolBalance(rpcUrl, wallet),
|
|
470
|
+
getTokenAccounts(rpcUrl, wallet).catch(() => [])
|
|
471
|
+
]);
|
|
472
|
+
const records = readHistory(historyPath);
|
|
473
|
+
return {
|
|
474
|
+
ui,
|
|
475
|
+
raw,
|
|
476
|
+
sol,
|
|
477
|
+
tokens,
|
|
478
|
+
records,
|
|
479
|
+
spend: calcSpend(records)
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function createBalanceTool(ctx) {
|
|
483
|
+
return {
|
|
484
|
+
name: "x_balance",
|
|
485
|
+
label: "Wallet Balance",
|
|
486
|
+
description: "Check wallet SOL and USDC balances. Use before making payments to verify sufficient funds.",
|
|
487
|
+
parameters: Type.Object({}),
|
|
488
|
+
async execute() {
|
|
489
|
+
const walletAddress = ctx.getWalletAddress();
|
|
490
|
+
if (!walletAddress) return toolResult("Wallet not loaded yet. Wait for gateway startup.");
|
|
491
|
+
try {
|
|
492
|
+
const snap = await getWalletSnapshot(ctx.rpcUrl, walletAddress, ctx.historyPath);
|
|
493
|
+
const total = Number.parseFloat(snap.ui);
|
|
494
|
+
const available = Math.max(0, total - INFERENCE_RESERVE);
|
|
495
|
+
const tokenLines = snap.tokens.slice(0, 5).map((t) => {
|
|
496
|
+
const short = `${t.mint.slice(0, 4)}...${t.mint.slice(-4)}`;
|
|
497
|
+
return `${Number.parseFloat(t.amount).toLocaleString("en-US", { maximumFractionDigits: 0 })} (${short})`;
|
|
498
|
+
});
|
|
499
|
+
return toolResult([
|
|
500
|
+
`Wallet: ${walletAddress}`,
|
|
501
|
+
`SOL: ${snap.sol} SOL`,
|
|
502
|
+
`USDC: ${snap.ui} USDC`,
|
|
503
|
+
`Available for tools: ${available.toFixed(2)} USDC`,
|
|
504
|
+
`Reserved for inference: ${INFERENCE_RESERVE.toFixed(2)} USDC`,
|
|
505
|
+
`Spent today: ${snap.spend.today.toFixed(4)} USDC`,
|
|
506
|
+
`Total spent: ${snap.spend.total.toFixed(4)} USDC (${snap.spend.count} txs)`,
|
|
507
|
+
...tokenLines.length > 0 ? [`Tokens held: ${tokenLines.join(", ")}`] : []
|
|
508
|
+
].join("\n"));
|
|
509
|
+
} catch (err) {
|
|
510
|
+
return toolResult(`Failed to check balance: ${String(err)}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
function createPaymentTool(ctx) {
|
|
516
|
+
return {
|
|
517
|
+
name: "x_payment",
|
|
518
|
+
label: "x402 Payment",
|
|
519
|
+
description: `Call an x402-enabled paid API endpoint with automatic USDC payment on Solana. Use this when you need to call a paid service given by the user. Note: ${INFERENCE_RESERVE.toFixed(2)} USDC is reserved for LLM inference and cannot be spent by this tool.`,
|
|
520
|
+
parameters: Type.Object({
|
|
521
|
+
url: Type.String({ description: "The x402-enabled endpoint URL" }),
|
|
522
|
+
method: Type.Optional(Type.String({ description: "HTTP method (default: GET)" })),
|
|
523
|
+
params: Type.Optional(Type.String({ description: "For GET: query params as JSON object. For POST/PUT/PATCH: JSON request body." })),
|
|
524
|
+
headers: Type.Optional(Type.String({ description: "Custom HTTP headers as JSON object" }))
|
|
525
|
+
}),
|
|
526
|
+
async execute(_id, params) {
|
|
527
|
+
const walletAddress = ctx.getWalletAddress();
|
|
528
|
+
if (!walletAddress) return toolResult("Wallet not loaded yet. Wait for gateway startup.");
|
|
529
|
+
try {
|
|
530
|
+
const { ui } = await getUsdcBalance(ctx.rpcUrl, walletAddress);
|
|
531
|
+
if (Number.parseFloat(ui) <= INFERENCE_RESERVE) return toolResult(`Insufficient funds. Balance: ${ui} USDC, reserved for inference: ${INFERENCE_RESERVE.toFixed(2)} USDC. Top up wallet: ${walletAddress}`);
|
|
532
|
+
} catch {}
|
|
533
|
+
const method = (params.method || "GET").toUpperCase();
|
|
534
|
+
let url = params.url;
|
|
535
|
+
const reqInit = { method };
|
|
536
|
+
if (params.headers) try {
|
|
537
|
+
reqInit.headers = JSON.parse(params.headers);
|
|
538
|
+
} catch {
|
|
539
|
+
return toolResult("Invalid headers JSON.");
|
|
540
|
+
}
|
|
541
|
+
if (params.params) if (method === "GET" || method === "HEAD") try {
|
|
542
|
+
const qp = JSON.parse(params.params);
|
|
543
|
+
const qs = new URLSearchParams(qp).toString();
|
|
544
|
+
url = qs ? `${url}${url.includes("?") ? "&" : "?"}${qs}` : url;
|
|
545
|
+
} catch {
|
|
546
|
+
return toolResult("Invalid params JSON for GET request.");
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
reqInit.body = params.params;
|
|
550
|
+
reqInit.headers = {
|
|
551
|
+
"Content-Type": "application/json",
|
|
552
|
+
...reqInit.headers
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
const toolStartMs = Date.now();
|
|
556
|
+
try {
|
|
557
|
+
const response = await ctx.proxy.x402Fetch(url, reqInit);
|
|
558
|
+
const body = await response.text();
|
|
559
|
+
if (response.status === 402) {
|
|
560
|
+
ctx.proxy.shiftPayment();
|
|
561
|
+
appendHistory(ctx.historyPath, {
|
|
562
|
+
t: Date.now(),
|
|
563
|
+
ok: false,
|
|
564
|
+
kind: "x402_payment",
|
|
565
|
+
net: SOL_MAINNET,
|
|
566
|
+
from: walletAddress,
|
|
567
|
+
label: url,
|
|
568
|
+
ms: Date.now() - toolStartMs,
|
|
569
|
+
error: "payment_required"
|
|
570
|
+
});
|
|
571
|
+
return toolResult(`Payment failed (402): ${body.substring(0, 500)}. Wallet: ${walletAddress}`);
|
|
572
|
+
}
|
|
573
|
+
const payment = ctx.proxy.shiftPayment();
|
|
574
|
+
const amount = paymentAmount(payment);
|
|
575
|
+
appendHistory(ctx.historyPath, {
|
|
576
|
+
t: Date.now(),
|
|
577
|
+
ok: true,
|
|
578
|
+
kind: "x402_payment",
|
|
579
|
+
net: SOL_MAINNET,
|
|
580
|
+
from: walletAddress,
|
|
581
|
+
to: payment?.payTo,
|
|
582
|
+
tx: extractTxSignature(response),
|
|
583
|
+
amount,
|
|
584
|
+
token: "USDC",
|
|
585
|
+
label: url,
|
|
586
|
+
ms: Date.now() - toolStartMs
|
|
587
|
+
});
|
|
588
|
+
const truncated = body.length > MAX_RESPONSE_CHARS ? `${body.substring(0, MAX_RESPONSE_CHARS)}\n\n[Truncated - response was ${body.length} chars]` : body;
|
|
589
|
+
return toolResult(`HTTP ${response.status}\n\n${truncated}`);
|
|
590
|
+
} catch (err) {
|
|
591
|
+
ctx.proxy.shiftPayment();
|
|
592
|
+
appendHistory(ctx.historyPath, {
|
|
593
|
+
t: Date.now(),
|
|
594
|
+
ok: false,
|
|
595
|
+
kind: "x402_payment",
|
|
596
|
+
net: SOL_MAINNET,
|
|
597
|
+
from: walletAddress,
|
|
598
|
+
label: params.url,
|
|
599
|
+
error: String(err).substring(0, 200)
|
|
600
|
+
});
|
|
601
|
+
const msg = String(err);
|
|
602
|
+
return toolResult(msg.includes("Simulation failed") || msg.includes("insufficient") ? `Payment failed - insufficient funds. Wallet: ${walletAddress}. Error: ${msg}` : `Request failed: ${msg}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region src/openclaw/commands.ts
|
|
610
|
+
const HISTORY_PAGE_SIZE = 5;
|
|
611
|
+
const STATUS_HISTORY_COUNT = 3;
|
|
612
|
+
const INLINE_HISTORY_TOKEN_THRESHOLD = 3;
|
|
613
|
+
const TOKEN_SYMBOL_CACHE_MAX = 200;
|
|
614
|
+
const tokenSymbolCache = /* @__PURE__ */ new Map();
|
|
615
|
+
async function resolveTokenSymbols(mints) {
|
|
616
|
+
const result = /* @__PURE__ */ new Map();
|
|
617
|
+
const toResolve = [];
|
|
618
|
+
for (const m of mints) {
|
|
619
|
+
const cached = tokenSymbolCache.get(m);
|
|
620
|
+
if (cached) result.set(m, cached);
|
|
621
|
+
else toResolve.push(m);
|
|
622
|
+
}
|
|
623
|
+
if (toResolve.length === 0) return result;
|
|
624
|
+
try {
|
|
625
|
+
const res = await globalThis.fetch(`https://api.dexscreener.com/tokens/v1/solana/${toResolve.join(",")}`, { signal: AbortSignal.timeout(5e3) });
|
|
626
|
+
if (!res.ok) return result;
|
|
627
|
+
const pairs = await res.json();
|
|
628
|
+
for (const pair of pairs) {
|
|
629
|
+
const addr = pair.baseToken?.address;
|
|
630
|
+
const sym = pair.baseToken?.symbol;
|
|
631
|
+
if (addr && sym && toResolve.includes(addr)) {
|
|
632
|
+
if (tokenSymbolCache.size >= TOKEN_SYMBOL_CACHE_MAX) {
|
|
633
|
+
const oldest = tokenSymbolCache.keys().next();
|
|
634
|
+
if (!oldest.done) tokenSymbolCache.delete(oldest.value);
|
|
635
|
+
}
|
|
636
|
+
tokenSymbolCache.set(addr, sym);
|
|
637
|
+
result.set(addr, sym);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
} catch {}
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
async function handleSend(parts, wallet, signer, rpc, histPath) {
|
|
644
|
+
if (!signer) return { text: "Wallet not loaded yet. Please wait for the gateway to finish starting." };
|
|
645
|
+
if (parts.length !== 2) return { text: "Usage: `/x_wallet send <amount|all> <address>`\n\n `/x_wallet send 0.5 7xKXtg...`\n `/x_wallet send all 7xKXtg...`" };
|
|
646
|
+
const [amountStr, destAddr] = parts;
|
|
647
|
+
if (destAddr.length < 32 || destAddr.length > 44) return { text: `Invalid Solana address: ${destAddr}` };
|
|
648
|
+
try {
|
|
649
|
+
if (!await checkAtaExists(rpc, destAddr)) return { text: "Recipient does not have a USDC token account.\nThey need to receive USDC at least once to create one." };
|
|
650
|
+
let amountRaw;
|
|
651
|
+
let amountUi;
|
|
652
|
+
if (amountStr.toLowerCase() === "all") {
|
|
653
|
+
const balance = await getUsdcBalance(rpc, wallet);
|
|
654
|
+
if (balance.raw === 0n) return { text: "Wallet has no USDC to send." };
|
|
655
|
+
amountRaw = balance.raw;
|
|
656
|
+
amountUi = balance.ui;
|
|
657
|
+
} else {
|
|
658
|
+
const amount = Number.parseFloat(amountStr);
|
|
659
|
+
if (Number.isNaN(amount) || amount <= 0) return { text: `Invalid amount: ${amountStr}` };
|
|
660
|
+
amountRaw = BigInt(Math.round(amount * 1e6));
|
|
661
|
+
amountUi = amount.toString();
|
|
662
|
+
}
|
|
663
|
+
const sig = await transferUsdc(signer, rpc, destAddr, amountRaw);
|
|
664
|
+
appendHistory(histPath, {
|
|
665
|
+
t: Date.now(),
|
|
666
|
+
ok: true,
|
|
667
|
+
kind: "transfer",
|
|
668
|
+
net: SOL_MAINNET,
|
|
669
|
+
from: wallet,
|
|
670
|
+
to: destAddr,
|
|
671
|
+
tx: sig,
|
|
672
|
+
amount: Number.parseFloat(amountUi),
|
|
673
|
+
token: "USDC",
|
|
674
|
+
label: `${destAddr.slice(0, 4)}...${destAddr.slice(-4)}`
|
|
675
|
+
});
|
|
676
|
+
return { text: `Sent ${amountUi} USDC to \`${destAddr}\`\n[View transaction](https://solscan.io/tx/${sig})` };
|
|
677
|
+
} catch (err) {
|
|
678
|
+
appendHistory(histPath, {
|
|
679
|
+
t: Date.now(),
|
|
680
|
+
ok: false,
|
|
681
|
+
kind: "transfer",
|
|
682
|
+
net: SOL_MAINNET,
|
|
683
|
+
from: wallet,
|
|
684
|
+
to: destAddr,
|
|
685
|
+
token: "USDC",
|
|
686
|
+
error: String(err).substring(0, 200)
|
|
687
|
+
});
|
|
688
|
+
const msg = String(err);
|
|
689
|
+
const detail = err.cause?.message || msg;
|
|
690
|
+
if (detail.includes("insufficient") || detail.includes("lamports")) return { text: "Send failed - insufficient SOL for fees\nBalance too low for transaction fees. Fund wallet with SOL." };
|
|
691
|
+
return { text: `Send failed: ${detail}` };
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function handleHistory(histPath, page) {
|
|
695
|
+
const records = readHistory(histPath);
|
|
696
|
+
const totalTxs = records.length;
|
|
697
|
+
const start = (page - 1) * HISTORY_PAGE_SIZE;
|
|
698
|
+
const fromEnd = totalTxs - start;
|
|
699
|
+
const pageRecords = records.slice(Math.max(0, fromEnd - HISTORY_PAGE_SIZE), fromEnd).reverse();
|
|
700
|
+
if (pageRecords.length === 0) return { text: page === 1 ? "No transactions yet." : "No more transactions." };
|
|
701
|
+
const lines = [`**History** (${start + 1}-${start + pageRecords.length})`, ""];
|
|
702
|
+
for (const r of pageRecords) lines.push(formatTxLine(r));
|
|
703
|
+
const nav = [];
|
|
704
|
+
if (page > 1) nav.push(`Newer: \`/x_wallet history${page === 2 ? "" : ` ${page - 1}`}\``);
|
|
705
|
+
if (start + HISTORY_PAGE_SIZE < totalTxs) nav.push(`Older: \`/x_wallet history ${page + 1}\``);
|
|
706
|
+
if (nav.length > 0) lines.push("", nav.join(" · "));
|
|
707
|
+
return { text: lines.join("\n") };
|
|
708
|
+
}
|
|
709
|
+
function createWalletCommand(ctx) {
|
|
710
|
+
return {
|
|
711
|
+
name: "x_wallet",
|
|
712
|
+
description: "Wallet status, balance, send USDC, transaction history",
|
|
713
|
+
acceptsArgs: true,
|
|
714
|
+
handler: async (cmdCtx) => {
|
|
715
|
+
const walletAddress = ctx.getWalletAddress();
|
|
716
|
+
if (!walletAddress) return { text: "Wallet not loaded yet. Please wait for the gateway to finish starting." };
|
|
717
|
+
const parts = (cmdCtx.args?.trim() ?? "").split(/\s+/).filter(Boolean);
|
|
718
|
+
if (parts[0]?.toLowerCase() === "send") return handleSend(parts.slice(1), walletAddress, ctx.getSigner(), ctx.rpcUrl, ctx.historyPath);
|
|
719
|
+
if (parts[0]?.toLowerCase() === "history") {
|
|
720
|
+
const pageArg = parts[1];
|
|
721
|
+
const page = pageArg ? Math.max(1, Number.parseInt(pageArg, 10) || 1) : 1;
|
|
722
|
+
return handleHistory(ctx.historyPath, page);
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
const snap = await getWalletSnapshot(ctx.rpcUrl, walletAddress, ctx.historyPath);
|
|
726
|
+
const solscanUrl = `https://solscan.io/account/${walletAddress}`;
|
|
727
|
+
const lines = [`x402-proxy v0.7.1`];
|
|
728
|
+
const defaultModel = ctx.allModels[0];
|
|
729
|
+
if (defaultModel) lines.push("", `**Model** - ${defaultModel.name} (${defaultModel.provider})`);
|
|
730
|
+
lines.push("", `**[Wallet](${solscanUrl})**`, `\`${walletAddress}\``);
|
|
731
|
+
lines.push("", ` ${snap.sol} SOL`, ` ${snap.ui} USDC`);
|
|
732
|
+
if (snap.spend.today > 0) lines.push(` -${snap.spend.today.toFixed(2)} USDC today`);
|
|
733
|
+
if (snap.tokens.length > 0) {
|
|
734
|
+
const displayTokens = snap.tokens.slice(0, 10);
|
|
735
|
+
const symbols = await resolveTokenSymbols(displayTokens.map((t) => t.mint));
|
|
736
|
+
lines.push("", "**Tokens**");
|
|
737
|
+
for (const t of displayTokens) {
|
|
738
|
+
const label = symbols.get(t.mint) ?? `${t.mint.slice(0, 4)}...${t.mint.slice(-4)}`;
|
|
739
|
+
const amt = Number.parseFloat(t.amount).toLocaleString("en-US", { maximumFractionDigits: 0 });
|
|
740
|
+
lines.push(` ${amt} ${label}`);
|
|
741
|
+
}
|
|
742
|
+
if (snap.tokens.length > 10) lines.push(` ...and ${snap.tokens.length - 10} more`);
|
|
743
|
+
}
|
|
744
|
+
if (snap.tokens.length <= INLINE_HISTORY_TOKEN_THRESHOLD) {
|
|
745
|
+
const recentRecords = snap.records.slice(-STATUS_HISTORY_COUNT).reverse();
|
|
746
|
+
if (recentRecords.length > 0) {
|
|
747
|
+
lines.push("", "**Recent**");
|
|
748
|
+
for (const r of recentRecords) lines.push(formatTxLine(r));
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
lines.push("", "History: `/x_wallet history`");
|
|
752
|
+
if (ctx.dashboardUrl) lines.push(`[Dashboard](${ctx.dashboardUrl})`);
|
|
753
|
+
return { text: lines.join("\n") };
|
|
754
|
+
} catch (err) {
|
|
755
|
+
return { text: `Failed to check balance: ${String(err)}` };
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
//#endregion
|
|
762
|
+
//#region src/openclaw/route.ts
|
|
763
|
+
function createX402RouteHandler(opts) {
|
|
764
|
+
const { upstreamOrigin, proxy, getWalletAddress, historyPath, allModels, logger } = opts;
|
|
765
|
+
return async (req, res) => {
|
|
766
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
767
|
+
const walletAddress = getWalletAddress();
|
|
768
|
+
if (!walletAddress) {
|
|
769
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
770
|
+
res.end(JSON.stringify({ error: {
|
|
771
|
+
message: "Wallet not loaded yet",
|
|
772
|
+
code: "not_ready"
|
|
773
|
+
} }));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const pathSuffix = url.pathname.slice(5);
|
|
777
|
+
const upstreamUrl = upstreamOrigin + pathSuffix + url.search;
|
|
778
|
+
logger.info(`x402: intercepting ${upstreamUrl.substring(0, 80)}`);
|
|
779
|
+
const chunks = [];
|
|
780
|
+
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
|
781
|
+
let body = Buffer.concat(chunks).toString("utf-8");
|
|
782
|
+
const HOP_BY_HOP = new Set([
|
|
783
|
+
"authorization",
|
|
784
|
+
"host",
|
|
785
|
+
"connection",
|
|
786
|
+
"content-length",
|
|
787
|
+
"transfer-encoding",
|
|
788
|
+
"keep-alive",
|
|
789
|
+
"te",
|
|
790
|
+
"upgrade"
|
|
791
|
+
]);
|
|
792
|
+
const headers = {};
|
|
793
|
+
for (const [key, val] of Object.entries(req.headers)) {
|
|
794
|
+
if (HOP_BY_HOP.has(key)) continue;
|
|
795
|
+
if (typeof val === "string") headers[key] = val;
|
|
796
|
+
}
|
|
797
|
+
const isChatCompletion = pathSuffix.includes("/chat/completions");
|
|
798
|
+
let thinkingMode;
|
|
799
|
+
if (isChatCompletion && body) try {
|
|
800
|
+
const parsed = JSON.parse(body);
|
|
801
|
+
if (parsed.reasoning_effort) thinkingMode = String(parsed.reasoning_effort);
|
|
802
|
+
if (!parsed.stream_options) {
|
|
803
|
+
parsed.stream_options = { include_usage: true };
|
|
804
|
+
body = JSON.stringify(parsed);
|
|
805
|
+
}
|
|
806
|
+
} catch {}
|
|
807
|
+
const method = req.method ?? "GET";
|
|
808
|
+
const startMs = Date.now();
|
|
809
|
+
try {
|
|
810
|
+
const response = await proxy.x402Fetch(upstreamUrl, {
|
|
811
|
+
method,
|
|
812
|
+
headers,
|
|
813
|
+
body: ["GET", "HEAD"].includes(method) ? void 0 : body
|
|
814
|
+
});
|
|
815
|
+
if (response.status === 402) {
|
|
816
|
+
const responseBody = await response.text();
|
|
817
|
+
logger.error(`x402: payment failed, raw response: ${responseBody}`);
|
|
818
|
+
const payment = proxy.shiftPayment();
|
|
819
|
+
const amount = paymentAmount(payment);
|
|
820
|
+
appendHistory(historyPath, {
|
|
821
|
+
t: Date.now(),
|
|
822
|
+
ok: false,
|
|
823
|
+
kind: "x402_inference",
|
|
824
|
+
net: SOL_MAINNET,
|
|
825
|
+
from: walletAddress,
|
|
826
|
+
to: payment?.payTo,
|
|
827
|
+
amount,
|
|
828
|
+
token: amount != null ? "USDC" : void 0,
|
|
829
|
+
ms: Date.now() - startMs,
|
|
830
|
+
error: "payment_required"
|
|
831
|
+
});
|
|
832
|
+
let userMessage;
|
|
833
|
+
if (responseBody.includes("simulation") || responseBody.includes("Simulation")) userMessage = `Insufficient USDC or SOL in wallet ${walletAddress}. Fund it with USDC (SPL token) to pay for inference.`;
|
|
834
|
+
else if (responseBody.includes("insufficient") || responseBody.includes("balance")) userMessage = `Insufficient funds in wallet ${walletAddress}. Top up with USDC on Solana mainnet.`;
|
|
835
|
+
else userMessage = `x402 payment failed: ${responseBody.substring(0, 200) || "unknown error"}. Wallet: ${walletAddress}`;
|
|
836
|
+
res.writeHead(402, { "Content-Type": "application/json" });
|
|
837
|
+
res.end(JSON.stringify({ error: {
|
|
838
|
+
message: userMessage,
|
|
839
|
+
type: "x402_payment_error",
|
|
840
|
+
code: "payment_failed"
|
|
841
|
+
} }));
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (!response.ok && isChatCompletion) {
|
|
845
|
+
const responseBody = await response.text();
|
|
846
|
+
logger.error(`x402: upstream error ${response.status}: ${responseBody.substring(0, 300)}`);
|
|
847
|
+
const payment = proxy.shiftPayment();
|
|
848
|
+
const amount = paymentAmount(payment);
|
|
849
|
+
appendHistory(historyPath, {
|
|
850
|
+
t: Date.now(),
|
|
851
|
+
ok: false,
|
|
852
|
+
kind: "x402_inference",
|
|
853
|
+
net: SOL_MAINNET,
|
|
854
|
+
from: walletAddress,
|
|
855
|
+
to: payment?.payTo,
|
|
856
|
+
amount,
|
|
857
|
+
token: amount != null ? "USDC" : void 0,
|
|
858
|
+
ms: Date.now() - startMs,
|
|
859
|
+
error: `upstream_${response.status}`
|
|
860
|
+
});
|
|
861
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
862
|
+
res.end(JSON.stringify({ error: {
|
|
863
|
+
message: `LLM provider temporarily unavailable (HTTP ${response.status}). Try again shortly.`,
|
|
864
|
+
type: "x402_upstream_error",
|
|
865
|
+
code: "upstream_failed"
|
|
866
|
+
} }));
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
logger.info(`x402: response ${response.status}`);
|
|
870
|
+
const txSig = extractTxSignature(response);
|
|
871
|
+
const payment = proxy.shiftPayment();
|
|
872
|
+
const amount = paymentAmount(payment);
|
|
873
|
+
const resHeaders = {};
|
|
874
|
+
for (const [key, val] of response.headers.entries()) resHeaders[key] = val;
|
|
875
|
+
res.writeHead(response.status, resHeaders);
|
|
876
|
+
if (!response.body) {
|
|
877
|
+
res.end();
|
|
878
|
+
appendHistory(historyPath, {
|
|
879
|
+
t: Date.now(),
|
|
880
|
+
ok: true,
|
|
881
|
+
kind: "x402_inference",
|
|
882
|
+
net: SOL_MAINNET,
|
|
883
|
+
from: walletAddress,
|
|
884
|
+
to: payment?.payTo,
|
|
885
|
+
tx: txSig,
|
|
886
|
+
amount,
|
|
887
|
+
token: "USDC",
|
|
888
|
+
ms: Date.now() - startMs
|
|
889
|
+
});
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const ct = response.headers.get("content-type") || "";
|
|
893
|
+
const isSSE = isChatCompletion && ct.includes("text/event-stream");
|
|
894
|
+
let lastDataLine = "";
|
|
895
|
+
let residual = "";
|
|
896
|
+
const reader = response.body.getReader();
|
|
897
|
+
const decoder = new TextDecoder();
|
|
898
|
+
try {
|
|
899
|
+
while (true) {
|
|
900
|
+
const { done, value } = await reader.read();
|
|
901
|
+
if (done) break;
|
|
902
|
+
res.write(value);
|
|
903
|
+
if (isSSE) {
|
|
904
|
+
const lines = (residual + decoder.decode(value, { stream: true })).split("\n");
|
|
905
|
+
residual = lines.pop() ?? "";
|
|
906
|
+
for (const line of lines) if (line.startsWith("data: ") && line !== "data: [DONE]") lastDataLine = line.slice(6);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
} finally {
|
|
910
|
+
reader.releaseLock();
|
|
911
|
+
}
|
|
912
|
+
res.end();
|
|
913
|
+
const durationMs = Date.now() - startMs;
|
|
914
|
+
if (isSSE && lastDataLine) try {
|
|
915
|
+
const parsed = JSON.parse(lastDataLine);
|
|
916
|
+
const usage = parsed.usage;
|
|
917
|
+
const model = parsed.model ?? "";
|
|
918
|
+
appendHistory(historyPath, {
|
|
919
|
+
t: Date.now(),
|
|
920
|
+
ok: true,
|
|
921
|
+
kind: "x402_inference",
|
|
922
|
+
net: SOL_MAINNET,
|
|
923
|
+
from: walletAddress,
|
|
924
|
+
to: payment?.payTo,
|
|
925
|
+
tx: txSig,
|
|
926
|
+
amount,
|
|
927
|
+
token: "USDC",
|
|
928
|
+
provider: allModels.find((m) => m.id === model || `${m.provider}/${m.id}` === model)?.provider,
|
|
929
|
+
model,
|
|
930
|
+
inputTokens: usage?.prompt_tokens ?? 0,
|
|
931
|
+
outputTokens: usage?.completion_tokens ?? 0,
|
|
932
|
+
reasoningTokens: usage?.completion_tokens_details?.reasoning_tokens,
|
|
933
|
+
cacheRead: usage?.prompt_tokens_details?.cached_tokens,
|
|
934
|
+
cacheWrite: usage?.prompt_tokens_details?.cache_creation_input_tokens,
|
|
935
|
+
thinking: thinkingMode,
|
|
936
|
+
ms: durationMs
|
|
937
|
+
});
|
|
938
|
+
} catch {
|
|
939
|
+
appendHistory(historyPath, {
|
|
940
|
+
t: Date.now(),
|
|
941
|
+
ok: true,
|
|
942
|
+
kind: "x402_inference",
|
|
943
|
+
net: SOL_MAINNET,
|
|
944
|
+
from: walletAddress,
|
|
945
|
+
to: payment?.payTo,
|
|
946
|
+
tx: txSig,
|
|
947
|
+
amount,
|
|
948
|
+
token: "USDC",
|
|
949
|
+
ms: durationMs
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
else appendHistory(historyPath, {
|
|
953
|
+
t: Date.now(),
|
|
954
|
+
ok: true,
|
|
955
|
+
kind: "x402_inference",
|
|
956
|
+
net: SOL_MAINNET,
|
|
957
|
+
from: walletAddress,
|
|
958
|
+
to: payment?.payTo,
|
|
959
|
+
tx: txSig,
|
|
960
|
+
amount,
|
|
961
|
+
token: "USDC",
|
|
962
|
+
ms: durationMs
|
|
963
|
+
});
|
|
964
|
+
return;
|
|
965
|
+
} catch (err) {
|
|
966
|
+
const msg = String(err);
|
|
967
|
+
logger.error(`x402: fetch threw: ${msg}`);
|
|
968
|
+
proxy.shiftPayment();
|
|
969
|
+
appendHistory(historyPath, {
|
|
970
|
+
t: Date.now(),
|
|
971
|
+
ok: false,
|
|
972
|
+
kind: "x402_inference",
|
|
973
|
+
net: SOL_MAINNET,
|
|
974
|
+
from: walletAddress,
|
|
975
|
+
ms: Date.now() - startMs,
|
|
976
|
+
error: msg.substring(0, 200)
|
|
977
|
+
});
|
|
978
|
+
let userMessage;
|
|
979
|
+
if (msg.includes("Simulation failed") || msg.includes("simulation")) userMessage = `Insufficient USDC or SOL in wallet ${walletAddress}. Fund it with USDC and SOL to pay for inference.`;
|
|
980
|
+
else if (msg.includes("Failed to create payment")) userMessage = `x402 payment creation failed: ${msg}. Wallet: ${walletAddress}`;
|
|
981
|
+
else userMessage = `x402 request failed: ${msg}`;
|
|
982
|
+
if (!res.headersSent) {
|
|
983
|
+
res.writeHead(402, { "Content-Type": "application/json" });
|
|
984
|
+
res.end(JSON.stringify({ error: {
|
|
985
|
+
message: userMessage,
|
|
986
|
+
type: "x402_payment_error",
|
|
987
|
+
code: "payment_failed"
|
|
988
|
+
} }));
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
//#endregion
|
|
995
|
+
//#region src/openclaw/plugin.ts
|
|
996
|
+
function parseProviders(config) {
|
|
997
|
+
const raw = config.providers ?? {};
|
|
998
|
+
const models = [];
|
|
999
|
+
const upstreamOrigins = [];
|
|
1000
|
+
for (const [name, prov] of Object.entries(raw)) {
|
|
1001
|
+
if (prov.upstreamUrl) upstreamOrigins.push(prov.upstreamUrl);
|
|
1002
|
+
for (const m of prov.models) models.push({
|
|
1003
|
+
...m,
|
|
1004
|
+
provider: name
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
return {
|
|
1008
|
+
models,
|
|
1009
|
+
upstreamOrigins
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
function register(api) {
|
|
1013
|
+
const config = api.pluginConfig ?? {};
|
|
1014
|
+
const explicitKeypairPath = config.keypairPath;
|
|
1015
|
+
const rpcUrl = config.rpcUrl || "https://api.mainnet-beta.solana.com";
|
|
1016
|
+
const dashboardUrl = config.dashboardUrl || "";
|
|
1017
|
+
const { models: allModels, upstreamOrigins } = parseProviders(config);
|
|
1018
|
+
if (allModels.length === 0) {
|
|
1019
|
+
api.logger.error("x402-proxy: no providers configured");
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const raw = config.providers ?? {};
|
|
1023
|
+
for (const [name, prov] of Object.entries(raw)) api.registerProvider({
|
|
1024
|
+
id: name,
|
|
1025
|
+
label: `${name} (x402)`,
|
|
1026
|
+
auth: [],
|
|
1027
|
+
models: {
|
|
1028
|
+
baseUrl: prov.baseUrl,
|
|
1029
|
+
api: "openai-completions",
|
|
1030
|
+
authHeader: false,
|
|
1031
|
+
models: prov.models
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
api.logger.info(`x402-proxy: ${Object.keys(raw).join(", ")} - ${allModels.length} models, ${upstreamOrigins.length} x402 endpoints`);
|
|
1035
|
+
let walletAddress = null;
|
|
1036
|
+
let signerRef = null;
|
|
1037
|
+
let proxyRef = null;
|
|
1038
|
+
let walletLoading = false;
|
|
1039
|
+
const historyPath = getHistoryPath();
|
|
1040
|
+
async function ensureWalletLoaded() {
|
|
1041
|
+
if (walletLoading || walletAddress) return;
|
|
1042
|
+
walletLoading = true;
|
|
1043
|
+
let signer;
|
|
1044
|
+
try {
|
|
1045
|
+
if (explicitKeypairPath) {
|
|
1046
|
+
signer = await loadSvmWallet(explicitKeypairPath.startsWith("~/") ? join(homedir(), explicitKeypairPath.slice(2)) : explicitKeypairPath);
|
|
1047
|
+
walletAddress = signer.address;
|
|
1048
|
+
} else {
|
|
1049
|
+
const resolution = resolveWallet();
|
|
1050
|
+
if (resolution.source === "none" || !resolution.solanaKey || !resolution.solanaAddress) {
|
|
1051
|
+
walletLoading = false;
|
|
1052
|
+
api.logger.error("x402-proxy: no wallet found. Run `x402-proxy setup` to create one, or set X402_PROXY_WALLET_MNEMONIC env var.");
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
signer = await createKeyPairSignerFromBytes(resolution.solanaKey);
|
|
1056
|
+
walletAddress = resolution.solanaAddress;
|
|
1057
|
+
}
|
|
1058
|
+
signerRef = signer;
|
|
1059
|
+
api.logger.info(`x402: wallet ${walletAddress}`);
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
walletLoading = false;
|
|
1062
|
+
api.logger.error(`x402: failed to load wallet: ${err}`);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const client = new x402Client();
|
|
1066
|
+
client.register(SOL_MAINNET, new ExactSvmScheme(signer, { rpcUrl }));
|
|
1067
|
+
proxyRef = createX402ProxyHandler({ client });
|
|
1068
|
+
const upstreamOrigin = upstreamOrigins[0];
|
|
1069
|
+
if (upstreamOrigin) {
|
|
1070
|
+
const handler = createX402RouteHandler({
|
|
1071
|
+
upstreamOrigin,
|
|
1072
|
+
proxy: proxyRef,
|
|
1073
|
+
getWalletAddress: () => walletAddress,
|
|
1074
|
+
historyPath,
|
|
1075
|
+
allModels,
|
|
1076
|
+
logger: api.logger
|
|
1077
|
+
});
|
|
1078
|
+
api.registerHttpRoute({
|
|
1079
|
+
path: "/x402",
|
|
1080
|
+
match: "prefix",
|
|
1081
|
+
auth: "plugin",
|
|
1082
|
+
handler
|
|
1083
|
+
});
|
|
1084
|
+
api.logger.info(`x402: HTTP route registered for ${upstreamOrigin}`);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
ensureWalletLoaded();
|
|
1088
|
+
api.registerService({
|
|
1089
|
+
id: "x402-wallet",
|
|
1090
|
+
async start() {
|
|
1091
|
+
await ensureWalletLoaded();
|
|
1092
|
+
},
|
|
1093
|
+
async stop() {}
|
|
1094
|
+
});
|
|
1095
|
+
const toolCtx = {
|
|
1096
|
+
getWalletAddress: () => walletAddress,
|
|
1097
|
+
getSigner: () => signerRef,
|
|
1098
|
+
rpcUrl,
|
|
1099
|
+
historyPath,
|
|
1100
|
+
get proxy() {
|
|
1101
|
+
if (!proxyRef) throw new Error("x402 proxy not initialized yet");
|
|
1102
|
+
return proxyRef;
|
|
1103
|
+
},
|
|
1104
|
+
allModels
|
|
1105
|
+
};
|
|
1106
|
+
api.registerTool(createBalanceTool(toolCtx));
|
|
1107
|
+
api.registerTool(createPaymentTool(toolCtx));
|
|
1108
|
+
const cmdCtx = {
|
|
1109
|
+
getWalletAddress: () => walletAddress,
|
|
1110
|
+
getSigner: () => signerRef,
|
|
1111
|
+
rpcUrl,
|
|
1112
|
+
dashboardUrl,
|
|
1113
|
+
historyPath,
|
|
1114
|
+
allModels
|
|
1115
|
+
};
|
|
1116
|
+
api.registerCommand(createWalletCommand(cmdCtx));
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
//#endregion
|
|
1120
|
+
export { register };
|