@wafermapxyz/cli 0.0.0-stage → 0.4.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 +42 -0
- package/LICENSE +21 -0
- package/README.md +114 -3
- package/dist/cli.js +1771 -0
- package/dist/index.js +719 -0
- package/dist/types/shared/solana.d.ts +36 -0
- package/dist/types/shared/swap.d.ts +78 -0
- package/dist/types/shared/x402client.d.ts +116 -0
- package/dist/types/src/core.d.ts +184 -0
- package/dist/types/src/index.d.ts +5 -0
- package/package.json +68 -6
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1771 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res, err) => function __init() {
|
|
5
|
+
if (err) throw err[0];
|
|
6
|
+
try {
|
|
7
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
8
|
+
} catch (e) {
|
|
9
|
+
throw err = [e], e;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// shared/swap.ts
|
|
18
|
+
import { decodeFunctionData, erc20Abi, formatUnits, getAddress, isAddress, parseAbi, parseUnits } from "viem";
|
|
19
|
+
function checkRouter(net2, router) {
|
|
20
|
+
const r = typeof router === "string" && isAddress(router) ? getAddress(router) : null;
|
|
21
|
+
if (r !== KYBER_ROUTER[net2]) throw new SwapError("unexpected_router", `The swap API named router ${String(router)}, not KyberSwap's router ${KYBER_ROUTER[net2]} on ${net2}. Nothing was approved or sent.`);
|
|
22
|
+
return r;
|
|
23
|
+
}
|
|
24
|
+
async function resolveToken(net2, ref, client, lookup) {
|
|
25
|
+
const r = ref.trim();
|
|
26
|
+
let t = null;
|
|
27
|
+
if (isAddress(r)) t = TOKENS[net2].find((x) => x.address.toLowerCase() === r.toLowerCase()) ?? { symbol: "?", name: "", address: getAddress(r), decimals: null };
|
|
28
|
+
else t = TOKENS[net2].find((x) => x.symbol.toLowerCase() === r.toLowerCase()) ?? (lookup ? await lookup(net2, r) : null);
|
|
29
|
+
if (!t) throw new SwapError("unknown_token", `Unknown token "${r}" on ${net2}. Use a symbol (ETH, ${net2 === "base" ? "USDC" : "USDG, TSLA"}) or a 0x address.`);
|
|
30
|
+
if (t.decimals === null || t.symbol === "?") {
|
|
31
|
+
const [dec, sym] = await client.multicall({ contracts: [{ address: t.address, abi: erc20Abi, functionName: "decimals" }, { address: t.address, abi: erc20Abi, functionName: "symbol" }], allowFailure: true });
|
|
32
|
+
if (dec.status !== "success") throw new SwapError("not_a_token", `${t.address} is not an ERC-20 token on ${net2}.`);
|
|
33
|
+
t = { ...t, decimals: Number(dec.result), symbol: t.symbol === "?" && sym.status === "success" ? String(sym.result) : t.symbol };
|
|
34
|
+
}
|
|
35
|
+
return t;
|
|
36
|
+
}
|
|
37
|
+
async function quoteSwap(o) {
|
|
38
|
+
const f = o.fetch ?? fetch;
|
|
39
|
+
if (o.tokenIn.address.toLowerCase() === o.tokenOut.address.toLowerCase()) throw new SwapError("same_token", "Pick two different tokens.");
|
|
40
|
+
let units;
|
|
41
|
+
try {
|
|
42
|
+
units = parseUnits(o.amount, o.tokenIn.decimals ?? 18);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new SwapError("invalid_amount", `"${o.amount}" is not an amount.`);
|
|
45
|
+
}
|
|
46
|
+
if (units <= 0n) throw new SwapError("invalid_amount", "Amount must be above zero.");
|
|
47
|
+
const q = new URLSearchParams({ tokenIn: o.tokenIn.address, tokenOut: o.tokenOut.address, amountIn: units.toString(), gasInclude: "true" });
|
|
48
|
+
const res = await f(`${KYBER(o.network)}/routes?${q}`, { headers: HEADERS }).catch(() => null);
|
|
49
|
+
const j = await res?.json().catch(() => null);
|
|
50
|
+
const s = j?.data?.routeSummary;
|
|
51
|
+
if (!res?.ok || !s) throw new SwapError("no_route", j?.message && j.message !== "successfully" ? `No route: ${j.message}` : "No swap route found for that pair and size.");
|
|
52
|
+
const inUsd = Number(s.amountInUsd);
|
|
53
|
+
const outUsd = Number(s.amountOutUsd);
|
|
54
|
+
return {
|
|
55
|
+
network: o.network,
|
|
56
|
+
tokenIn: o.tokenIn,
|
|
57
|
+
tokenOut: o.tokenOut,
|
|
58
|
+
amountIn: o.amount,
|
|
59
|
+
amountOut: formatUnits(BigInt(s.amountOut), o.tokenOut.decimals ?? 18),
|
|
60
|
+
amountInUsd: Number.isFinite(inUsd) && inUsd > 0 ? inUsd : null,
|
|
61
|
+
amountOutUsd: Number.isFinite(outUsd) && outUsd > 0 ? outUsd : null,
|
|
62
|
+
gasUsd: Number(s.gasUsd) || null,
|
|
63
|
+
impactPct: inUsd > 0 && outUsd > 0 ? Math.round((1 - outUsd / inUsd) * 1e4) / 100 : null,
|
|
64
|
+
routerAddress: checkRouter(o.network, j.data.routerAddress),
|
|
65
|
+
route: s,
|
|
66
|
+
at: Date.now()
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function decodeSwap(data) {
|
|
70
|
+
let d;
|
|
71
|
+
try {
|
|
72
|
+
d = decodeFunctionData({ abi: ROUTER_ABI, data });
|
|
73
|
+
} catch {
|
|
74
|
+
throw new SwapError("unexpected_calldata", "The swap API returned a call this wallet does not recognise. Nothing was sent.");
|
|
75
|
+
}
|
|
76
|
+
const desc = d.functionName === "swap" ? d.args[0].desc : d.args[1];
|
|
77
|
+
return { fn: d.functionName, srcToken: getAddress(desc.srcToken), dstToken: getAddress(desc.dstToken), receiver: getAddress(desc.dstReceiver), amount: desc.amount, minReturn: desc.minReturnAmount, feeReceivers: desc.feeReceivers, feeAmounts: desc.feeAmounts };
|
|
78
|
+
}
|
|
79
|
+
async function executeSwap(o) {
|
|
80
|
+
const f = o.fetch ?? fetch;
|
|
81
|
+
const { quote } = o;
|
|
82
|
+
const account = o.wallet.account;
|
|
83
|
+
if (!account) throw new SwapError("no_account", "No wallet account to swap from.");
|
|
84
|
+
if (Date.now() - quote.at > 9e4) throw new SwapError("stale_quote", "The quote is older than 90 seconds. Get a fresh one.");
|
|
85
|
+
checkRouter(quote.network, quote.routerAddress);
|
|
86
|
+
const from = account.address;
|
|
87
|
+
const amountIn = parseUnits(quote.amountIn, quote.tokenIn.decimals ?? 18);
|
|
88
|
+
const slippageBps = o.slippageBps ?? 100;
|
|
89
|
+
if (!(slippageBps > 0 && slippageBps <= 500)) throw new SwapError("invalid_slippage", "Slippage must be above 0 and at most 5%.");
|
|
90
|
+
const native = quote.tokenIn.address.toLowerCase() === NATIVE.toLowerCase();
|
|
91
|
+
const eth = await o.client.getBalance({ address: from });
|
|
92
|
+
if (eth === 0n) throw new SwapError("no_gas", `The wallet has no ETH on ${quote.network} to pay gas. Send a little ETH first.`);
|
|
93
|
+
if (native ? eth < amountIn : await o.client.readContract({ address: quote.tokenIn.address, abi: erc20Abi, functionName: "balanceOf", args: [from] }) < amountIn)
|
|
94
|
+
throw new SwapError("insufficient_balance", `Not enough ${quote.tokenIn.symbol} in ${from.slice(0, 6)}\u2026${from.slice(-4)}.`);
|
|
95
|
+
o.step?.("building");
|
|
96
|
+
const res = await f(`${KYBER(quote.network)}/route/build`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { ...HEADERS, "content-type": "application/json" },
|
|
99
|
+
body: JSON.stringify({ routeSummary: quote.route, sender: from, recipient: from, slippageTolerance: slippageBps, deadline: Math.floor(Date.now() / 1e3) + 1200, source: "wafermap" })
|
|
100
|
+
}).catch(() => null);
|
|
101
|
+
const j = await res?.json().catch(() => null);
|
|
102
|
+
if (!res?.ok || !j?.data?.data) throw new SwapError("build_failed", j?.message ? `Route build failed: ${j.message}` : "Could not build the swap. Get a fresh quote.");
|
|
103
|
+
const b = j.data;
|
|
104
|
+
const to = checkRouter(quote.network, b.routerAddress);
|
|
105
|
+
const big = (v) => (typeof v === "string" || typeof v === "number") && /^\d+$/.test(String(v)) ? BigInt(v) : null;
|
|
106
|
+
const value = big(b.transactionValue ?? "0");
|
|
107
|
+
const builtOut = big(b.amountOut);
|
|
108
|
+
const quotedOut = parseUnits(quote.amountOut, quote.tokenOut.decimals ?? 18);
|
|
109
|
+
const call = decodeSwap(b.data);
|
|
110
|
+
const mismatch = (what) => {
|
|
111
|
+
throw new SwapError("build_mismatch", `The built swap ${what}. Nothing was approved or sent.`);
|
|
112
|
+
};
|
|
113
|
+
if (call.srcToken !== getAddress(quote.tokenIn.address) || call.dstToken !== getAddress(quote.tokenOut.address)) mismatch("trades other tokens than the quote");
|
|
114
|
+
if (call.amount !== amountIn) mismatch("spends a different amount than the quote");
|
|
115
|
+
if (call.receiver !== getAddress(from)) mismatch(`sends the output to ${call.receiver}, not to this wallet`);
|
|
116
|
+
if (call.feeAmounts.some((x) => x > 0n)) mismatch("takes a fee");
|
|
117
|
+
if (value === null || value !== (native ? amountIn : 0n)) mismatch("sends a different amount of ETH than the quote");
|
|
118
|
+
if (builtOut === null || builtOut * 10000n < quotedOut * BigInt(1e4 - slippageBps)) throw new SwapError("price_moved", "The price moved past your slippage since the quote. Nothing was sent; get a fresh quote.");
|
|
119
|
+
if (call.minReturn < builtOut * BigInt(1e4 - slippageBps) / 10000n) mismatch("accepts less output than your slippage allows");
|
|
120
|
+
let approveTx;
|
|
121
|
+
if (!native) {
|
|
122
|
+
const allowance = await o.client.readContract({ address: quote.tokenIn.address, abi: erc20Abi, functionName: "allowance", args: [from, to] });
|
|
123
|
+
if (allowance < amountIn) {
|
|
124
|
+
o.step?.("approving");
|
|
125
|
+
approveTx = await o.wallet.writeContract({ address: quote.tokenIn.address, abi: erc20Abi, functionName: "approve", args: [to, amountIn], account, chain: o.wallet.chain });
|
|
126
|
+
const r = await o.client.waitForTransactionReceipt({ hash: approveTx });
|
|
127
|
+
if (r.status !== "success") throw new SwapError("approve_failed", "The approval transaction failed.");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
o.step?.("swapping");
|
|
131
|
+
const tx = await o.wallet.sendTransaction({ to, data: b.data, value, account, chain: o.wallet.chain });
|
|
132
|
+
const receipt = await o.client.waitForTransactionReceipt({ hash: tx });
|
|
133
|
+
if (receipt.status !== "success") throw new SwapError("swap_reverted", "The swap reverted on-chain (price moved past your slippage). Nothing was swapped; gas was spent.");
|
|
134
|
+
return { approveTx, tx, amountOutMin: formatUnits(call.minReturn, quote.tokenOut.decimals ?? 18) };
|
|
135
|
+
}
|
|
136
|
+
var NATIVE, TOKENS, KYBER_ROUTER, KYBER, HEADERS, SwapError, SWAP_DESC, ROUTER_ABI;
|
|
137
|
+
var init_swap = __esm({
|
|
138
|
+
"shared/swap.ts"() {
|
|
139
|
+
"use strict";
|
|
140
|
+
NATIVE = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
|
|
141
|
+
TOKENS = {
|
|
142
|
+
robinhood: [
|
|
143
|
+
{ symbol: "ETH", name: "Ether", address: NATIVE, decimals: 18, kind: "native" },
|
|
144
|
+
{ symbol: "WETH", name: "Wrapped Ether", address: "0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73", decimals: 18, kind: "wrapped" },
|
|
145
|
+
{ symbol: "USDG", name: "Global Dollar", address: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", decimals: 6, kind: "stable" }
|
|
146
|
+
],
|
|
147
|
+
base: [
|
|
148
|
+
{ symbol: "ETH", name: "Ether", address: NATIVE, decimals: 18, kind: "native" },
|
|
149
|
+
{ symbol: "WETH", name: "Wrapped Ether", address: "0x4200000000000000000000000000000000000006", decimals: 18, kind: "wrapped" },
|
|
150
|
+
{ symbol: "USDC", name: "USD Coin", address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6, kind: "stable" }
|
|
151
|
+
]
|
|
152
|
+
};
|
|
153
|
+
KYBER_ROUTER = {
|
|
154
|
+
robinhood: "0x6131B5fae19EA4f9D964eAc0408E4408b66337b5",
|
|
155
|
+
base: "0x6131B5fae19EA4f9D964eAc0408E4408b66337b5"
|
|
156
|
+
};
|
|
157
|
+
KYBER = (net2) => `https://aggregator-api.kyberswap.com/${net2}/api/v1`;
|
|
158
|
+
HEADERS = { "x-client-id": "wafermap", accept: "application/json" };
|
|
159
|
+
SwapError = class extends Error {
|
|
160
|
+
constructor(code, message) {
|
|
161
|
+
super(message);
|
|
162
|
+
this.code = code;
|
|
163
|
+
}
|
|
164
|
+
code;
|
|
165
|
+
};
|
|
166
|
+
SWAP_DESC = "(address srcToken, address dstToken, address[] srcReceivers, uint256[] srcAmounts, address[] feeReceivers, uint256[] feeAmounts, address dstReceiver, uint256 amount, uint256 minReturnAmount, uint256 flags, bytes permit)";
|
|
167
|
+
ROUTER_ABI = parseAbi([
|
|
168
|
+
`function swap((address callTarget, address approveTarget, bytes targetData, ${SWAP_DESC} desc, bytes clientData) execution) payable returns (uint256, uint256)`,
|
|
169
|
+
`function swapSimpleMode(address caller, ${SWAP_DESC} desc, bytes executorData, bytes clientData) returns (uint256, uint256)`
|
|
170
|
+
]);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// shared/solana.ts
|
|
175
|
+
import { ed25519 } from "@noble/curves/ed25519";
|
|
176
|
+
import { hmac } from "@noble/hashes/hmac";
|
|
177
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
178
|
+
import { base58, base64 } from "@scure/base";
|
|
179
|
+
function isSolanaAddress(v) {
|
|
180
|
+
try {
|
|
181
|
+
return base58.decode(v).length === 32;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function solanaKeyFromEvm(privateKey) {
|
|
187
|
+
const seed = hmac(sha256, hexBytes(privateKey), utf8("wafermap/solana/v1"));
|
|
188
|
+
const pub = ed25519.getPublicKey(seed);
|
|
189
|
+
return {
|
|
190
|
+
address: base58.encode(pub),
|
|
191
|
+
/** seed ‖ public key, base58: the format Phantom and Solflare import. */
|
|
192
|
+
secretKey: base58.encode(concat(seed, pub)),
|
|
193
|
+
sign: (message) => ed25519.sign(message, seed)
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function ataOf(owner, mint = SOLANA_USDC) {
|
|
197
|
+
const seeds = concat(base58.decode(owner), base58.decode(TOKEN_PROGRAM), base58.decode(mint));
|
|
198
|
+
const tail = concat(base58.decode(ATA_PROGRAM), utf8("ProgramDerivedAddress"));
|
|
199
|
+
for (let bump = 255; bump >= 0; bump--) {
|
|
200
|
+
const h = sha256(concat(seeds, Uint8Array.of(bump), tail));
|
|
201
|
+
if (!onCurve(h)) return base58.encode(h);
|
|
202
|
+
}
|
|
203
|
+
throw new Error("No associated token account address.");
|
|
204
|
+
}
|
|
205
|
+
async function solanaPayment(payer, q) {
|
|
206
|
+
if (q.asset !== SOLANA_USDC) throw new Error("Only USDC is paid on Solana.");
|
|
207
|
+
if (!isSolanaAddress(q.feePayer) || !isSolanaAddress(q.payTo)) throw new Error("The quote has no valid Solana fee payer or recipient.");
|
|
208
|
+
if (q.feePayer === payer.address) throw new Error("The fee payer cannot be the paying wallet.");
|
|
209
|
+
const memo = utf8(q.memo ?? [...crypto.getRandomValues(new Uint8Array(16))].map((b) => b.toString(16).padStart(2, "0")).join(""));
|
|
210
|
+
if (memo.length > MAX_MEMO_BYTES) throw new Error("The seller's memo is too long.");
|
|
211
|
+
const source = ataOf(payer.address, q.asset);
|
|
212
|
+
const dest = ataOf(q.payTo, q.asset);
|
|
213
|
+
const keys = [q.feePayer, payer.address, source, dest, q.asset, TOKEN_PROGRAM, COMPUTE_BUDGET, MEMO_PROGRAM];
|
|
214
|
+
if (new Set(keys).size !== keys.length) throw new Error("The quote reuses an account it should not.");
|
|
215
|
+
const at = (k) => keys.indexOf(k);
|
|
216
|
+
const ixs = [
|
|
217
|
+
{ program: COMPUTE_BUDGET, accounts: [], data: concat(Uint8Array.of(2), le(BigInt(CU_LIMIT), 4)) },
|
|
218
|
+
{ program: COMPUTE_BUDGET, accounts: [], data: concat(Uint8Array.of(3), le(CU_PRICE_MICROLAMPORTS, 8)) },
|
|
219
|
+
{ program: TOKEN_PROGRAM, accounts: [source, q.asset, dest, payer.address], data: concat(Uint8Array.of(12), le(BigInt(q.amount), 8), Uint8Array.of(USDC_DECIMALS)) },
|
|
220
|
+
{ program: MEMO_PROGRAM, accounts: [], data: memo }
|
|
221
|
+
];
|
|
222
|
+
const message = concat(
|
|
223
|
+
Uint8Array.of(128, 2, 1, 4),
|
|
224
|
+
// v0; 2 signatures, 1 of them readonly; 4 readonly non-signers
|
|
225
|
+
shortvec(keys.length),
|
|
226
|
+
...keys.map((k) => base58.decode(k)),
|
|
227
|
+
base58.decode(await payer.blockhash()),
|
|
228
|
+
shortvec(ixs.length),
|
|
229
|
+
...ixs.map((ix) => concat(Uint8Array.of(at(ix.program)), shortvec(ix.accounts.length), Uint8Array.from(ix.accounts.map(at)), shortvec(ix.data.length), ix.data)),
|
|
230
|
+
shortvec(0)
|
|
231
|
+
// no address lookup tables
|
|
232
|
+
);
|
|
233
|
+
const signature = await payer.sign(message);
|
|
234
|
+
return base64.encode(concat(shortvec(2), new Uint8Array(64), signature, message));
|
|
235
|
+
}
|
|
236
|
+
async function solanaRpc(url, method, params, f = fetch) {
|
|
237
|
+
const r = await f(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) });
|
|
238
|
+
const j = await r.json().catch(() => null);
|
|
239
|
+
if (!j) throw new Error(`Solana RPC answered ${r.status}.`);
|
|
240
|
+
if (j.error) throw new Error(j.error.message ?? "Solana RPC error.");
|
|
241
|
+
return j.result;
|
|
242
|
+
}
|
|
243
|
+
async function solanaBalances(url, owner, f) {
|
|
244
|
+
const [usdc, lamports] = await Promise.all([
|
|
245
|
+
solanaRpc(url, "getAccountInfo", [ataOf(owner), { commitment: "confirmed", encoding: "jsonParsed" }], f).then((r) => String(r.value?.data?.parsed?.info?.tokenAmount?.uiAmountString ?? "0")),
|
|
246
|
+
solanaRpc(url, "getBalance", [owner, { commitment: "confirmed" }], f).then((r) => Number(r.value ?? 0))
|
|
247
|
+
]);
|
|
248
|
+
return { usdc, sol: String(lamports / 1e9) };
|
|
249
|
+
}
|
|
250
|
+
var SOLANA_CAIP, SOLANA_USDC, USDC_DECIMALS, TOKEN_PROGRAM, ATA_PROGRAM, COMPUTE_BUDGET, MEMO_PROGRAM, CU_LIMIT, CU_PRICE_MICROLAMPORTS, MAX_MEMO_BYTES, utf8, concat, hexBytes, le, shortvec, onCurve, latestBlockhash;
|
|
251
|
+
var init_solana = __esm({
|
|
252
|
+
"shared/solana.ts"() {
|
|
253
|
+
"use strict";
|
|
254
|
+
SOLANA_CAIP = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
|
|
255
|
+
SOLANA_USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
256
|
+
USDC_DECIMALS = 6;
|
|
257
|
+
TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
|
258
|
+
ATA_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
|
|
259
|
+
COMPUTE_BUDGET = "ComputeBudget111111111111111111111111111111";
|
|
260
|
+
MEMO_PROGRAM = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
|
|
261
|
+
CU_LIMIT = 2e4;
|
|
262
|
+
CU_PRICE_MICROLAMPORTS = 1n;
|
|
263
|
+
MAX_MEMO_BYTES = 256;
|
|
264
|
+
utf8 = (s) => new TextEncoder().encode(s);
|
|
265
|
+
concat = (...parts) => {
|
|
266
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
267
|
+
let o = 0;
|
|
268
|
+
for (const p of parts) out.set(p, o), o += p.length;
|
|
269
|
+
return out;
|
|
270
|
+
};
|
|
271
|
+
hexBytes = (h) => {
|
|
272
|
+
const s = h.replace(/^0x/, "");
|
|
273
|
+
if (!/^[0-9a-fA-F]{64}$/.test(s)) throw new Error("Expected a 32-byte hex private key.");
|
|
274
|
+
return Uint8Array.from(s.match(/../g), (b) => parseInt(b, 16));
|
|
275
|
+
};
|
|
276
|
+
le = (v, bytes) => Uint8Array.from({ length: bytes }, (_, i) => Number(v >> BigInt(8 * i) & 0xffn));
|
|
277
|
+
shortvec = (n) => {
|
|
278
|
+
const out = [];
|
|
279
|
+
for (let v = n; ; ) {
|
|
280
|
+
const b = v & 127;
|
|
281
|
+
v >>= 7;
|
|
282
|
+
if (!v) return Uint8Array.from([...out, b]);
|
|
283
|
+
out.push(b | 128);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
onCurve = (b) => {
|
|
287
|
+
try {
|
|
288
|
+
ed25519.ExtendedPoint.fromHex(b);
|
|
289
|
+
return true;
|
|
290
|
+
} catch {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
latestBlockhash = async (url, f) => (await solanaRpc(url, "getLatestBlockhash", [{ commitment: "confirmed" }], f)).value.blockhash;
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// shared/x402client.ts
|
|
299
|
+
function chainOf(network) {
|
|
300
|
+
const n = String(network ?? "").toLowerCase();
|
|
301
|
+
return PAY_CHAINS.find((c) => c.caip.toLowerCase() === n || c.v1 === n || c.id > 0 && String(c.id) === n);
|
|
302
|
+
}
|
|
303
|
+
async function readQuote(res) {
|
|
304
|
+
let body = null;
|
|
305
|
+
const header = res.headers.get("payment-required");
|
|
306
|
+
if (header) {
|
|
307
|
+
try {
|
|
308
|
+
body = JSON.parse(b64decode(header));
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (!body) body = await res.json().catch(() => null);
|
|
313
|
+
if (!body || !Array.isArray(body.accepts)) throw new X402Error("not_x402", "The service answered 402 without x402 payment details.");
|
|
314
|
+
const version = Number(body.x402Version) === 1 ? 1 : 2;
|
|
315
|
+
const accepts = [];
|
|
316
|
+
const offered = [];
|
|
317
|
+
for (const r of body.accepts) {
|
|
318
|
+
offered.push(`${r.scheme ?? "?"}/${r.network ?? "?"}`);
|
|
319
|
+
const chain = chainOf(r.network);
|
|
320
|
+
if (!chain || r.scheme !== "exact") continue;
|
|
321
|
+
if (chain.vm === "evm" ? String(r.asset ?? "").toLowerCase() !== chain.stable.toLowerCase() : r.asset !== chain.stable) continue;
|
|
322
|
+
const amount = String(version === 1 ? r.maxAmountRequired : r.amount);
|
|
323
|
+
if (!/^\d+$/.test(amount)) continue;
|
|
324
|
+
let extra;
|
|
325
|
+
if (chain.vm === "svm") {
|
|
326
|
+
if (typeof r.extra?.feePayer !== "string") continue;
|
|
327
|
+
extra = { feePayer: r.extra.feePayer, ...typeof r.extra.memo === "string" ? { memo: r.extra.memo } : {} };
|
|
328
|
+
} else extra = r.extra?.name && r.extra?.version ? { name: String(r.extra.name), version: String(r.extra.version) } : chain.domain;
|
|
329
|
+
accepts.push({ version, chain, amount, usd: Number(amount) / 1e6, payTo: r.payTo, asset: r.asset, maxTimeoutSeconds: Number(r.maxTimeoutSeconds) || 120, extra, raw: r });
|
|
330
|
+
}
|
|
331
|
+
const inputError = body.inputError?.message ? { code: String(body.inputError.code ?? "invalid_input"), message: String(body.inputError.message) } : void 0;
|
|
332
|
+
return { version, resource: body.resource ?? null, accepts, offered, inputError };
|
|
333
|
+
}
|
|
334
|
+
function choose(q, prefer = CHAIN_KEYS) {
|
|
335
|
+
for (const k of prefer) {
|
|
336
|
+
const r = q.accepts.find((a) => a.chain.key === k);
|
|
337
|
+
if (r) return r;
|
|
338
|
+
}
|
|
339
|
+
throw new X402Error("no_supported_payment", q.offered.length ? `This service only accepts ${[...new Set(q.offered)].join(", ")}. Wafermap pays USDG on Robinhood Chain or USDC on Solana, Base, Arbitrum or Polygon.` : "The service offered no payment option.");
|
|
340
|
+
}
|
|
341
|
+
async function signPayment(signer, req, resource, solana) {
|
|
342
|
+
if (req.chain.vm === "svm") {
|
|
343
|
+
if (!solana) throw new X402Error("no_solana_wallet", "This payment is on Solana and no Solana wallet was given.");
|
|
344
|
+
let transaction;
|
|
345
|
+
try {
|
|
346
|
+
transaction = await solanaPayment(solana, { amount: req.amount, payTo: req.payTo, asset: req.asset, feePayer: req.extra.feePayer, memo: req.extra.memo });
|
|
347
|
+
} catch (e) {
|
|
348
|
+
if (e?.name === "TimeoutError" || e?.cause?.name === "TimeoutError") throw e;
|
|
349
|
+
throw new X402Error("sign_failed", e?.message ?? "Could not build the Solana payment.");
|
|
350
|
+
}
|
|
351
|
+
if (req.version === 1) return { name: "X-PAYMENT", value: b64encode(JSON.stringify({ x402Version: 1, scheme: "exact", network: req.raw.network, payload: { transaction } })) };
|
|
352
|
+
return { name: "PAYMENT-SIGNATURE", value: b64encode(JSON.stringify({ x402Version: 2, resource, accepted: req.raw, payload: { transaction } })) };
|
|
353
|
+
}
|
|
354
|
+
const nonce = randomNonce();
|
|
355
|
+
const validBefore = BigInt(Math.floor(Date.now() / 1e3) + Math.min(req.maxTimeoutSeconds, MAX_VALIDITY_SECONDS));
|
|
356
|
+
const message = { from: signer.address, to: req.payTo, value: BigInt(req.amount), validAfter: 0n, validBefore, nonce };
|
|
357
|
+
let signature;
|
|
358
|
+
try {
|
|
359
|
+
signature = await signer.signTypedData({ domain: { name: req.extra.name, version: req.extra.version, chainId: req.chain.id, verifyingContract: req.asset }, types: TYPES, primaryType: "TransferWithAuthorization", message });
|
|
360
|
+
} catch (e) {
|
|
361
|
+
throw new X402Error("sign_rejected", e?.shortMessage ?? e?.message ?? "Signature rejected.");
|
|
362
|
+
}
|
|
363
|
+
const authorization = { from: signer.address, to: req.payTo, value: req.amount, validAfter: "0", validBefore: validBefore.toString(), nonce };
|
|
364
|
+
const auth = { nonce, validBefore: Number(validBefore) };
|
|
365
|
+
if (req.version === 1) return { name: "X-PAYMENT", value: b64encode(JSON.stringify({ x402Version: 1, scheme: "exact", network: req.raw.network, payload: { signature, authorization } })), auth };
|
|
366
|
+
return { name: "PAYMENT-SIGNATURE", value: b64encode(JSON.stringify({ x402Version: 2, resource, accepted: req.raw, payload: { signature, authorization } })), auth };
|
|
367
|
+
}
|
|
368
|
+
function readSettlement(res) {
|
|
369
|
+
const h = res.headers.get("payment-response") ?? res.headers.get("x-payment-response");
|
|
370
|
+
if (!h) return null;
|
|
371
|
+
try {
|
|
372
|
+
const j = JSON.parse(b64decode(h));
|
|
373
|
+
return { tx: j.transaction ?? j.txHash ?? j.tx, network: j.network, success: j.success };
|
|
374
|
+
} catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async function x402fetch(url, init, opts) {
|
|
379
|
+
const f = opts.fetch ?? fetch;
|
|
380
|
+
const first = await f(url, init);
|
|
381
|
+
if (first.status !== 402) return { res: first, paid: null };
|
|
382
|
+
const quote = await readQuote(first);
|
|
383
|
+
if (quote.inputError) throw new X402Error(quote.inputError.code, quote.inputError.message);
|
|
384
|
+
const req = choose(quote, (opts.prefer ?? CHAIN_KEYS).filter((k) => k !== "solana" || opts.solana));
|
|
385
|
+
if (opts.approve) await opts.approve(req);
|
|
386
|
+
const svm = req.chain.vm === "svm";
|
|
387
|
+
const signer = svm ? null : typeof opts.signer === "function" ? await opts.signer() : opts.signer;
|
|
388
|
+
const header = await signPayment(signer, req, quote.resource, opts.solana);
|
|
389
|
+
const signed = { req, from: svm ? opts.solana.address : signer.address, ...header.auth ? { auth: header.auth } : {} };
|
|
390
|
+
opts.onSigned?.(signed);
|
|
391
|
+
const headers = new Headers(init.headers);
|
|
392
|
+
headers.set(header.name, header.value);
|
|
393
|
+
const res = await f(url, { ...init, headers });
|
|
394
|
+
return { res, paid: { ...signed, tx: readSettlement(res)?.tx ?? null } };
|
|
395
|
+
}
|
|
396
|
+
var USDC_DOMAIN, PAY_CHAINS, EVM_CHAINS, CHAIN_KEYS, X402Error, b64encode, b64decode, TYPES, MAX_VALIDITY_SECONDS, randomNonce;
|
|
397
|
+
var init_x402client = __esm({
|
|
398
|
+
"shared/x402client.ts"() {
|
|
399
|
+
"use strict";
|
|
400
|
+
init_solana();
|
|
401
|
+
USDC_DOMAIN = { name: "USD Coin", version: "2" };
|
|
402
|
+
PAY_CHAINS = [
|
|
403
|
+
{ key: "robinhood", vm: "evm", id: 4663, caip: "eip155:4663", v1: "robinhood", stable: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", symbol: "USDG", name: "Robinhood Chain", domain: { name: "Global Dollar", version: "1" } },
|
|
404
|
+
{ key: "solana", vm: "svm", id: 0, caip: SOLANA_CAIP, v1: "solana", stable: SOLANA_USDC, symbol: "USDC", name: "Solana" },
|
|
405
|
+
{ key: "base", vm: "evm", id: 8453, caip: "eip155:8453", v1: "base", stable: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", symbol: "USDC", name: "Base", domain: USDC_DOMAIN },
|
|
406
|
+
{ key: "arbitrum", vm: "evm", id: 42161, caip: "eip155:42161", v1: "arbitrum", stable: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", symbol: "USDC", name: "Arbitrum", domain: USDC_DOMAIN },
|
|
407
|
+
{ key: "polygon", vm: "evm", id: 137, caip: "eip155:137", v1: "polygon", stable: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", symbol: "USDC", name: "Polygon", domain: USDC_DOMAIN }
|
|
408
|
+
];
|
|
409
|
+
EVM_CHAINS = PAY_CHAINS.filter((c) => c.vm === "evm");
|
|
410
|
+
CHAIN_KEYS = PAY_CHAINS.map((c) => c.key);
|
|
411
|
+
X402Error = class extends Error {
|
|
412
|
+
constructor(code, message) {
|
|
413
|
+
super(message);
|
|
414
|
+
this.code = code;
|
|
415
|
+
}
|
|
416
|
+
code;
|
|
417
|
+
};
|
|
418
|
+
b64encode = (s) => {
|
|
419
|
+
const bytes = new TextEncoder().encode(s);
|
|
420
|
+
let bin = "";
|
|
421
|
+
for (let i = 0; i < bytes.length; i += 32768) bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
|
|
422
|
+
return btoa(bin);
|
|
423
|
+
};
|
|
424
|
+
b64decode = (s) => new TextDecoder().decode(Uint8Array.from(atob(s), (c) => c.charCodeAt(0)));
|
|
425
|
+
TYPES = {
|
|
426
|
+
TransferWithAuthorization: [
|
|
427
|
+
{ name: "from", type: "address" },
|
|
428
|
+
{ name: "to", type: "address" },
|
|
429
|
+
{ name: "value", type: "uint256" },
|
|
430
|
+
{ name: "validAfter", type: "uint256" },
|
|
431
|
+
{ name: "validBefore", type: "uint256" },
|
|
432
|
+
{ name: "nonce", type: "bytes32" }
|
|
433
|
+
]
|
|
434
|
+
};
|
|
435
|
+
MAX_VALIDITY_SECONDS = 90;
|
|
436
|
+
randomNonce = () => "0x" + [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// src/core.ts
|
|
441
|
+
var core_exports = {};
|
|
442
|
+
__export(core_exports, {
|
|
443
|
+
DEFAULT_LIMITS: () => DEFAULT_LIMITS,
|
|
444
|
+
DEFAULT_TIMEOUT_MS: () => DEFAULT_TIMEOUT_MS,
|
|
445
|
+
NETS: () => NETS,
|
|
446
|
+
Wafermap: () => Wafermap,
|
|
447
|
+
WafermapError: () => WafermapError,
|
|
448
|
+
checkPayment: () => checkPayment,
|
|
449
|
+
isNet: () => isNet,
|
|
450
|
+
mergeLog: () => mergeLog
|
|
451
|
+
});
|
|
452
|
+
import { createPublicClient, createWalletClient, erc20Abi as erc20Abi2, formatUnits as formatUnits2, http } from "viem";
|
|
453
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
454
|
+
import { arbitrum, base, polygon, robinhood } from "viem/chains";
|
|
455
|
+
function mergeLog(entries) {
|
|
456
|
+
const outcomes = new Map(entries.filter((e) => e.of).map((e) => [e.of, e]));
|
|
457
|
+
return entries.filter((e) => !e.of).map((e) => {
|
|
458
|
+
const o = e.id ? outcomes.get(e.id) : void 0;
|
|
459
|
+
if (!o) return e;
|
|
460
|
+
return { ...e, status: o.status, tx: o.tx ?? e.tx, reported: o.reported ?? null, ...o.status === "aborted" ? { usd: 0 } : {} };
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
async function checkPayment(e, rpc) {
|
|
464
|
+
if (e.network === "solana" || !e.nonce || !(e.network in CHAINS)) return "unknown";
|
|
465
|
+
const chain = PAY_CHAINS.find((c) => c.key === e.network);
|
|
466
|
+
try {
|
|
467
|
+
const client = createPublicClient({ chain: CHAINS[e.network], transport: http(rpc) });
|
|
468
|
+
const used = await client.readContract({ address: chain.stable, abi: AUTH_STATE_ABI, functionName: "authorizationState", args: [e.payer, e.nonce] });
|
|
469
|
+
if (used) return "used";
|
|
470
|
+
return e.validBefore && Date.now() / 1e3 > e.validBefore ? "unused" : "pending";
|
|
471
|
+
} catch {
|
|
472
|
+
return "error";
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
var NETS, isNet, isSwapNet, WafermapError, CHAINS, AUTH_STATE_ABI, DEFAULT_LIMITS, DEFAULT_TIMEOUT_MS, timedOut, Wafermap;
|
|
476
|
+
var init_core = __esm({
|
|
477
|
+
"src/core.ts"() {
|
|
478
|
+
"use strict";
|
|
479
|
+
init_swap();
|
|
480
|
+
init_solana();
|
|
481
|
+
init_x402client();
|
|
482
|
+
NETS = CHAIN_KEYS;
|
|
483
|
+
isNet = (v) => NETS.includes(v);
|
|
484
|
+
isSwapNet = (v) => v === "robinhood" || v === "base";
|
|
485
|
+
WafermapError = class extends Error {
|
|
486
|
+
constructor(code, message) {
|
|
487
|
+
super(message);
|
|
488
|
+
this.code = code;
|
|
489
|
+
}
|
|
490
|
+
code;
|
|
491
|
+
};
|
|
492
|
+
CHAINS = { robinhood, base, arbitrum, polygon };
|
|
493
|
+
AUTH_STATE_ABI = [{ type: "function", name: "authorizationState", stateMutability: "view", inputs: [{ name: "authorizer", type: "address" }, { name: "nonce", type: "bytes32" }], outputs: [{ type: "bool" }] }];
|
|
494
|
+
DEFAULT_LIMITS = { perPayment: 0.1, perDay: 1, perSwap: 25 };
|
|
495
|
+
DEFAULT_TIMEOUT_MS = 6e4;
|
|
496
|
+
timedOut = (e) => e?.name === "TimeoutError" || e?.cause?.name === "TimeoutError";
|
|
497
|
+
Wafermap = class {
|
|
498
|
+
account;
|
|
499
|
+
baseUrl;
|
|
500
|
+
network;
|
|
501
|
+
limits;
|
|
502
|
+
ledger;
|
|
503
|
+
approveFn;
|
|
504
|
+
rpc;
|
|
505
|
+
clients = {};
|
|
506
|
+
solana;
|
|
507
|
+
solanaAddress;
|
|
508
|
+
timeoutMs;
|
|
509
|
+
/** fetch with this wallet's time limit: a server that never answers cannot hold a call forever. */
|
|
510
|
+
fetch;
|
|
511
|
+
constructor(o) {
|
|
512
|
+
const account = o.account ?? (o.privateKey ? privateKeyToAccount(o.privateKey) : null);
|
|
513
|
+
if (!account) throw new WafermapError("no_account", "Pass privateKey or account.");
|
|
514
|
+
this.account = account;
|
|
515
|
+
this.baseUrl = (o.baseUrl ?? "https://wafermap.xyz").replace(/\/$/, "");
|
|
516
|
+
this.network = o.network ?? "robinhood";
|
|
517
|
+
this.limits = { ...DEFAULT_LIMITS, ...o.limits };
|
|
518
|
+
const mem = [];
|
|
519
|
+
this.ledger = o.ledger ?? { entries: () => mem, record: (e) => void mem.push(e) };
|
|
520
|
+
this.approveFn = o.approve;
|
|
521
|
+
this.rpc = o.rpc ?? {};
|
|
522
|
+
this.timeoutMs = o.timeoutMs && o.timeoutMs > 0 ? o.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
523
|
+
this.fetch = (input, init) => fetch(input, { ...init, signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs) });
|
|
524
|
+
const sol = o.solana ?? (o.privateKey ? solanaKeyFromEvm(o.privateKey) : null);
|
|
525
|
+
const solRpc = this.rpc.solana ?? `${this.baseUrl}/api/solana`;
|
|
526
|
+
this.solana = sol ? { address: sol.address, sign: sol.sign, blockhash: () => latestBlockhash(solRpc, this.fetch) } : null;
|
|
527
|
+
this.solanaAddress = sol?.address ?? o.solanaAddress ?? null;
|
|
528
|
+
}
|
|
529
|
+
get address() {
|
|
530
|
+
return this.account.address;
|
|
531
|
+
}
|
|
532
|
+
client(n) {
|
|
533
|
+
return this.clients[n] ??= createPublicClient({ chain: CHAINS[n], transport: http(this.rpc[n]) });
|
|
534
|
+
}
|
|
535
|
+
/** USD signed today (local midnight) on payments, not counting swaps. Every signed payment
|
|
536
|
+
* counts at its full amount, whatever the server answered, because a signed payment can be
|
|
537
|
+
* executed until it expires. `checkPayment` shows on-chain which ones were never used. */
|
|
538
|
+
spentToday() {
|
|
539
|
+
const start = /* @__PURE__ */ new Date();
|
|
540
|
+
start.setHours(0, 0, 0, 0);
|
|
541
|
+
return this.ledger.entries().filter((e) => e.kind !== "swap" && e.at >= start.getTime() && (e.payer.toLowerCase() === this.address.toLowerCase() || e.payer === this.solanaAddress)).reduce((a, e) => a + e.usd, 0);
|
|
542
|
+
}
|
|
543
|
+
async gate(a) {
|
|
544
|
+
if (a.kind === "swap") {
|
|
545
|
+
if (a.usd > this.limits.perSwap) throw new WafermapError("over_swap_cap", `$${a.usd.toFixed(2)} is above the per-swap cap of $${this.limits.perSwap}.`);
|
|
546
|
+
} else {
|
|
547
|
+
if (a.usd > this.limits.perPayment) throw new WafermapError("over_payment_cap", `$${a.usd} is above the per-payment cap of $${this.limits.perPayment}.`);
|
|
548
|
+
if (this.spentToday() + a.usd > this.limits.perDay + 1e-9) throw new WafermapError("over_daily_cap", `This would pass the daily cap of $${this.limits.perDay} ($${this.spentToday().toFixed(4)} spent today).`);
|
|
549
|
+
}
|
|
550
|
+
if (this.approveFn && !await this.approveFn(a)) throw new WafermapError("declined", "Declined.");
|
|
551
|
+
}
|
|
552
|
+
prefer(n) {
|
|
553
|
+
const first = n ?? this.network;
|
|
554
|
+
return [first, ...NETS.filter((x) => x !== first)];
|
|
555
|
+
}
|
|
556
|
+
/** A second 402 means the payment itself was refused (usually: not enough balance). */
|
|
557
|
+
rejected(res, body, paid) {
|
|
558
|
+
if (!paid || res.status !== 402) return null;
|
|
559
|
+
const reason = typeof body?.error === "string" ? body.error : body?.error?.message ?? "rejected";
|
|
560
|
+
return new WafermapError("payment_rejected", `Payment refused (${reason}). Check that ${paid.req.chain.vm === "svm" ? this.solanaAddress : this.address} holds at least $${paid.req.usd} ${paid.req.chain.symbol} on ${paid.req.chain.name}.`);
|
|
561
|
+
}
|
|
562
|
+
/** Write the signed payment to the ledger before it leaves the machine. */
|
|
563
|
+
booked(kind, what, s) {
|
|
564
|
+
const id = crypto.randomUUID();
|
|
565
|
+
this.ledger.record({ at: Date.now(), kind, what, usd: s.req.usd, network: s.req.chain.key, tx: null, payer: s.from, id, status: "signed", ...s.auth ? { nonce: s.auth.nonce, validBefore: s.auth.validBefore } : {} });
|
|
566
|
+
return id;
|
|
567
|
+
}
|
|
568
|
+
/** The outcome line for a booked payment. It adds nothing to the daily total. */
|
|
569
|
+
outcome(id, kind, what, s, status, tx, reported = null) {
|
|
570
|
+
this.ledger.record({ at: Date.now(), kind, what, usd: 0, network: s.req.chain.key, tx, payer: s.from, of: id, status, reported });
|
|
571
|
+
}
|
|
572
|
+
/** The timeout error for `host`, saying whether a payment was signed and until when it can be executed. */
|
|
573
|
+
timeoutError(host, signed) {
|
|
574
|
+
const after = signed ? ` A payment had been signed and sent. It stays counted${signed.validBefore ? ` and can only be executed until ${new Date(signed.validBefore * 1e3).toLocaleTimeString()}` : ""}; check with \`wafermap history --verify\`.` : " Nothing was paid.";
|
|
575
|
+
return new WafermapError("timeout", `${host} did not answer within ${Math.round(this.timeoutMs / 1e3)} s.${after}`);
|
|
576
|
+
}
|
|
577
|
+
/** Read a JSON body; a time-out while reading is reported, anything else gives `fallback`. */
|
|
578
|
+
async body(res, read, fallback, host, signed) {
|
|
579
|
+
try {
|
|
580
|
+
return await read();
|
|
581
|
+
} catch (e) {
|
|
582
|
+
if (timedOut(e)) throw this.timeoutError(host, signed);
|
|
583
|
+
return fallback;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// Check-sign-book runs one payment at a time in this process, so two calls started together
|
|
587
|
+
// cannot both pass the daily cap on the same remaining budget. Across processes, see onSigned.
|
|
588
|
+
queue = Promise.resolve();
|
|
589
|
+
acquire() {
|
|
590
|
+
let release;
|
|
591
|
+
const held = new Promise((r) => release = r);
|
|
592
|
+
const prev = this.queue;
|
|
593
|
+
this.queue = prev.then(() => held);
|
|
594
|
+
return prev.then(() => release);
|
|
595
|
+
}
|
|
596
|
+
async paidFetch(kind, what, url, init, network) {
|
|
597
|
+
let id = null;
|
|
598
|
+
let release = null;
|
|
599
|
+
const unlock = () => {
|
|
600
|
+
release?.();
|
|
601
|
+
release = null;
|
|
602
|
+
};
|
|
603
|
+
try {
|
|
604
|
+
const r = await x402fetch(url, init, {
|
|
605
|
+
signer: this.account,
|
|
606
|
+
solana: this.solana ?? void 0,
|
|
607
|
+
prefer: this.prefer(network),
|
|
608
|
+
fetch: this.fetch,
|
|
609
|
+
approve: async (req) => {
|
|
610
|
+
release = await this.acquire();
|
|
611
|
+
await this.gate({ kind, what, usd: req.usd, network: req.chain.key, detail: `${req.chain.symbol} to ${req.payTo}` });
|
|
612
|
+
},
|
|
613
|
+
onSigned: (s) => {
|
|
614
|
+
id = this.booked(kind, what, s);
|
|
615
|
+
unlock();
|
|
616
|
+
if (this.spentToday() > this.limits.perDay + 1e-9) {
|
|
617
|
+
this.ledger.record({ at: Date.now(), kind, what, usd: -s.req.usd, network: s.req.chain.key, tx: null, payer: s.from, of: id, status: "aborted" });
|
|
618
|
+
id = null;
|
|
619
|
+
throw new WafermapError("over_daily_cap", `Another payment from this wallet used the daily cap of $${this.limits.perDay} at the same time. Nothing was sent.`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
return { ...r, id };
|
|
624
|
+
} catch (e) {
|
|
625
|
+
if (e instanceof WafermapError) throw e;
|
|
626
|
+
if (e instanceof X402Error) throw new WafermapError(e.code, e.message);
|
|
627
|
+
if (timedOut(e)) throw this.timeoutError(new URL(url).host, id ? this.ledger.entries().find((x) => x.id === id) ?? {} : null);
|
|
628
|
+
throw new WafermapError("network_error", `${new URL(url).host}: ${e?.cause?.message ?? e?.message ?? "request failed"}`);
|
|
629
|
+
} finally {
|
|
630
|
+
unlock();
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// ---- Wafermap resources ----
|
|
634
|
+
async resources() {
|
|
635
|
+
const host = new URL(this.baseUrl).host;
|
|
636
|
+
const r = await this.fetch(`${this.baseUrl}/v1/resources`).catch((e) => {
|
|
637
|
+
throw timedOut(e) ? this.timeoutError(host) : new WafermapError("network_error", `${host}: ${e?.message ?? "request failed"}`);
|
|
638
|
+
});
|
|
639
|
+
const j = await this.body(r, () => r.json(), null, host);
|
|
640
|
+
return j?.data ?? [];
|
|
641
|
+
}
|
|
642
|
+
async call(id, input = {}, o = {}) {
|
|
643
|
+
const go = () => this.paidFetch("resource", id, `${this.baseUrl}/v1/${id}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) }, o.network);
|
|
644
|
+
const settle = (r2, out2) => {
|
|
645
|
+
if (!r2.paid || !r2.id) return;
|
|
646
|
+
const status = r2.res.status === 402 ? "refused" : r2.res.ok ? "ok" : "failed";
|
|
647
|
+
this.outcome(r2.id, "resource", id, r2.paid, status, out2?.receipt?.tx ?? r2.paid.tx, typeof out2?.receipt?.amountUsd === "number" ? out2.receipt.amountUsd : null);
|
|
648
|
+
};
|
|
649
|
+
const host = new URL(this.baseUrl).host;
|
|
650
|
+
const read = (r2) => this.body(r2.res, () => r2.res.json(), {}, host, r2.paid?.auth ?? (r2.paid ? {} : null));
|
|
651
|
+
let r = await go();
|
|
652
|
+
let out = await read(r);
|
|
653
|
+
settle(r, out);
|
|
654
|
+
if (r.paid?.req.chain.vm === "svm" && r.res.status === 402 && /fee_payer/i.test(JSON.stringify(out))) {
|
|
655
|
+
r = await go().catch((e) => {
|
|
656
|
+
if (e?.code === "over_daily_cap") throw new WafermapError("over_daily_cap", `The facilitator refused the first Solana payment (its fee payer rotated). That signed payment still counts until it expires, and a second one would pass the daily cap of $${this.limits.perDay}. Try again in a few minutes or pay on another network.`);
|
|
657
|
+
throw e;
|
|
658
|
+
});
|
|
659
|
+
out = await read(r);
|
|
660
|
+
settle(r, out);
|
|
661
|
+
}
|
|
662
|
+
const { res, paid } = r;
|
|
663
|
+
const refused = this.rejected(res, out, paid);
|
|
664
|
+
if (refused) throw refused;
|
|
665
|
+
if (!res.ok) throw new WafermapError(out?.error?.code ?? "http_error", out?.error?.message ?? (typeof out?.error === "string" ? out.error : `HTTP ${res.status}`));
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
668
|
+
// ---- Third-party x402 services ----
|
|
669
|
+
async discover(query, limit = 10) {
|
|
670
|
+
const host = new URL(this.baseUrl).host;
|
|
671
|
+
const r = await this.fetch(`${this.baseUrl}/api/discover?q=${encodeURIComponent(query)}&limit=${limit}`).catch((e) => {
|
|
672
|
+
throw timedOut(e) ? this.timeoutError(host) : new WafermapError("network_error", `${host}: ${e?.message ?? "request failed"}`);
|
|
673
|
+
});
|
|
674
|
+
const j = await this.body(r, () => r.json(), null, host);
|
|
675
|
+
if (!r.ok || !Array.isArray(j?.services)) throw new WafermapError(j?.error?.code ?? "discovery_failed", j?.error?.message ?? "Discovery is unavailable.");
|
|
676
|
+
return j.services;
|
|
677
|
+
}
|
|
678
|
+
/** Call any x402 URL and pay it if it asks. GET sends `input` as the query string, POST as JSON. */
|
|
679
|
+
async pay(url, o = {}) {
|
|
680
|
+
const method = o.method ?? "GET";
|
|
681
|
+
const qs = method === "GET" && o.input && Object.keys(o.input).length ? new URLSearchParams(Object.entries(o.input).map(([k, v]) => [k, String(v)])).toString() : "";
|
|
682
|
+
const target = qs ? `${url}${url.includes("?") ? "&" : "?"}${qs}` : url;
|
|
683
|
+
const init = method === "GET" ? { method } : { method, headers: { "content-type": "application/json" }, body: JSON.stringify(o.input ?? {}) };
|
|
684
|
+
const u = new URL(url);
|
|
685
|
+
const what = `${u.host}${u.pathname}`;
|
|
686
|
+
const { res, paid, id } = await this.paidFetch("service", what, target, init, o.network);
|
|
687
|
+
if (paid && id) this.outcome(id, "service", what, paid, res.status === 402 ? "refused" : res.ok ? "ok" : "failed", paid.tx);
|
|
688
|
+
const type = res.headers.get("content-type") ?? "";
|
|
689
|
+
const signed = paid?.auth ?? (paid ? {} : null);
|
|
690
|
+
const result2 = /json/.test(type) ? await this.body(res, () => res.json(), null, u.host, signed) : /^(image|audio|video)\//.test(type) ? { bytes: (await this.body(res, () => res.arrayBuffer(), new ArrayBuffer(0), u.host, signed)).byteLength, contentType: type } : await this.body(res, () => res.text(), "", u.host, signed);
|
|
691
|
+
const refused = this.rejected(res, result2, paid);
|
|
692
|
+
if (refused) throw refused;
|
|
693
|
+
if (!res.ok) throw new WafermapError(paid ? "payment_failed" : "http_error", `${u.host} answered ${res.status}: ${JSON.stringify(result2).slice(0, 300)}`);
|
|
694
|
+
return { status: res.status, result: result2, paid: paid ? { usd: paid.req.usd, network: paid.req.chain.key, tx: paid.tx } : null };
|
|
695
|
+
}
|
|
696
|
+
// ---- Wallet ----
|
|
697
|
+
async balances() {
|
|
698
|
+
const out = Object.fromEntries(NETS.map((n) => [n, null]));
|
|
699
|
+
const sol = this.solanaAddress;
|
|
700
|
+
await Promise.all([
|
|
701
|
+
sol ? solanaBalances(this.rpc.solana ?? `${this.baseUrl}/api/solana`, sol, this.fetch).then(
|
|
702
|
+
(b) => void (out.solana = { eth: b.sol, gas: "SOL", stable: b.usdc, symbol: "USDC" }),
|
|
703
|
+
() => {
|
|
704
|
+
}
|
|
705
|
+
) : null,
|
|
706
|
+
...NETS.filter((n) => n !== "solana").map(async (n) => {
|
|
707
|
+
try {
|
|
708
|
+
const c = this.client(n);
|
|
709
|
+
const chain = PAY_CHAINS.find((x) => x.key === n);
|
|
710
|
+
const [eth, usd] = await Promise.all([c.getBalance({ address: this.address }), c.readContract({ address: chain.stable, abi: erc20Abi2, functionName: "balanceOf", args: [this.address] })]);
|
|
711
|
+
out[n] = { eth: formatUnits2(eth, 18), gas: CHAINS[n].nativeCurrency.symbol, stable: formatUnits2(usd, 6), symbol: chain.symbol };
|
|
712
|
+
} catch {
|
|
713
|
+
out[n] = null;
|
|
714
|
+
}
|
|
715
|
+
})
|
|
716
|
+
]);
|
|
717
|
+
return out;
|
|
718
|
+
}
|
|
719
|
+
// ---- Swaps ----
|
|
720
|
+
tokenCache = {};
|
|
721
|
+
async lookup(n, symbol) {
|
|
722
|
+
if (!this.tokenCache[n]) {
|
|
723
|
+
const j = await this.fetch(`${this.baseUrl}/api/tokens?network=${n}`).then((r) => r.json()).catch(() => null);
|
|
724
|
+
this.tokenCache[n] = j?.tokens ?? TOKENS[n];
|
|
725
|
+
}
|
|
726
|
+
return this.tokenCache[n].find((t) => t.symbol.toLowerCase() === symbol.toLowerCase()) ?? null;
|
|
727
|
+
}
|
|
728
|
+
async swapQuote(from, to, amount, network) {
|
|
729
|
+
const n = network ?? (isSwapNet(this.network) ? this.network : "robinhood");
|
|
730
|
+
if (!isSwapNet(n)) throw new WafermapError("unsupported_network", "Swaps run on Robinhood Chain or Base. Use --network robinhood or base.");
|
|
731
|
+
const c = this.client(n);
|
|
732
|
+
try {
|
|
733
|
+
const [tokenIn, tokenOut] = await Promise.all([resolveToken(n, from, c, (net2, s) => this.lookup(net2, s)), resolveToken(n, to, c, (net2, s) => this.lookup(net2, s))]);
|
|
734
|
+
return await quoteSwap({ network: n, tokenIn, tokenOut, amount, fetch: this.fetch });
|
|
735
|
+
} catch (e) {
|
|
736
|
+
throw new WafermapError(e?.code ?? "quote_failed", e?.shortMessage ?? e?.message ?? "No quote.");
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
async swap(quote, o = {}) {
|
|
740
|
+
if (!quote.amountInUsd) throw new WafermapError("no_usd_value", "This swap has no USD value, so the per-swap cap cannot be checked. Not sent.");
|
|
741
|
+
await this.gate({ kind: "swap", what: `${quote.amountIn} ${quote.tokenIn.symbol} \u2192 ${quote.tokenOut.symbol}`, usd: quote.amountInUsd, network: quote.network, detail: `about ${quote.amountOut} ${quote.tokenOut.symbol}` });
|
|
742
|
+
const wallet = createWalletClient({ account: this.account, chain: CHAINS[quote.network], transport: http(this.rpc[quote.network]) });
|
|
743
|
+
try {
|
|
744
|
+
const r = await executeSwap({ quote, wallet, client: this.client(quote.network), slippageBps: o.slippageBps, step: o.step, fetch: this.fetch });
|
|
745
|
+
this.ledger.record({ at: Date.now(), kind: "swap", what: `${quote.amountIn} ${quote.tokenIn.symbol} \u2192 ${quote.tokenOut.symbol}`, usd: 0, network: quote.network, tx: r.tx, payer: this.address });
|
|
746
|
+
return r;
|
|
747
|
+
} catch (e) {
|
|
748
|
+
throw new WafermapError(e?.code ?? "swap_failed", e?.shortMessage ?? e?.message ?? "Swap failed.");
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
// src/store.ts
|
|
756
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
|
757
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
758
|
+
import { homedir } from "node:os";
|
|
759
|
+
import { join } from "node:path";
|
|
760
|
+
function ensureHome() {
|
|
761
|
+
if (!existsSync(HOME)) mkdirSync(HOME, { recursive: true, mode: 448 });
|
|
762
|
+
}
|
|
763
|
+
function secureWrite(path, data) {
|
|
764
|
+
ensureHome();
|
|
765
|
+
writeFileSync(path, data, { mode: 384 });
|
|
766
|
+
try {
|
|
767
|
+
chmodSync(path, 384);
|
|
768
|
+
} catch {
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
function saveKey(privateKey, address, password2, solana) {
|
|
772
|
+
if (password2.length < 8) throw new Error("Use a password of at least 8 characters.");
|
|
773
|
+
const salt = randomBytes(32);
|
|
774
|
+
const iv = randomBytes(12);
|
|
775
|
+
const key = scryptSync(password2, salt, 32, SCRYPT);
|
|
776
|
+
const c = createCipheriv("aes-256-gcm", key, iv);
|
|
777
|
+
const ct = Buffer.concat([c.update(privateKey.slice(2), "hex"), c.final()]);
|
|
778
|
+
secureWrite(
|
|
779
|
+
KEYSTORE,
|
|
780
|
+
JSON.stringify(
|
|
781
|
+
{ version: 1, address, ...solana ? { solana } : {}, kdf: "scrypt", kdfparams: { n: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p, salt: salt.toString("hex") }, cipher: "aes-256-gcm", iv: iv.toString("hex"), tag: c.getAuthTag().toString("hex"), ciphertext: ct.toString("hex") },
|
|
782
|
+
null,
|
|
783
|
+
2
|
|
784
|
+
)
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
function keystoreAddress() {
|
|
788
|
+
if (!hasWallet()) return null;
|
|
789
|
+
return JSON.parse(readFileSync(KEYSTORE, "utf8")).address ?? null;
|
|
790
|
+
}
|
|
791
|
+
function keystoreSolana() {
|
|
792
|
+
if (!hasWallet()) return null;
|
|
793
|
+
return JSON.parse(readFileSync(KEYSTORE, "utf8")).solana ?? null;
|
|
794
|
+
}
|
|
795
|
+
function rememberSolana(address) {
|
|
796
|
+
const k = JSON.parse(readFileSync(KEYSTORE, "utf8"));
|
|
797
|
+
if (k.solana === address) return;
|
|
798
|
+
secureWrite(KEYSTORE, JSON.stringify({ ...k, solana: address }, null, 2));
|
|
799
|
+
}
|
|
800
|
+
function loadKey(password2) {
|
|
801
|
+
if (!hasWallet()) throw new Error("No wallet yet. Run `wafermap init`.");
|
|
802
|
+
const k = JSON.parse(readFileSync(KEYSTORE, "utf8"));
|
|
803
|
+
const key = scryptSync(password2, Buffer.from(k.kdfparams.salt, "hex"), 32, { N: k.kdfparams.n, r: k.kdfparams.r, p: k.kdfparams.p, maxmem: SCRYPT.maxmem });
|
|
804
|
+
const d = createDecipheriv("aes-256-gcm", key, Buffer.from(k.iv, "hex"));
|
|
805
|
+
d.setAuthTag(Buffer.from(k.tag, "hex"));
|
|
806
|
+
try {
|
|
807
|
+
return `0x${Buffer.concat([d.update(Buffer.from(k.ciphertext, "hex")), d.final()]).toString("hex")}`;
|
|
808
|
+
} catch {
|
|
809
|
+
throw new Error("Wrong password.");
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
function config() {
|
|
813
|
+
if (!existsSync(CONFIG)) return DEFAULTS;
|
|
814
|
+
const c = JSON.parse(readFileSync(CONFIG, "utf8"));
|
|
815
|
+
return { network: NETS.includes(c.network) ? c.network : "robinhood", limits: { ...DEFAULTS.limits, ...c.limits }, mcpSwaps: c.mcpSwaps === true, mcpConfirm: c.mcpConfirm === true };
|
|
816
|
+
}
|
|
817
|
+
function saveConfig(c) {
|
|
818
|
+
secureWrite(CONFIG, JSON.stringify(c, null, 2));
|
|
819
|
+
}
|
|
820
|
+
function spendLog() {
|
|
821
|
+
if (!existsSync(SPEND)) return [];
|
|
822
|
+
return readFileSync(SPEND, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
823
|
+
}
|
|
824
|
+
function appendSpend(e) {
|
|
825
|
+
ensureHome();
|
|
826
|
+
appendFileSync(SPEND, JSON.stringify(e) + "\n", { mode: 384 });
|
|
827
|
+
}
|
|
828
|
+
var HOME, KEYSTORE, CONFIG, SPEND, SCRYPT, hasWallet, DEFAULTS;
|
|
829
|
+
var init_store = __esm({
|
|
830
|
+
"src/store.ts"() {
|
|
831
|
+
"use strict";
|
|
832
|
+
init_core();
|
|
833
|
+
HOME = process.env.WAFERMAP_HOME || join(homedir(), ".wafermap");
|
|
834
|
+
KEYSTORE = join(HOME, "keystore.json");
|
|
835
|
+
CONFIG = join(HOME, "config.json");
|
|
836
|
+
SPEND = join(HOME, "spend.jsonl");
|
|
837
|
+
SCRYPT = { N: 1 << 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
|
|
838
|
+
hasWallet = () => existsSync(KEYSTORE);
|
|
839
|
+
DEFAULTS = { network: "robinhood", limits: { perPayment: 0.1, perDay: 1, perSwap: 25 }, mcpSwaps: false, mcpConfirm: false };
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// src/keychain.ts
|
|
844
|
+
import { spawnSync } from "node:child_process";
|
|
845
|
+
import { existsSync as existsSync2, rmSync } from "node:fs";
|
|
846
|
+
import { join as join2 } from "node:path";
|
|
847
|
+
function backend() {
|
|
848
|
+
if (process.platform === "win32") return "windows-dpapi";
|
|
849
|
+
if (process.platform === "darwin") return "macos-keychain";
|
|
850
|
+
if (process.platform === "linux") return "secret-service";
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
function decode(v) {
|
|
854
|
+
const s = v.replace(/\r?\n$/, "");
|
|
855
|
+
return s.startsWith("wm1:") ? Buffer.from(s.slice(4), "base64").toString("utf8") : null;
|
|
856
|
+
}
|
|
857
|
+
function run(cmd, args, input, env) {
|
|
858
|
+
const merged = { ...process.env, ...env };
|
|
859
|
+
if (cmd === POWERSHELL) delete merged.PSModulePath;
|
|
860
|
+
const r = spawnSync(cmd, args, { input, encoding: "utf8", env: merged, windowsHide: true, timeout: 2e4 });
|
|
861
|
+
if (r.error) throw new Error(`${cmd} could not run: ${r.error.code === "ENOENT" ? "not installed" : r.error.message}`);
|
|
862
|
+
return { ok: r.status === 0, out: r.stdout ?? "", err: (r.stderr ?? "").trim() };
|
|
863
|
+
}
|
|
864
|
+
function keychainSet(password2) {
|
|
865
|
+
const b = backend();
|
|
866
|
+
const value = encode(password2);
|
|
867
|
+
if (b === "windows-dpapi") {
|
|
868
|
+
const r = run(POWERSHELL, [...PS, DPAPI_PROTECT], value);
|
|
869
|
+
if (!r.ok || !r.out.trim()) throw new Error(`DPAPI refused: ${r.err || "no output"}`);
|
|
870
|
+
secureWrite(DPAPI_FILE, r.out.trim());
|
|
871
|
+
} else if (b === "macos-keychain") {
|
|
872
|
+
const r = run("security", ["-i"], `add-generic-password -U -a ${ACCOUNT} -s ${SERVICE} -l "Wafermap MCP wallet password" -X ${Buffer.from(value, "utf8").toString("hex")}
|
|
873
|
+
`);
|
|
874
|
+
if (!r.ok || r.err) throw new Error(`Keychain refused: ${r.err || "unknown error"}`);
|
|
875
|
+
} else if (b === "secret-service") {
|
|
876
|
+
const r = run("secret-tool", ["store", "--label=Wafermap MCP wallet password", "service", SERVICE, "account", ACCOUNT], value);
|
|
877
|
+
if (!r.ok) throw new Error(`secret-tool refused: ${r.err || "is a Secret Service (GNOME Keyring, KWallet) running?"}`);
|
|
878
|
+
} else throw new Error(`No OS keychain support on ${process.platform}.`);
|
|
879
|
+
}
|
|
880
|
+
function keychainGet() {
|
|
881
|
+
const b = backend();
|
|
882
|
+
try {
|
|
883
|
+
if (b === "windows-dpapi") {
|
|
884
|
+
if (!existsSync2(DPAPI_FILE)) return null;
|
|
885
|
+
const r = run(POWERSHELL, [...PS, DPAPI_UNPROTECT], void 0, { WM_DPAPI_FILE: DPAPI_FILE });
|
|
886
|
+
return r.ok ? decode(r.out) : null;
|
|
887
|
+
}
|
|
888
|
+
if (b === "macos-keychain") {
|
|
889
|
+
const r = run("security", ["find-generic-password", "-a", ACCOUNT, "-s", SERVICE, "-w"]);
|
|
890
|
+
return r.ok ? decode(r.out) : null;
|
|
891
|
+
}
|
|
892
|
+
if (b === "secret-service") {
|
|
893
|
+
const r = run("secret-tool", ["lookup", "service", SERVICE, "account", ACCOUNT]);
|
|
894
|
+
return r.ok ? decode(r.out) : null;
|
|
895
|
+
}
|
|
896
|
+
} catch {
|
|
897
|
+
}
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
function keychainClear() {
|
|
901
|
+
const b = backend();
|
|
902
|
+
if (b === "windows-dpapi") rmSync(DPAPI_FILE, { force: true });
|
|
903
|
+
else if (b === "macos-keychain") run("security", ["delete-generic-password", "-a", ACCOUNT, "-s", SERVICE]);
|
|
904
|
+
else if (b === "secret-service") run("secret-tool", ["clear", "service", SERVICE, "account", ACCOUNT]);
|
|
905
|
+
}
|
|
906
|
+
var SERVICE, ACCOUNT, DPAPI_FILE, BACKEND_NAME, encode, PS, POWERSHELL, DPAPI_PROTECT, DPAPI_UNPROTECT;
|
|
907
|
+
var init_keychain = __esm({
|
|
908
|
+
"src/keychain.ts"() {
|
|
909
|
+
"use strict";
|
|
910
|
+
init_store();
|
|
911
|
+
SERVICE = "wafermap";
|
|
912
|
+
ACCOUNT = "mcp";
|
|
913
|
+
DPAPI_FILE = join2(HOME, "mcp-password.dpapi");
|
|
914
|
+
BACKEND_NAME = { "windows-dpapi": "Windows DPAPI", "macos-keychain": "macOS Keychain", "secret-service": "Secret Service (secret-tool)" };
|
|
915
|
+
encode = (pw) => `wm1:${Buffer.from(pw, "utf8").toString("base64")}`;
|
|
916
|
+
PS = ["-NoProfile", "-NonInteractive", "-Command"];
|
|
917
|
+
POWERSHELL = join2(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
918
|
+
DPAPI_PROTECT = "[void][Reflection.Assembly]::LoadWithPartialName('System.Security'); $b = [Text.Encoding]::UTF8.GetBytes([Console]::In.ReadToEnd()); [Console]::Out.Write([Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect($b, $null, 'CurrentUser')))";
|
|
919
|
+
DPAPI_UNPROTECT = "[void][Reflection.Assembly]::LoadWithPartialName('System.Security'); $b = [Convert]::FromBase64String([IO.File]::ReadAllText($env:WM_DPAPI_FILE).Trim()); [Console]::Out.Write([Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect($b, $null, 'CurrentUser')))";
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
// src/local.ts
|
|
924
|
+
function openWallet(password2, approve) {
|
|
925
|
+
if (!hasWallet()) throw new Error("No wallet yet. Run `wafermap init` first.");
|
|
926
|
+
const c = config();
|
|
927
|
+
const wm = new Wafermap({
|
|
928
|
+
privateKey: loadKey(password2),
|
|
929
|
+
baseUrl: process.env.WAFERMAP_URL,
|
|
930
|
+
network: c.network,
|
|
931
|
+
limits: c.limits,
|
|
932
|
+
ledger: { entries: spendLog, record: appendSpend },
|
|
933
|
+
approve,
|
|
934
|
+
rpc: { robinhood: process.env.WAFERMAP_RPC_ROBINHOOD, base: process.env.WAFERMAP_RPC_BASE, arbitrum: process.env.WAFERMAP_RPC_ARBITRUM, polygon: process.env.WAFERMAP_RPC_POLYGON, solana: process.env.WAFERMAP_RPC_SOLANA }
|
|
935
|
+
});
|
|
936
|
+
if (wm.solanaAddress) rememberSolana(wm.solanaAddress);
|
|
937
|
+
return wm;
|
|
938
|
+
}
|
|
939
|
+
var init_local = __esm({
|
|
940
|
+
"src/local.ts"() {
|
|
941
|
+
"use strict";
|
|
942
|
+
init_core();
|
|
943
|
+
init_store();
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
// src/version.ts
|
|
948
|
+
var VERSION;
|
|
949
|
+
var init_version = __esm({
|
|
950
|
+
"src/version.ts"() {
|
|
951
|
+
"use strict";
|
|
952
|
+
VERSION = "0.4.1";
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
// src/mcp.ts
|
|
957
|
+
var mcp_exports = {};
|
|
958
|
+
__export(mcp_exports, {
|
|
959
|
+
runMcp: () => runMcp
|
|
960
|
+
});
|
|
961
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
962
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
963
|
+
import { z } from "zod";
|
|
964
|
+
function text(v) {
|
|
965
|
+
return { content: [{ type: "text", text: typeof v === "string" ? v : JSON.stringify(v, null, 2) }] };
|
|
966
|
+
}
|
|
967
|
+
function result(v) {
|
|
968
|
+
const content = [];
|
|
969
|
+
const scrub = (x) => {
|
|
970
|
+
if (typeof x === "string" && /^data:(image|audio)\/[\w.+-]+;base64,/.test(x)) {
|
|
971
|
+
const [head, data] = x.split(",");
|
|
972
|
+
const mimeType = head.slice(5, head.indexOf(";"));
|
|
973
|
+
if (mimeType.startsWith("image/")) content.push({ type: "image", data, mimeType });
|
|
974
|
+
return `[${mimeType}, ${Math.round(data.length * 3 / 4 / 1024)} KB${mimeType.startsWith("image/") ? ", attached" : ""}]`;
|
|
975
|
+
}
|
|
976
|
+
if (Array.isArray(x)) return x.map(scrub);
|
|
977
|
+
if (x && typeof x === "object") return Object.fromEntries(Object.entries(x).map(([k, y]) => [k, scrub(y)]));
|
|
978
|
+
return x;
|
|
979
|
+
};
|
|
980
|
+
const clean = scrub(v);
|
|
981
|
+
return { content: [{ type: "text", text: JSON.stringify(clean, null, 2) }, ...content] };
|
|
982
|
+
}
|
|
983
|
+
function failure(e) {
|
|
984
|
+
const msg = e instanceof WafermapError ? `${e.code}: ${e.message}` : String(e?.message ?? e);
|
|
985
|
+
return { isError: true, content: [{ type: "text", text: msg }] };
|
|
986
|
+
}
|
|
987
|
+
async function confirmWithPerson(server, a) {
|
|
988
|
+
if (!server.server.getClientCapabilities()?.elicitation) {
|
|
989
|
+
throw new WafermapError(
|
|
990
|
+
"confirm_unavailable",
|
|
991
|
+
"Per-payment confirmation is on, but this MCP client cannot show an approval prompt, so nothing was paid. Use a client that supports MCP elicitation, or turn it off with `wafermap limits --mcp-confirm off`."
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
const message = a.kind === "swap" ? `Approve this swap?
|
|
995
|
+
|
|
996
|
+
${a.what} (${a.detail}), worth about $${a.usd.toFixed(2)}, on ${a.network}.` : `Approve this payment?
|
|
997
|
+
|
|
998
|
+
$${a.usd} ${a.detail ?? ""} on ${a.network}
|
|
999
|
+
for ${a.what}`;
|
|
1000
|
+
const r = await server.server.elicitInput({
|
|
1001
|
+
mode: "form",
|
|
1002
|
+
message,
|
|
1003
|
+
requestedSchema: { type: "object", properties: { approve: { type: "boolean", title: "Approve", description: "Sign and pay this one call", default: false } }, required: ["approve"] }
|
|
1004
|
+
});
|
|
1005
|
+
return r.action === "accept" && r.content?.approve === true;
|
|
1006
|
+
}
|
|
1007
|
+
async function runMcp() {
|
|
1008
|
+
const fromEnv = process.env.WAFERMAP_PASSWORD || void 0;
|
|
1009
|
+
const password2 = fromEnv ?? keychainGet();
|
|
1010
|
+
if (!password2) {
|
|
1011
|
+
process.stderr.write("No wallet password: run `wafermap keychain on` once in a terminal (recommended), or set WAFERMAP_PASSWORD in your MCP client's env.\n");
|
|
1012
|
+
process.exit(1);
|
|
1013
|
+
}
|
|
1014
|
+
const store = backend();
|
|
1015
|
+
if (fromEnv && store) process.stderr.write(`WAFERMAP_PASSWORD is set in plain text. Run \`wafermap keychain on\` to keep it in the ${BACKEND_NAME[store]} instead, then remove it from your MCP config.
|
|
1016
|
+
`);
|
|
1017
|
+
const server = new McpServer({ name: "wafermap", version: VERSION });
|
|
1018
|
+
const wm = openWallet(password2, config().mcpConfirm ? (a) => confirmWithPerson(server, a) : void 0);
|
|
1019
|
+
server.registerTool(
|
|
1020
|
+
"wafermap_wallet",
|
|
1021
|
+
{ title: "Wallet status", description: "The agent wallet's address, USDG/USDC and ETH balances on Robinhood Chain and Base, spending caps and today's spend.", inputSchema: {} },
|
|
1022
|
+
async () => {
|
|
1023
|
+
try {
|
|
1024
|
+
return text({ address: wm.address, balances: await wm.balances(), limits: wm.limits, spentTodayUsd: Number(wm.spentToday().toFixed(6)), network: wm.network, swapsEnabled: !!config().mcpSwaps, confirmEachPayment: !!config().mcpConfirm });
|
|
1025
|
+
} catch (e) {
|
|
1026
|
+
return failure(e);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
);
|
|
1030
|
+
server.registerTool(
|
|
1031
|
+
"wafermap_resources",
|
|
1032
|
+
{
|
|
1033
|
+
title: "List Wafermap resources",
|
|
1034
|
+
description: "Pay-per-call tools on Wafermap: LLMs, image and audio models, web reading, on-chain data, tokenized stocks, prediction markets. Each has an id, a USD price and input fields.",
|
|
1035
|
+
inputSchema: { query: z.string().optional().describe("Filter by words in the name, summary or category.") }
|
|
1036
|
+
},
|
|
1037
|
+
async ({ query }) => {
|
|
1038
|
+
try {
|
|
1039
|
+
const q = query?.toLowerCase();
|
|
1040
|
+
const list = (await wm.resources()).filter((r) => !q || `${r.id} ${r.name} ${r.summary} ${r.category}`.toLowerCase().includes(q));
|
|
1041
|
+
return text(list.map((r) => ({ id: r.id, name: r.name, priceUsd: r.price, category: r.category, summary: r.summary, input: r.input ?? r.fields })));
|
|
1042
|
+
} catch (e) {
|
|
1043
|
+
return failure(e);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
);
|
|
1047
|
+
server.registerTool(
|
|
1048
|
+
"wafermap_call",
|
|
1049
|
+
{
|
|
1050
|
+
title: "Call a Wafermap resource",
|
|
1051
|
+
description: "Run one Wafermap resource and pay its exact price over x402 from the local wallet, within the per-payment and daily caps. Failed calls are not charged.",
|
|
1052
|
+
inputSchema: { resource: z.string().describe("Resource id, e.g. market-brief, prediction-markets, stock-signal, flux-2-dev."), input: z.record(z.string(), z.any()).optional().describe("Input fields for the resource."), network: NET }
|
|
1053
|
+
},
|
|
1054
|
+
async ({ resource, input, network }) => {
|
|
1055
|
+
try {
|
|
1056
|
+
return result(await wm.call(resource, input ?? {}, { network }));
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
return failure(e);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
);
|
|
1062
|
+
server.registerTool(
|
|
1063
|
+
"wafermap_discover",
|
|
1064
|
+
{
|
|
1065
|
+
title: "Discover x402 services",
|
|
1066
|
+
description: "Search the open x402 index for third-party paid APIs (weather, search, enrichment, data\u2026). Services marked payable accept USDG on Robinhood Chain or USDC on Solana, Base, Arbitrum or Polygon and can be called with wafermap_pay.",
|
|
1067
|
+
inputSchema: { query: z.string().min(2).describe("What you need, in a few words."), limit: z.number().int().min(1).max(20).optional() }
|
|
1068
|
+
},
|
|
1069
|
+
async ({ query, limit }) => {
|
|
1070
|
+
try {
|
|
1071
|
+
return text(await wm.discover(query, limit ?? 10));
|
|
1072
|
+
} catch (e) {
|
|
1073
|
+
return failure(e);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
);
|
|
1077
|
+
server.registerTool(
|
|
1078
|
+
"wafermap_pay",
|
|
1079
|
+
{
|
|
1080
|
+
title: "Call and pay an x402 service",
|
|
1081
|
+
description: "Call any x402 URL (e.g. one found with wafermap_discover) and pay it if it answers 402, within the caps. GET sends input as query parameters, POST as a JSON body.",
|
|
1082
|
+
inputSchema: { url: z.string().url().startsWith("https://"), method: z.enum(["GET", "POST"]).optional(), input: z.record(z.string(), z.any()).optional(), network: NET }
|
|
1083
|
+
},
|
|
1084
|
+
async ({ url, method, input, network }) => {
|
|
1085
|
+
try {
|
|
1086
|
+
return result(await wm.pay(url, { method, input, network }));
|
|
1087
|
+
} catch (e) {
|
|
1088
|
+
return failure(e);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
);
|
|
1092
|
+
server.registerTool(
|
|
1093
|
+
"wafermap_swap_quote",
|
|
1094
|
+
{
|
|
1095
|
+
title: "Quote a token swap",
|
|
1096
|
+
description: "Best route through the KyberSwap aggregator on Robinhood Chain or Base. Tokens: ETH, WETH, USDG, USDC, Robinhood stock tickers (TSLA\u2026) or a 0x address. Amount is in units of the token you sell.",
|
|
1097
|
+
inputSchema: { from: z.string(), to: z.string(), amount: z.string().describe('e.g. "0.01"'), network: SWAP_NET }
|
|
1098
|
+
},
|
|
1099
|
+
async ({ from, to, amount, network }) => {
|
|
1100
|
+
try {
|
|
1101
|
+
const q = await wm.swapQuote(from, to, amount, network);
|
|
1102
|
+
return text({ network: q.network, pay: `${q.amountIn} ${q.tokenIn.symbol}`, receiveAbout: `${q.amountOut} ${q.tokenOut.symbol}`, amountInUsd: q.amountInUsd, amountOutUsd: q.amountOutUsd, costPct: q.impactPct, gasUsd: q.gasUsd, swapsEnabled: !!config().mcpSwaps });
|
|
1103
|
+
} catch (e) {
|
|
1104
|
+
return failure(e);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
);
|
|
1108
|
+
server.registerTool(
|
|
1109
|
+
"wafermap_swap",
|
|
1110
|
+
{
|
|
1111
|
+
title: "Swap tokens",
|
|
1112
|
+
description: "Execute a swap from the local wallet (needs ETH for gas). Disabled unless the owner ran `wafermap limits --mcp-swaps on`. Always within the per-swap cap. Only swap when the user explicitly asked for this trade.",
|
|
1113
|
+
inputSchema: { from: z.string(), to: z.string(), amount: z.string(), network: SWAP_NET, slippagePct: z.number().min(0.05).max(5).optional().describe("Max slippage, default 1%.") }
|
|
1114
|
+
},
|
|
1115
|
+
async ({ from, to, amount, network, slippagePct }) => {
|
|
1116
|
+
if (!config().mcpSwaps) return failure(new WafermapError("swaps_disabled", "Swaps from MCP are off. The wallet owner can enable them with `wafermap limits --mcp-swaps on`."));
|
|
1117
|
+
try {
|
|
1118
|
+
const q = await wm.swapQuote(from, to, amount, network);
|
|
1119
|
+
const r = await wm.swap(q, { slippageBps: Math.round((slippagePct ?? 1) * 100) });
|
|
1120
|
+
return text({ swapped: `${q.amountIn} ${q.tokenIn.symbol} \u2192 ~${q.amountOut} ${q.tokenOut.symbol}`, minimumOut: r.amountOutMin, tx: r.tx, approveTx: r.approveTx ?? null, network: q.network });
|
|
1121
|
+
} catch (e) {
|
|
1122
|
+
return failure(e);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
);
|
|
1126
|
+
server.registerTool(
|
|
1127
|
+
"wafermap_history",
|
|
1128
|
+
{ title: "Spending history", description: "Recent payments and swaps made by this wallet.", inputSchema: { limit: z.number().int().min(1).max(200).optional() } },
|
|
1129
|
+
async ({ limit }) => text(mergeLog(spendLog()).slice(-(limit ?? 30)).reverse())
|
|
1130
|
+
);
|
|
1131
|
+
await server.connect(new StdioServerTransport());
|
|
1132
|
+
process.stderr.write(`Wafermap MCP ${VERSION} ready \xB7 wallet ${wm.address}
|
|
1133
|
+
`);
|
|
1134
|
+
}
|
|
1135
|
+
var NET, SWAP_NET;
|
|
1136
|
+
var init_mcp = __esm({
|
|
1137
|
+
"src/mcp.ts"() {
|
|
1138
|
+
"use strict";
|
|
1139
|
+
init_core();
|
|
1140
|
+
init_keychain();
|
|
1141
|
+
init_local();
|
|
1142
|
+
init_store();
|
|
1143
|
+
init_version();
|
|
1144
|
+
NET = z.enum(["robinhood", "solana", "base", "arbitrum", "polygon"]).optional().describe("Payment network: robinhood (USDG), or solana, base, arbitrum or polygon (USDC). Defaults to your configured network.");
|
|
1145
|
+
SWAP_NET = z.enum(["robinhood", "base"]).optional().describe("Swap network: robinhood or base. Defaults to your configured network when it is one of these, else robinhood.");
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
// src/cli.ts
|
|
1150
|
+
import { createInterface } from "node:readline";
|
|
1151
|
+
import { generatePrivateKey, privateKeyToAccount as privateKeyToAccount2 } from "viem/accounts";
|
|
1152
|
+
|
|
1153
|
+
// shared/catalog.ts
|
|
1154
|
+
var PROMPT = { name: "prompt", type: "string", required: true, description: "What you want, in plain words.", max: 24e3, example: "Explain x402 in two sentences." };
|
|
1155
|
+
var SYSTEM = { name: "system", type: "string", description: "Optional instructions that shape every answer.", max: 8e3 };
|
|
1156
|
+
var MAX_TOKENS = { name: "max_tokens", type: "number", description: "Output cap. 2048 at most, 1024 by default.", max: 2048, example: 1024 };
|
|
1157
|
+
var NETWORK = { name: "network", type: "network", description: "robinhood (default) or base.", options: ["robinhood", "base"], example: "robinhood" };
|
|
1158
|
+
var TOKEN = { name: "address", type: "address", required: true, description: "Token contract address (0x\u2026).", example: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168" };
|
|
1159
|
+
var IMAGE_PROMPT = { name: "prompt", type: "string", required: true, description: "Describe the image.", max: 2e3, example: "A glossy black wafer on white, studio light" };
|
|
1160
|
+
var SIZE = { name: "size", type: "enum", description: "Square, wide or tall.", options: ["1024x1024", "1280x768", "768x1280"], example: "1024x1024" };
|
|
1161
|
+
var llm = (id, name, model, price, summary, tags = []) => ({
|
|
1162
|
+
id,
|
|
1163
|
+
name,
|
|
1164
|
+
category: "llm",
|
|
1165
|
+
price,
|
|
1166
|
+
summary,
|
|
1167
|
+
provider: "Workers AI",
|
|
1168
|
+
returns: "{ text, model, usage }",
|
|
1169
|
+
model,
|
|
1170
|
+
fields: [PROMPT, SYSTEM, MAX_TOKENS],
|
|
1171
|
+
tags: ["chat", "write", "answer", ...tags]
|
|
1172
|
+
});
|
|
1173
|
+
var image = (id, name, model, price, summary) => ({
|
|
1174
|
+
id,
|
|
1175
|
+
name,
|
|
1176
|
+
category: "image",
|
|
1177
|
+
price,
|
|
1178
|
+
summary,
|
|
1179
|
+
provider: "Workers AI",
|
|
1180
|
+
returns: "{ image: data:image/png;base64,\u2026 }",
|
|
1181
|
+
model,
|
|
1182
|
+
fields: [IMAGE_PROMPT, SIZE],
|
|
1183
|
+
tags: ["image", "picture", "logo", "art", "generate"]
|
|
1184
|
+
});
|
|
1185
|
+
var RESOURCES = [
|
|
1186
|
+
// LLM
|
|
1187
|
+
llm("kimi-k2-6", "Kimi K2.6", "@cf/moonshotai/kimi-k2.6", "0.02", "Moonshot's agentic flagship. Long reasoning, strong tool use, 262K context.", ["reasoning", "agent"]),
|
|
1188
|
+
llm("deepseek-v4-pro", "DeepSeek V4 Pro", "@cf/deepseek-ai/deepseek-v4-pro-0813", "0.025", "DeepSeek's top model for hard reasoning, math and code.", ["reasoning", "code", "math"]),
|
|
1189
|
+
llm("glm-5-3", "GLM 5.3", "@cf/zai-org/glm-5.3", "0.025", "Z.ai's frontier model. Careful writing and multi-step planning.", ["reasoning", "writing"]),
|
|
1190
|
+
llm("qwen-3-8", "Qwen 3.8 27B", "@cf/qwen/qwen3.8-27b", "0.015", "Alibaba's dense 27B. Fast, multilingual, solid at code.", ["multilingual", "code"]),
|
|
1191
|
+
llm("nemotron-3", "Nemotron 3 120B", "@cf/nvidia/nemotron-3-120b-a12b", "0.01", "NVIDIA's open MoE model tuned for agent workflows.", ["agent"]),
|
|
1192
|
+
llm("gpt-oss-120b", "GPT-OSS 120B", "@cf/openai/gpt-oss-120b", "0.006", "OpenAI's open-weight 120B. Reasoning at a low price.", ["reasoning"]),
|
|
1193
|
+
llm("deepseek-v4-1-flash", "DeepSeek V4.1 Flash", "@cf/deepseek-ai/deepseek-v4.1-flash-1017", "0.007", "Quick DeepSeek for everyday questions and drafts.", ["fast"]),
|
|
1194
|
+
llm("llama-4-scout", "Llama 4 Scout", "@cf/meta/llama-4-scout-17b-16e-instruct", "0.006", "Meta's multimodal MoE. Good general assistant.", ["general"]),
|
|
1195
|
+
llm("gemma-4", "Gemma 4 26B", "@cf/google/gemma-4-26b-a4b-it", "0.002", "Google's open model. The cheapest way to get a good answer.", ["cheap", "fast"]),
|
|
1196
|
+
// Image
|
|
1197
|
+
image("flux-2-dev", "FLUX.2 dev", "@cf/black-forest-labs/flux-2-dev", "0.04", "Black Forest Labs' FLUX.2. High fidelity, accurate text in images."),
|
|
1198
|
+
image("flux-2-klein-4b", "FLUX.2 klein 4B", "@cf/black-forest-labs/flux-2-klein-4b", "0.01", "Smallest FLUX.2. Fast drafts and variations."),
|
|
1199
|
+
image("lucid-origin", "Lucid Origin", "@cf/leonardo/lucid-origin", "0.04", "Leonardo's prompt-faithful model. Strong on design and typography."),
|
|
1200
|
+
image("phoenix-1", "Phoenix 1.0", "@cf/leonardo/phoenix-1.0", "0.035", "Leonardo Phoenix. Polished, coherent compositions."),
|
|
1201
|
+
image("flux-1-schnell", "FLUX.1 schnell", "@cf/black-forest-labs/flux-1-schnell", "0.005", "The quick classic. Four steps, half a cent."),
|
|
1202
|
+
// Audio
|
|
1203
|
+
{
|
|
1204
|
+
id: "tts-aura-2",
|
|
1205
|
+
name: "Aura 2 Voice",
|
|
1206
|
+
category: "audio",
|
|
1207
|
+
price: "0.04",
|
|
1208
|
+
summary: "Deepgram Aura 2. Natural English speech from text, up to 1,000 characters.",
|
|
1209
|
+
provider: "Workers AI",
|
|
1210
|
+
returns: "{ audio: data:audio/mpeg;base64,\u2026 }",
|
|
1211
|
+
model: "@cf/deepgram/aura-2-en",
|
|
1212
|
+
fields: [{ name: "text", type: "string", required: true, description: "Text to speak.", max: 1e3, example: "Your agent just paid for this sentence." }],
|
|
1213
|
+
tags: ["speech", "voice", "tts", "audio"]
|
|
1214
|
+
},
|
|
1215
|
+
{
|
|
1216
|
+
id: "tts-melo",
|
|
1217
|
+
name: "MeloTTS",
|
|
1218
|
+
category: "audio",
|
|
1219
|
+
price: "0.002",
|
|
1220
|
+
summary: "Light multilingual text-to-speech. Cheap narration for drafts.",
|
|
1221
|
+
provider: "Workers AI",
|
|
1222
|
+
returns: "{ audio: data:audio/mpeg;base64,\u2026 }",
|
|
1223
|
+
model: "@cf/myshell-ai/melotts",
|
|
1224
|
+
fields: [
|
|
1225
|
+
{ name: "text", type: "string", required: true, description: "Text to speak.", max: 1e3, example: "Hello from Wafermap." },
|
|
1226
|
+
{ name: "lang", type: "enum", description: "Language.", options: ["en", "es", "fr", "zh", "ja", "ko"], example: "en" }
|
|
1227
|
+
],
|
|
1228
|
+
tags: ["speech", "voice", "tts", "audio"]
|
|
1229
|
+
},
|
|
1230
|
+
{
|
|
1231
|
+
id: "stt-whisper",
|
|
1232
|
+
name: "Whisper v3 Turbo",
|
|
1233
|
+
category: "audio",
|
|
1234
|
+
price: "0.01",
|
|
1235
|
+
summary: "Transcribe audio from an HTTPS link. Files up to 10 MB.",
|
|
1236
|
+
provider: "Workers AI",
|
|
1237
|
+
returns: "{ text, language }",
|
|
1238
|
+
model: "@cf/openai/whisper-large-v3-turbo",
|
|
1239
|
+
fields: [{ name: "url", type: "url", required: true, description: "HTTPS link to an audio file (mp3, wav, m4a, ogg).", example: "https://raw.githubusercontent.com/openai/whisper/main/tests/jfk.flac" }],
|
|
1240
|
+
tags: ["transcribe", "speech to text", "audio"]
|
|
1241
|
+
},
|
|
1242
|
+
// Vision + language
|
|
1243
|
+
{
|
|
1244
|
+
id: "vision-describe",
|
|
1245
|
+
name: "Image Describe",
|
|
1246
|
+
category: "vision",
|
|
1247
|
+
price: "0.006",
|
|
1248
|
+
summary: "Ask a question about an image at an HTTPS link. Llama 4 Scout reads it.",
|
|
1249
|
+
provider: "Workers AI",
|
|
1250
|
+
returns: "{ text }",
|
|
1251
|
+
model: "@cf/meta/llama-4-scout-17b-16e-instruct",
|
|
1252
|
+
fields: [
|
|
1253
|
+
{ name: "url", type: "url", required: true, description: "HTTPS link to a PNG, JPEG or WebP image.", example: "https://wafermap.xyz/og.png" },
|
|
1254
|
+
{ name: "question", type: "string", description: "What to look for. Defaults to a plain description.", max: 1e3, example: "What does this image show?" }
|
|
1255
|
+
],
|
|
1256
|
+
tags: ["image", "caption", "ocr", "see"]
|
|
1257
|
+
},
|
|
1258
|
+
{
|
|
1259
|
+
id: "translate",
|
|
1260
|
+
name: "Translate",
|
|
1261
|
+
category: "web",
|
|
1262
|
+
price: "0.002",
|
|
1263
|
+
summary: "Translate text between 100 languages with M2M100.",
|
|
1264
|
+
provider: "Workers AI",
|
|
1265
|
+
returns: "{ text }",
|
|
1266
|
+
model: "@cf/meta/m2m100-1.2b",
|
|
1267
|
+
fields: [
|
|
1268
|
+
{ name: "text", type: "string", required: true, description: "Text to translate.", max: 4e3, example: "Agents pay per call." },
|
|
1269
|
+
{ name: "target", type: "string", required: true, description: "Target language code (e.g. id, es, zh).", example: "id" },
|
|
1270
|
+
{ name: "source", type: "string", description: "Source language code. Defaults to en.", example: "en" }
|
|
1271
|
+
],
|
|
1272
|
+
tags: ["translate", "language"]
|
|
1273
|
+
},
|
|
1274
|
+
{
|
|
1275
|
+
id: "web-read",
|
|
1276
|
+
name: "Web Reader",
|
|
1277
|
+
category: "web",
|
|
1278
|
+
price: "0.003",
|
|
1279
|
+
summary: "Fetch a public web page and return clean Markdown an agent can read.",
|
|
1280
|
+
provider: "Wafermap",
|
|
1281
|
+
returns: "{ title, markdown }",
|
|
1282
|
+
fields: [{ name: "url", type: "url", required: true, description: "HTTPS link to read.", example: "https://www.x402.org" }],
|
|
1283
|
+
tags: ["web", "read", "scrape", "page", "article"]
|
|
1284
|
+
},
|
|
1285
|
+
// On-chain
|
|
1286
|
+
{
|
|
1287
|
+
id: "token-snapshot",
|
|
1288
|
+
name: "Token Snapshot",
|
|
1289
|
+
category: "onchain",
|
|
1290
|
+
price: "0.005",
|
|
1291
|
+
summary: "Name, supply and decimals read from the chain, plus price, liquidity and volume from the top pool.",
|
|
1292
|
+
provider: "Wafermap \xB7 GeckoTerminal",
|
|
1293
|
+
returns: "{ token, market, pools }",
|
|
1294
|
+
fields: [TOKEN, NETWORK],
|
|
1295
|
+
tags: ["token", "price", "market cap", "liquidity", "contract"]
|
|
1296
|
+
},
|
|
1297
|
+
{
|
|
1298
|
+
id: "token-signal",
|
|
1299
|
+
name: "Token Signal",
|
|
1300
|
+
category: "onchain",
|
|
1301
|
+
price: "0.02",
|
|
1302
|
+
summary: "Snapshot plus a short AI read: momentum, liquidity depth and the risks worth checking.",
|
|
1303
|
+
provider: "Wafermap \xB7 Workers AI",
|
|
1304
|
+
returns: "{ snapshot, signal: { stance, summary, risks[] } }",
|
|
1305
|
+
fields: [TOKEN, NETWORK],
|
|
1306
|
+
tags: ["token", "analysis", "sentiment", "risk", "signal"]
|
|
1307
|
+
},
|
|
1308
|
+
{
|
|
1309
|
+
id: "contract-check",
|
|
1310
|
+
name: "Contract Check",
|
|
1311
|
+
category: "onchain",
|
|
1312
|
+
price: "0.005",
|
|
1313
|
+
summary: "Who owns it, is it a proxy, can supply still change. Read straight from bytecode and storage.",
|
|
1314
|
+
provider: "Wafermap",
|
|
1315
|
+
returns: "{ isContract, owner, proxy, implementation, notes[] }",
|
|
1316
|
+
fields: [TOKEN, NETWORK],
|
|
1317
|
+
tags: ["contract", "owner", "proxy", "safety"]
|
|
1318
|
+
},
|
|
1319
|
+
{
|
|
1320
|
+
id: "trending-pools",
|
|
1321
|
+
name: "Trending Pools",
|
|
1322
|
+
category: "onchain",
|
|
1323
|
+
price: "0.01",
|
|
1324
|
+
summary: "The pools traders are hitting right now, with price change, volume and liquidity.",
|
|
1325
|
+
provider: "Wafermap \xB7 GeckoTerminal",
|
|
1326
|
+
returns: "{ network, pools[] }",
|
|
1327
|
+
fields: [NETWORK],
|
|
1328
|
+
tags: ["trending", "hot", "movers", "pools"]
|
|
1329
|
+
},
|
|
1330
|
+
{
|
|
1331
|
+
id: "new-pools",
|
|
1332
|
+
name: "New Pools",
|
|
1333
|
+
category: "onchain",
|
|
1334
|
+
price: "0.005",
|
|
1335
|
+
summary: "Freshly created pools, newest first, with age, liquidity and early volume.",
|
|
1336
|
+
provider: "Wafermap \xB7 GeckoTerminal",
|
|
1337
|
+
returns: "{ network, pools[] }",
|
|
1338
|
+
fields: [NETWORK],
|
|
1339
|
+
tags: ["new", "launch", "fresh", "pools"]
|
|
1340
|
+
},
|
|
1341
|
+
{
|
|
1342
|
+
id: "market-brief",
|
|
1343
|
+
name: "Market Brief",
|
|
1344
|
+
category: "onchain",
|
|
1345
|
+
price: "0.03",
|
|
1346
|
+
summary: "Trending and new pools in one call, written up as a short brief by an LLM.",
|
|
1347
|
+
provider: "Wafermap \xB7 Workers AI",
|
|
1348
|
+
returns: "{ network, brief, pools[] }",
|
|
1349
|
+
fields: [NETWORK],
|
|
1350
|
+
tags: ["market", "brief", "summary", "today"]
|
|
1351
|
+
},
|
|
1352
|
+
{
|
|
1353
|
+
id: "wallet-balances",
|
|
1354
|
+
name: "Wallet Balances",
|
|
1355
|
+
category: "onchain",
|
|
1356
|
+
price: "0.002",
|
|
1357
|
+
summary: "ETH and stablecoin balances for any address, plus any tokens you list.",
|
|
1358
|
+
provider: "Wafermap",
|
|
1359
|
+
returns: "{ address, network, balances[] }",
|
|
1360
|
+
fields: [
|
|
1361
|
+
{ name: "address", type: "address", required: true, description: "Wallet address (0x\u2026).", example: "0x0000000000000000000000000000000000000000" },
|
|
1362
|
+
NETWORK,
|
|
1363
|
+
{ name: "tokens", type: "string", description: "Extra token addresses, comma-separated (up to 10)." }
|
|
1364
|
+
],
|
|
1365
|
+
tags: ["wallet", "balance", "portfolio"]
|
|
1366
|
+
},
|
|
1367
|
+
{
|
|
1368
|
+
id: "gas-now",
|
|
1369
|
+
name: "Gas Now",
|
|
1370
|
+
category: "onchain",
|
|
1371
|
+
price: "0.001",
|
|
1372
|
+
summary: "Current gas price and latest block on Robinhood Chain or Base.",
|
|
1373
|
+
provider: "Wafermap",
|
|
1374
|
+
returns: "{ network, block, gasPriceGwei }",
|
|
1375
|
+
fields: [NETWORK],
|
|
1376
|
+
tags: ["gas", "fee", "block"]
|
|
1377
|
+
},
|
|
1378
|
+
// Tokenized stocks (Robinhood Chain)
|
|
1379
|
+
{
|
|
1380
|
+
id: "stock-tokens",
|
|
1381
|
+
name: "Stock Tokens",
|
|
1382
|
+
category: "stocks",
|
|
1383
|
+
price: "0.002",
|
|
1384
|
+
summary: "Every active stock token on Robinhood Chain: ticker, name, contract and whether a Chainlink feed prices it.",
|
|
1385
|
+
provider: "Wafermap \xB7 Robinhood registry",
|
|
1386
|
+
returns: "{ total, stocks[] }",
|
|
1387
|
+
fields: [{ name: "query", type: "string", description: "Filter by ticker or company name.", max: 60, example: "tesla" }],
|
|
1388
|
+
tags: ["stocks", "equities", "xstocks", "tokenized", "list"]
|
|
1389
|
+
},
|
|
1390
|
+
{
|
|
1391
|
+
id: "stock-signal",
|
|
1392
|
+
name: "Stock Token Signal",
|
|
1393
|
+
category: "stocks",
|
|
1394
|
+
price: "0.02",
|
|
1395
|
+
summary: "One stock token: Chainlink reference price, on-chain DEX price, the premium between them and a short AI read.",
|
|
1396
|
+
provider: "Wafermap \xB7 Chainlink \xB7 Workers AI",
|
|
1397
|
+
returns: "{ stock, reference, dex, premiumPct, signal: { stance, summary, risks[] } }",
|
|
1398
|
+
fields: [{ name: "symbol", type: "string", required: true, description: "Ticker (TSLA) or token address.", max: 64, example: "TSLA" }],
|
|
1399
|
+
tags: ["stocks", "signal", "tsla", "nvda", "premium", "tokenized", "trading"]
|
|
1400
|
+
},
|
|
1401
|
+
// Prediction markets (Polymarket)
|
|
1402
|
+
{
|
|
1403
|
+
id: "prediction-markets",
|
|
1404
|
+
name: "Prediction Markets",
|
|
1405
|
+
category: "prediction",
|
|
1406
|
+
price: "0.003",
|
|
1407
|
+
summary: "Live Polymarket markets with outcome odds, volume, liquidity and end date. Search by topic or browse the busiest.",
|
|
1408
|
+
provider: "Wafermap \xB7 Polymarket",
|
|
1409
|
+
returns: "{ markets[]: { question, outcomes[{ name, probability }], volume24hUsd, liquidityUsd, endDate, url } }",
|
|
1410
|
+
fields: [
|
|
1411
|
+
{ name: "query", type: "string", description: "Topic to search, e.g. fed rates, bitcoin, election.", max: 120, example: "bitcoin" },
|
|
1412
|
+
{ name: "sort", type: "enum", description: "Without a query: busiest, ending soon or newest.", options: ["volume", "ending", "new"], example: "volume" },
|
|
1413
|
+
{ name: "limit", type: "number", description: "How many markets, 20 at most.", max: 20, example: 10 }
|
|
1414
|
+
],
|
|
1415
|
+
tags: ["polymarket", "prediction", "odds", "bets", "forecast", "election"]
|
|
1416
|
+
},
|
|
1417
|
+
{
|
|
1418
|
+
id: "prediction-read",
|
|
1419
|
+
name: "Prediction Read",
|
|
1420
|
+
category: "prediction",
|
|
1421
|
+
price: "0.02",
|
|
1422
|
+
summary: "One Polymarket market, read by an LLM: what the odds price in, the week's move, catalysts and risks.",
|
|
1423
|
+
provider: "Wafermap \xB7 Polymarket \xB7 Workers AI",
|
|
1424
|
+
returns: "{ market, history[], read: { summary, pricedIn, lean, confidence, catalysts[], risks[] } }",
|
|
1425
|
+
fields: [{ name: "market", type: "string", required: true, description: "Polymarket link, slug, market id, or a few words to find the busiest matching market.", max: 300, example: "fed interest rates" }],
|
|
1426
|
+
tags: ["polymarket", "prediction", "analysis", "odds", "forecast"]
|
|
1427
|
+
},
|
|
1428
|
+
// Content
|
|
1429
|
+
{
|
|
1430
|
+
id: "token-starter-kit",
|
|
1431
|
+
name: "Token Starter Kit",
|
|
1432
|
+
category: "content",
|
|
1433
|
+
price: "0.08",
|
|
1434
|
+
summary: "From one idea: three name and ticker options, a tagline, a short lore and a logo.",
|
|
1435
|
+
provider: "Wafermap \xB7 Workers AI",
|
|
1436
|
+
returns: "{ names[], tagline, lore, logo: data:image/png;base64,\u2026 }",
|
|
1437
|
+
fields: [{ name: "idea", type: "string", required: true, description: "The idea, in a sentence.", max: 500, example: "A cat that only trades on weekends" }],
|
|
1438
|
+
tags: ["token", "launch", "logo", "name", "meme"]
|
|
1439
|
+
},
|
|
1440
|
+
{
|
|
1441
|
+
id: "x-post-writer",
|
|
1442
|
+
name: "X Post Writer",
|
|
1443
|
+
category: "content",
|
|
1444
|
+
price: "0.01",
|
|
1445
|
+
summary: "Three ready-to-post X drafts under 280 characters, in the tone you ask for.",
|
|
1446
|
+
provider: "Wafermap \xB7 Workers AI",
|
|
1447
|
+
returns: "{ posts[] }",
|
|
1448
|
+
fields: [
|
|
1449
|
+
{ name: "topic", type: "string", required: true, description: "What the posts are about.", max: 1500, example: "Our agent now pays for its own tools" },
|
|
1450
|
+
{ name: "tone", type: "enum", description: "Voice.", options: ["confident", "playful", "technical", "minimal"], example: "confident" }
|
|
1451
|
+
],
|
|
1452
|
+
tags: ["twitter", "x", "post", "tweet", "copy"]
|
|
1453
|
+
},
|
|
1454
|
+
{
|
|
1455
|
+
id: "prompt-refine",
|
|
1456
|
+
name: "Prompt Refiner",
|
|
1457
|
+
category: "content",
|
|
1458
|
+
price: "0.004",
|
|
1459
|
+
summary: "Turn a rough idea into a precise prompt for an image or text model.",
|
|
1460
|
+
provider: "Wafermap \xB7 Workers AI",
|
|
1461
|
+
returns: "{ prompt, negative }",
|
|
1462
|
+
fields: [
|
|
1463
|
+
{ name: "idea", type: "string", required: true, description: "Your rough idea.", max: 1500, example: "logo for a robot bank" },
|
|
1464
|
+
{ name: "target", type: "enum", description: "What the prompt is for.", options: ["image", "text"], example: "image" }
|
|
1465
|
+
],
|
|
1466
|
+
tags: ["prompt", "creative", "director"]
|
|
1467
|
+
}
|
|
1468
|
+
];
|
|
1469
|
+
var NETWORKS = {
|
|
1470
|
+
robinhood: { id: 4663, caip: "eip155:4663", name: "Robinhood Chain", asset: "USDG", gecko: "robinhood", explorer: "https://robinhoodchain.blockscout.com" },
|
|
1471
|
+
base: { id: 8453, caip: "eip155:8453", name: "Base", asset: "USDC", gecko: "base", explorer: "https://basescan.org" }
|
|
1472
|
+
};
|
|
1473
|
+
var PAY_NETWORKS = {
|
|
1474
|
+
robinhood: { ...NETWORKS.robinhood, vm: "evm" },
|
|
1475
|
+
solana: { id: 0, caip: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", name: "Solana", asset: "USDC", explorer: "https://solscan.io", vm: "svm" },
|
|
1476
|
+
base: { ...NETWORKS.base, vm: "evm" },
|
|
1477
|
+
arbitrum: { id: 42161, caip: "eip155:42161", name: "Arbitrum", asset: "USDC", explorer: "https://arbiscan.io", vm: "evm" },
|
|
1478
|
+
polygon: { id: 137, caip: "eip155:137", name: "Polygon", asset: "USDC", explorer: "https://polygonscan.com", vm: "evm" }
|
|
1479
|
+
};
|
|
1480
|
+
function payNetworkOf(v) {
|
|
1481
|
+
const s = String(v ?? "").toLowerCase();
|
|
1482
|
+
for (const [k, n] of Object.entries(PAY_NETWORKS)) if (s === k || s === n.caip.toLowerCase() || n.id && s === String(n.id)) return k;
|
|
1483
|
+
return null;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// src/cli.ts
|
|
1487
|
+
init_core();
|
|
1488
|
+
init_keychain();
|
|
1489
|
+
init_local();
|
|
1490
|
+
init_solana();
|
|
1491
|
+
init_store();
|
|
1492
|
+
init_version();
|
|
1493
|
+
var HELP = `wafermap ${VERSION}: an x402 wallet for agents (Robinhood Chain, Solana, Base, Arbitrum, Polygon)
|
|
1494
|
+
|
|
1495
|
+
Wallet
|
|
1496
|
+
wafermap init [--import] create (or import) the agent wallet, encrypted with a password
|
|
1497
|
+
wafermap address print the wallet addresses (EVM and Solana)
|
|
1498
|
+
wafermap balance USDG / USDC and gas on every payment network
|
|
1499
|
+
wafermap limits [--per-payment 0.1] [--per-day 1] [--per-swap 25] [--network <net>] [--mcp-swaps on|off] [--mcp-confirm on|off]
|
|
1500
|
+
wafermap history [--days 7] [--verify] payments and swaps from this machine; --verify checks on-chain
|
|
1501
|
+
wafermap export-key [--solana] print the private key, or the Solana key for Phantom/Solflare
|
|
1502
|
+
wafermap keychain [on|off] keep the password for \`wafermap mcp\` in the OS keychain, not in plain text
|
|
1503
|
+
|
|
1504
|
+
Pay
|
|
1505
|
+
wafermap resources [words] Wafermap's pay-per-call catalog
|
|
1506
|
+
wafermap call <id> [field=value ...] run a resource and pay its price (--json '{...}' for complex input)
|
|
1507
|
+
wafermap discover <words> search third-party x402 services
|
|
1508
|
+
wafermap pay <url> [field=value ...] call any x402 URL and pay it (--post to send a JSON body)
|
|
1509
|
+
|
|
1510
|
+
Trade
|
|
1511
|
+
wafermap swap <amount> <from> <to> swap through KyberSwap on robinhood or base, e.g. wafermap swap 0.01 ETH USDG
|
|
1512
|
+
|
|
1513
|
+
Agents
|
|
1514
|
+
wafermap mcp run as an MCP server (stdio); password from \`wafermap keychain on\` or WAFERMAP_PASSWORD
|
|
1515
|
+
|
|
1516
|
+
Common flags: --network robinhood|solana|base|arbitrum|polygon --yes (skip the confirmation)
|
|
1517
|
+
Files live in ${HOME}. Docs: https://wafermap.xyz/docs#cli`;
|
|
1518
|
+
function parse(argv) {
|
|
1519
|
+
const out = { _: [], flags: {} };
|
|
1520
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1521
|
+
const a = argv[i];
|
|
1522
|
+
if (a.startsWith("--")) {
|
|
1523
|
+
const [k, v] = a.slice(2).split("=", 2);
|
|
1524
|
+
if (v !== void 0) out.flags[k] = v;
|
|
1525
|
+
else if (argv[i + 1] && !argv[i + 1].startsWith("--") && !["yes", "post", "import"].includes(k)) out.flags[k] = argv[++i];
|
|
1526
|
+
else out.flags[k] = true;
|
|
1527
|
+
} else out._.push(a);
|
|
1528
|
+
}
|
|
1529
|
+
return out;
|
|
1530
|
+
}
|
|
1531
|
+
function ask(question, hidden = false) {
|
|
1532
|
+
return new Promise((resolve) => {
|
|
1533
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
1534
|
+
if (hidden) {
|
|
1535
|
+
const out = process.stdout;
|
|
1536
|
+
rl._writeToOutput = (s) => out.write(s.includes(question) ? question : s === "\r\n" || s === "\n" ? s : "");
|
|
1537
|
+
}
|
|
1538
|
+
rl.question(question, (a) => {
|
|
1539
|
+
rl.close();
|
|
1540
|
+
if (hidden) process.stdout.write("\n");
|
|
1541
|
+
resolve(a);
|
|
1542
|
+
});
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
async function password() {
|
|
1546
|
+
return process.env.WAFERMAP_PASSWORD || await ask("Wallet password: ", true);
|
|
1547
|
+
}
|
|
1548
|
+
var net = (v) => isNet(v) ? v : v === void 0 || v === true ? void 0 : die(`--network must be one of ${NETS.join(", ")}`);
|
|
1549
|
+
function die(msg) {
|
|
1550
|
+
console.error(`\u2715 ${msg}`);
|
|
1551
|
+
process.exit(1);
|
|
1552
|
+
}
|
|
1553
|
+
function inputOf(a, from = 0) {
|
|
1554
|
+
const input = {};
|
|
1555
|
+
if (typeof a.flags.json === "string") {
|
|
1556
|
+
try {
|
|
1557
|
+
Object.assign(input, JSON.parse(a.flags.json));
|
|
1558
|
+
} catch {
|
|
1559
|
+
die("--json is not valid JSON");
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
for (const kv of a._.slice(from)) {
|
|
1563
|
+
const i = kv.indexOf("=");
|
|
1564
|
+
if (i < 1) die(`"${kv}" should look like field=value`);
|
|
1565
|
+
const v = kv.slice(i + 1);
|
|
1566
|
+
input[kv.slice(0, i)] = /^-?\d+(\.\d+)?$/.test(v) && !/^0x/i.test(v) && v.length < 16 ? Number(v) : v;
|
|
1567
|
+
}
|
|
1568
|
+
return input;
|
|
1569
|
+
}
|
|
1570
|
+
function approver(yes) {
|
|
1571
|
+
return async (a) => {
|
|
1572
|
+
if (yes) return true;
|
|
1573
|
+
const what = a.kind === "swap" ? `Swap ${a.what} (${a.detail}), worth $${a.usd.toFixed(2)}` : `Pay $${a.usd} on ${a.network} for ${a.what}`;
|
|
1574
|
+
return /^y(es)?$/i.test((await ask(`${what}? [y/N] `)).trim());
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
var EXPLORER = (n) => PAY_NETWORKS[payNetworkOf(n) ?? "robinhood"].explorer;
|
|
1578
|
+
function printResult(r) {
|
|
1579
|
+
const scrub = (x) => typeof x === "string" && x.startsWith("data:") ? `[${x.slice(5, x.indexOf(";"))}, ${Math.round(x.length / 1365)} KB, use --out to save]` : Array.isArray(x) ? x.map(scrub) : x && typeof x === "object" ? Object.fromEntries(Object.entries(x).map(([k, y]) => [k, scrub(y)])) : x;
|
|
1580
|
+
console.log(typeof r === "string" ? r : JSON.stringify(scrub(r), null, 2));
|
|
1581
|
+
}
|
|
1582
|
+
async function saveMedia(r, out) {
|
|
1583
|
+
const { writeFileSync: writeFileSync2 } = await import("node:fs");
|
|
1584
|
+
const media = JSON.stringify(r).match(/data:[\w/.+-]+;base64,[A-Za-z0-9+/=]+/);
|
|
1585
|
+
if (!media) return console.log("(no image or audio in the result to save)");
|
|
1586
|
+
writeFileSync2(out, Buffer.from(media[0].split(",")[1], "base64"));
|
|
1587
|
+
console.log(`Saved ${out}`);
|
|
1588
|
+
}
|
|
1589
|
+
async function main() {
|
|
1590
|
+
const a = parse(process.argv.slice(2));
|
|
1591
|
+
const cmd = a._.shift() ?? (a.flags.version ? "version" : "help");
|
|
1592
|
+
const yes = a.flags.yes === true;
|
|
1593
|
+
switch (cmd) {
|
|
1594
|
+
case "help":
|
|
1595
|
+
case "--help":
|
|
1596
|
+
case "-h":
|
|
1597
|
+
return console.log(HELP);
|
|
1598
|
+
case "version":
|
|
1599
|
+
case "--version":
|
|
1600
|
+
return console.log(VERSION);
|
|
1601
|
+
case "init": {
|
|
1602
|
+
if (hasWallet()) die(`A wallet already exists (${keystoreAddress()}). Delete ${HOME}/keystore.json first if you really want a new one.`);
|
|
1603
|
+
let key;
|
|
1604
|
+
if (a.flags.import) {
|
|
1605
|
+
const k = (await ask("Private key (0x\u2026): ", true)).trim();
|
|
1606
|
+
key = k.startsWith("0x") ? k : `0x${k}`;
|
|
1607
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(key)) die("That is not a 64-character hex private key.");
|
|
1608
|
+
} else key = generatePrivateKey();
|
|
1609
|
+
const p1 = process.env.WAFERMAP_PASSWORD ?? await ask("New password (8+ characters): ", true);
|
|
1610
|
+
if (!process.env.WAFERMAP_PASSWORD && p1 !== await ask("Repeat password: ", true)) die("Passwords do not match.");
|
|
1611
|
+
const acct = privateKeyToAccount2(key);
|
|
1612
|
+
const sol = solanaKeyFromEvm(key).address;
|
|
1613
|
+
saveKey(key, acct.address, p1, sol);
|
|
1614
|
+
saveConfig(config());
|
|
1615
|
+
console.log(`\u2713 Agent wallet ${acct.address}
|
|
1616
|
+
Solana address ${sol} (derived from the same key)
|
|
1617
|
+
Key encrypted in ${HOME}/keystore.json. Forget the password and the key is gone: run \`wafermap export-key\` to back it up.
|
|
1618
|
+
Fund it with a few dollars of USDC on Solana (Solana address), or USDG on Robinhood Chain or USDC on Base, Arbitrum or Polygon (EVM address). Payments are gasless; swaps need a little ETH.
|
|
1619
|
+
Caps: $0.10 per payment, $1 per day, $25 per swap. Change with \`wafermap limits\`.`);
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
case "address": {
|
|
1623
|
+
const evm = keystoreAddress() ?? die("No wallet yet. Run `wafermap init`.");
|
|
1624
|
+
let sol = keystoreSolana();
|
|
1625
|
+
if (!sol && process.env.WAFERMAP_PASSWORD) rememberSolana(sol = solanaKeyFromEvm(loadKey(process.env.WAFERMAP_PASSWORD)).address);
|
|
1626
|
+
console.log(`evm ${evm}`);
|
|
1627
|
+
return console.log(`solana ${sol ?? "(unlock once to see it: WAFERMAP_PASSWORD=\u2026 wafermap address)"}`);
|
|
1628
|
+
}
|
|
1629
|
+
case "export-key": {
|
|
1630
|
+
const k = loadKey(await password());
|
|
1631
|
+
console.error("Anyone with this key can spend the wallet. Do not paste it into chats or websites.");
|
|
1632
|
+
if (a.flags.solana) {
|
|
1633
|
+
const s = solanaKeyFromEvm(k);
|
|
1634
|
+
rememberSolana(s.address);
|
|
1635
|
+
console.error(`Solana ${s.address}. Import this secret key in Phantom or Solflare ("Import private key"):`);
|
|
1636
|
+
return console.log(s.secretKey);
|
|
1637
|
+
}
|
|
1638
|
+
return console.log(k);
|
|
1639
|
+
}
|
|
1640
|
+
case "limits": {
|
|
1641
|
+
const c = config();
|
|
1642
|
+
const num = (k) => {
|
|
1643
|
+
const v = a.flags[k];
|
|
1644
|
+
if (v === void 0) return void 0;
|
|
1645
|
+
const n = Number(v);
|
|
1646
|
+
if (!(n > 0)) die(`--${k} must be a positive number`);
|
|
1647
|
+
return n;
|
|
1648
|
+
};
|
|
1649
|
+
c.limits.perPayment = num("per-payment") ?? c.limits.perPayment;
|
|
1650
|
+
c.limits.perDay = num("per-day") ?? c.limits.perDay;
|
|
1651
|
+
c.limits.perSwap = num("per-swap") ?? c.limits.perSwap;
|
|
1652
|
+
if (c.limits.perPayment > c.limits.perDay) die("The per-payment cap cannot be above the daily cap.");
|
|
1653
|
+
c.network = net(a.flags.network) ?? c.network;
|
|
1654
|
+
if (a.flags["mcp-swaps"] !== void 0) c.mcpSwaps = a.flags["mcp-swaps"] === "on";
|
|
1655
|
+
if (a.flags["mcp-confirm"] !== void 0) c.mcpConfirm = a.flags["mcp-confirm"] === "on";
|
|
1656
|
+
if (Object.keys(a.flags).length) saveConfig(c);
|
|
1657
|
+
return console.log(`Per payment $${c.limits.perPayment} \xB7 per day $${c.limits.perDay} \xB7 per swap $${c.limits.perSwap} \xB7 network ${c.network} \xB7 swaps from MCP ${c.mcpSwaps ? "on" : "off"} \xB7 confirm each MCP payment ${c.mcpConfirm ? "on" : "off"}`);
|
|
1658
|
+
}
|
|
1659
|
+
case "history": {
|
|
1660
|
+
const days = Number(a.flags.days ?? 7);
|
|
1661
|
+
const since = Date.now() - days * 864e5;
|
|
1662
|
+
const rows = mergeLog(spendLog()).filter((e) => e.at >= since).reverse();
|
|
1663
|
+
if (!rows.length) return console.log(`Nothing in the last ${days} days.`);
|
|
1664
|
+
const verify = a.flags.verify !== void 0;
|
|
1665
|
+
const RPC = { robinhood: process.env.WAFERMAP_RPC_ROBINHOOD, base: process.env.WAFERMAP_RPC_BASE, arbitrum: process.env.WAFERMAP_RPC_ARBITRUM, polygon: process.env.WAFERMAP_RPC_POLYGON };
|
|
1666
|
+
const chain = verify ? await Promise.all(rows.map((e) => e.kind === "swap" ? null : checkPayment(e, RPC[e.network]))) : [];
|
|
1667
|
+
rows.forEach((e, i) => {
|
|
1668
|
+
const state = [e.status, chain[i] ? `on-chain: ${chain[i]}` : null].filter(Boolean).join(", ");
|
|
1669
|
+
console.log(`${new Date(e.at).toISOString().slice(0, 16).replace("T", " ")} ${e.kind.padEnd(8)} ${e.kind === "swap" ? " " : `$${e.usd}`.padEnd(8)} ${e.network.padEnd(9)} ${e.what}${state ? ` [${state}]` : ""}${e.tx ? ` ${EXPLORER(e.network)}/tx/${e.tx}` : ""}`);
|
|
1670
|
+
});
|
|
1671
|
+
const pays = rows.filter((e) => e.kind !== "swap");
|
|
1672
|
+
const total = pays.reduce((s, e) => s + e.usd, 0);
|
|
1673
|
+
console.log(`
|
|
1674
|
+
Total $${total.toFixed(4)} in ${pays.length} payments. From 0.3.3 every payment counts at its full signed amount; older lines show what the server reported.`);
|
|
1675
|
+
if (verify) {
|
|
1676
|
+
const never = rows.filter((e, i) => e.kind !== "swap" && chain[i] === "unused").reduce((s, e) => s + e.usd, 0);
|
|
1677
|
+
console.log(`On-chain: $${never.toFixed(4)} of that was signed but never executed and has expired. "unknown" = Solana or an older log line; "error" = the RPC did not answer, try again.`);
|
|
1678
|
+
} else console.log("Add --verify to check on-chain which signed payments were executed.");
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
case "resources": {
|
|
1682
|
+
const q = a._.join(" ").toLowerCase();
|
|
1683
|
+
const r = await fetch(`${process.env.WAFERMAP_URL ?? "https://wafermap.xyz"}/v1/resources`).then((x) => x.json());
|
|
1684
|
+
for (const x of (r.data ?? []).filter((x2) => !q || `${x2.id} ${x2.name} ${x2.summary} ${x2.category}`.toLowerCase().includes(q))) console.log(`${x.id.padEnd(22)} $${String(x.price).padEnd(7)} ${x.summary}`);
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
case "discover": {
|
|
1688
|
+
const q = a._.join(" ");
|
|
1689
|
+
if (q.length < 2) die("Say what you are looking for: wafermap discover weather");
|
|
1690
|
+
const wm = openWallet(await password());
|
|
1691
|
+
const list = await wm.discover(q, Number(a.flags.limit ?? 10));
|
|
1692
|
+
for (const s of list) console.log(`${s.payable ? "\u25CF" : "\u25CB"} ${s.priceUsd !== null ? `$${s.priceUsd}`.padEnd(8) : "\u2014".padEnd(8)} ${s.method.padEnd(4)} ${s.url}
|
|
1693
|
+
${s.description.slice(0, 140)}${s.input ? `
|
|
1694
|
+
input: ${JSON.stringify(s.input).slice(0, 140)}` : ""}`);
|
|
1695
|
+
return console.log(`
|
|
1696
|
+
\u25CF payable with USDG (Robinhood Chain) or USDC (Solana, Base, Arbitrum, Polygon) \u25CB other networks only`);
|
|
1697
|
+
}
|
|
1698
|
+
case "call": {
|
|
1699
|
+
const id = a._[0] ?? die("Which resource? e.g. wafermap call gas-now network=base");
|
|
1700
|
+
const wm = openWallet(await password(), approver(yes));
|
|
1701
|
+
const out = await wm.call(id, inputOf(a, 1), { network: net(a.flags.network) });
|
|
1702
|
+
if (out.receipt) console.error(`\u2713 Paid $${out.receipt.amountUsd} ${out.receipt.asset} \xB7 ${EXPLORER(out.receipt.network)}/tx/${out.receipt.tx}`);
|
|
1703
|
+
if (typeof a.flags.out === "string") await saveMedia(out.result, a.flags.out);
|
|
1704
|
+
return printResult(out.result);
|
|
1705
|
+
}
|
|
1706
|
+
case "pay": {
|
|
1707
|
+
const url = a._[0] ?? die("Which URL? e.g. wafermap pay https://example.com/api city=Paris");
|
|
1708
|
+
if (!/^https:\/\//.test(url)) die("Only https:// URLs.");
|
|
1709
|
+
const wm = openWallet(await password(), approver(yes));
|
|
1710
|
+
const out = await wm.pay(url, { method: a.flags.post ? "POST" : "GET", input: inputOf(a, 1), network: net(a.flags.network) });
|
|
1711
|
+
if (out.paid) console.error(`\u2713 Paid $${out.paid.usd} on ${out.paid.network}${out.paid.tx ? ` \xB7 ${EXPLORER(out.paid.network)}/tx/${out.paid.tx}` : ""}`);
|
|
1712
|
+
return printResult(out.result);
|
|
1713
|
+
}
|
|
1714
|
+
case "balance": {
|
|
1715
|
+
const addr = keystoreAddress() ?? die("No wallet yet. Run `wafermap init`.");
|
|
1716
|
+
const { Wafermap: Wafermap2 } = await Promise.resolve().then(() => (init_core(), core_exports));
|
|
1717
|
+
const wm = new Wafermap2({ account: { ...privateKeyToAccount2(generatePrivateKey()), address: addr }, solanaAddress: keystoreSolana() ?? void 0, baseUrl: process.env.WAFERMAP_URL, rpc: { robinhood: process.env.WAFERMAP_RPC_ROBINHOOD, base: process.env.WAFERMAP_RPC_BASE, arbitrum: process.env.WAFERMAP_RPC_ARBITRUM, polygon: process.env.WAFERMAP_RPC_POLYGON, solana: process.env.WAFERMAP_RPC_SOLANA } });
|
|
1718
|
+
const b = await wm.balances();
|
|
1719
|
+
console.log(addr);
|
|
1720
|
+
for (const n of NETS) console.log(` ${n.padEnd(10)} ${b[n] ? `${Number(b[n].stable).toFixed(4)} ${b[n].symbol} ${Number(b[n].eth).toPrecision(4)} ${b[n].gas}` : n === "solana" && !wm.solanaAddress ? "run `wafermap address` once with WAFERMAP_PASSWORD set" : "unreadable (RPC did not answer)"}`);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
case "swap": {
|
|
1724
|
+
const [amount, from, to] = a._;
|
|
1725
|
+
if (!amount || !from || !to) die("Usage: wafermap swap <amount> <from> <to> e.g. wafermap swap 0.01 ETH USDG");
|
|
1726
|
+
const wm = openWallet(await password(), approver(yes));
|
|
1727
|
+
const q = await wm.swapQuote(from, to, amount, net(a.flags.network));
|
|
1728
|
+
console.log(`${q.network}: pay ${q.amountIn} ${q.tokenIn.symbol}${q.amountInUsd ? ` ($${q.amountInUsd.toFixed(2)})` : ""} \u2192 about ${q.amountOut} ${q.tokenOut.symbol}${q.amountOutUsd ? ` ($${q.amountOutUsd.toFixed(2)})` : ""} \xB7 cost ${q.impactPct ?? "?"}% \xB7 gas ~$${q.gasUsd?.toFixed(3) ?? "?"}`);
|
|
1729
|
+
const slip = a.flags.slippage !== void 0 ? Number(a.flags.slippage) : 1;
|
|
1730
|
+
if (!(slip > 0 && slip <= 5)) die("--slippage must be between 0 and 5 (percent)");
|
|
1731
|
+
const r = await wm.swap(q, { slippageBps: Math.round(slip * 100), step: (s) => console.error(`\u2026 ${s}`) });
|
|
1732
|
+
return console.log(`\u2713 Swapped \xB7 ${EXPLORER(q.network)}/tx/${r.tx}`);
|
|
1733
|
+
}
|
|
1734
|
+
case "keychain": {
|
|
1735
|
+
const b = backend() ?? die(`No OS keychain support on ${process.platform}. Use WAFERMAP_PASSWORD.`);
|
|
1736
|
+
const where = BACKEND_NAME[b];
|
|
1737
|
+
const what = a._[0];
|
|
1738
|
+
if (what === "on") {
|
|
1739
|
+
const pw2 = await password();
|
|
1740
|
+
loadKey(pw2);
|
|
1741
|
+
keychainSet(pw2);
|
|
1742
|
+
if (keychainGet() !== pw2) die(`The ${where} did not give the password back. Nothing changed for \`wafermap mcp\`.`);
|
|
1743
|
+
return console.log(`\u2713 The wallet password is in the ${where}. \`wafermap mcp\` reads it from there: remove WAFERMAP_PASSWORD from your MCP client's config and restart the client.`);
|
|
1744
|
+
}
|
|
1745
|
+
if (what === "off") {
|
|
1746
|
+
keychainClear();
|
|
1747
|
+
if (keychainGet() !== null) die(`The ${where} still holds the password. Remove the "wafermap" entry by hand.`);
|
|
1748
|
+
return console.log(`\u2713 Removed from the ${where}. \`wafermap mcp\` needs WAFERMAP_PASSWORD again.`);
|
|
1749
|
+
}
|
|
1750
|
+
if (what !== void 0) die("Use: wafermap keychain [on|off]");
|
|
1751
|
+
const pw = keychainGet();
|
|
1752
|
+
if (!pw) return console.log(`Off. \`wafermap mcp\` reads WAFERMAP_PASSWORD. Run \`wafermap keychain on\` to keep it in the ${where}.`);
|
|
1753
|
+
try {
|
|
1754
|
+
loadKey(pw);
|
|
1755
|
+
} catch {
|
|
1756
|
+
die(`On (${where}), but it no longer unlocks the wallet. Run \`wafermap keychain on\` again.`);
|
|
1757
|
+
}
|
|
1758
|
+
return console.log(`On (${where}). It unlocks the wallet.`);
|
|
1759
|
+
}
|
|
1760
|
+
case "mcp": {
|
|
1761
|
+
const { runMcp: runMcp2 } = await Promise.resolve().then(() => (init_mcp(), mcp_exports));
|
|
1762
|
+
return runMcp2();
|
|
1763
|
+
}
|
|
1764
|
+
default:
|
|
1765
|
+
die(`Unknown command "${cmd}". Run \`wafermap help\`.`);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
main().catch((e) => {
|
|
1769
|
+
if (e instanceof WafermapError && e.code === "declined") die("Cancelled. Nothing was paid.");
|
|
1770
|
+
die(e instanceof WafermapError ? `${e.message} (${e.code})` : e?.shortMessage ?? e?.message ?? String(e));
|
|
1771
|
+
});
|