@jaw.id/cli 0.1.26 → 0.2.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/dist/base-command.js +3 -1
- package/dist/base-command.js.map +1 -1
- package/dist/commands/config/set.js +140 -12
- package/dist/commands/config/set.js.map +1 -1
- package/dist/commands/config/show.js +3 -1
- package/dist/commands/config/show.js.map +1 -1
- package/dist/commands/config/write.js +6 -4
- package/dist/commands/config/write.js.map +1 -1
- package/dist/commands/disconnect.js +24 -10
- package/dist/commands/disconnect.js.map +1 -1
- package/dist/commands/mcp/index.js +2426 -94
- package/dist/commands/mcp/index.js.map +1 -1
- package/dist/commands/rpc/call.js +197 -45
- package/dist/commands/rpc/call.js.map +1 -1
- package/dist/commands/session/add.js +1547 -0
- package/dist/commands/session/add.js.map +1 -0
- package/dist/commands/session/revoke.js +181 -54
- package/dist/commands/session/revoke.js.map +1 -1
- package/dist/commands/session/setup.js +516 -65
- package/dist/commands/session/setup.js.map +1 -1
- package/dist/commands/session/status.js +315 -6
- package/dist/commands/session/status.js.map +1 -1
- package/dist/commands/version.js +3 -1
- package/dist/commands/version.js.map +1 -1
- package/dist/commands/x402/log.js +344 -0
- package/dist/commands/x402/log.js.map +1 -0
- package/dist/commands/x402/pay.js +2122 -0
- package/dist/commands/x402/pay.js.map +1 -0
- package/dist/commands/x402/status.js +1047 -0
- package/dist/commands/x402/status.js.map +1 -0
- package/dist/index.js +41 -14
- package/dist/index.js.map +1 -1
- package/dist/lib/bridge-singleton.js +41 -14
- package/dist/lib/bridge-singleton.js.map +1 -1
- package/dist/lib/config.js +26 -3
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/keystore.js +13 -2
- package/dist/lib/keystore.js.map +1 -1
- package/dist/lib/paths.js +3 -1
- package/dist/lib/paths.js.map +1 -1
- package/dist/lib/payment-lock.js +121 -0
- package/dist/lib/payment-lock.js.map +1 -0
- package/dist/lib/session-bridge.js +148 -24
- package/dist/lib/session-bridge.js.map +1 -1
- package/dist/lib/session-config.js +78 -11
- package/dist/lib/session-config.js.map +1 -1
- package/dist/lib/terminal.js +22 -0
- package/dist/lib/terminal.js.map +1 -0
- package/dist/lib/validation.js +3 -3
- package/dist/lib/validation.js.map +1 -1
- package/dist/lib/ws-bridge.js +22 -10
- package/dist/lib/ws-bridge.js.map +1 -1
- package/dist/mcp/handlers/config.js +73 -6
- package/dist/mcp/handlers/config.js.map +1 -1
- package/dist/mcp/handlers/daemon.js +43 -12
- package/dist/mcp/handlers/daemon.js.map +1 -1
- package/dist/mcp/handlers/resources.js +119 -0
- package/dist/mcp/handlers/resources.js.map +1 -1
- package/dist/mcp/handlers/rpc.js +269 -60
- package/dist/mcp/handlers/rpc.js.map +1 -1
- package/dist/mcp/helpers.js +50 -3
- package/dist/mcp/helpers.js.map +1 -1
- package/dist/mcp/server.js +2426 -94
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools.js +43 -3
- package/dist/mcp/tools.js.map +1 -1
- package/dist/x402/log-view.js +160 -0
- package/dist/x402/log-view.js.map +1 -0
- package/dist/x402/status-report.js +90 -0
- package/dist/x402/status-report.js.map +1 -0
- package/oclif.manifest.json +405 -11
- package/package.json +5 -2
|
@@ -0,0 +1,1547 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import * as crypto from 'crypto';
|
|
6
|
+
import WebSocket from 'ws';
|
|
7
|
+
import { parseAbi, parseUnits, formatUnits, erc20Abi, zeroAddress, toFunctionSelector, createPublicClient, http } from 'viem';
|
|
8
|
+
import { polygonAmoy, polygon, baseSepolia, base } from 'viem/chains';
|
|
9
|
+
|
|
10
|
+
// src/commands/session/add.ts
|
|
11
|
+
var JAW_DIR = path.join(os.homedir(), ".jaw");
|
|
12
|
+
var PATHS = {
|
|
13
|
+
root: JAW_DIR,
|
|
14
|
+
config: path.join(JAW_DIR, "config.json"),
|
|
15
|
+
session: path.join(JAW_DIR, "session.json"),
|
|
16
|
+
relay: path.join(JAW_DIR, "relay.json"),
|
|
17
|
+
keystore: path.join(JAW_DIR, "keystore.json"),
|
|
18
|
+
sessionConfig: path.join(JAW_DIR, "session-config.json"),
|
|
19
|
+
x402Log: path.join(JAW_DIR, "x402-log.jsonl"),
|
|
20
|
+
paymentLock: path.join(JAW_DIR, "x402-payment.lock")
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// src/lib/validation.ts
|
|
24
|
+
var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
25
|
+
var SELECTOR_RE = /^0x[0-9a-fA-F]{8}$/;
|
|
26
|
+
var ALLOWANCE_RE = /^(0x[0-9a-fA-F]+|[0-9]+)$/;
|
|
27
|
+
var VALID_SPEND_UNITS = /* @__PURE__ */ new Set(["minute", "hour", "day", "week", "month", "year", "forever"]);
|
|
28
|
+
function parsePermissionsConfig(raw) {
|
|
29
|
+
const errors = [];
|
|
30
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
31
|
+
throw new Error("Invalid permissions:\n - Must be an object");
|
|
32
|
+
}
|
|
33
|
+
const obj = raw;
|
|
34
|
+
const calls = obj.calls;
|
|
35
|
+
const spends = obj.spends;
|
|
36
|
+
if (!calls && !spends) {
|
|
37
|
+
throw new Error('Invalid permissions:\n - Must include at least "calls" or "spends"');
|
|
38
|
+
}
|
|
39
|
+
if (calls !== void 0) {
|
|
40
|
+
if (!Array.isArray(calls) || calls.length === 0) {
|
|
41
|
+
errors.push("calls: Must be a non-empty array");
|
|
42
|
+
} else {
|
|
43
|
+
for (let i = 0; i < calls.length; i++) {
|
|
44
|
+
const c = calls[i];
|
|
45
|
+
if (!c || typeof c !== "object") {
|
|
46
|
+
errors.push(`calls.${i}: Must be an object`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (typeof c.target !== "string" || !ADDRESS_RE.test(c.target)) {
|
|
50
|
+
errors.push(`calls.${i}.target: Must be a valid 0x address (40 hex chars)`);
|
|
51
|
+
}
|
|
52
|
+
if (c.selector !== void 0 && (typeof c.selector !== "string" || !SELECTOR_RE.test(c.selector))) {
|
|
53
|
+
errors.push(`calls.${i}.selector: Must be a 4-byte hex selector (e.g. 0xa9059cbb)`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (spends !== void 0) {
|
|
59
|
+
if (!Array.isArray(spends) || spends.length === 0) {
|
|
60
|
+
errors.push("spends: Must be a non-empty array");
|
|
61
|
+
} else {
|
|
62
|
+
for (let i = 0; i < spends.length; i++) {
|
|
63
|
+
const s = spends[i];
|
|
64
|
+
if (!s || typeof s !== "object") {
|
|
65
|
+
errors.push(`spends.${i}: Must be an object`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (typeof s.token !== "string" || !ADDRESS_RE.test(s.token)) {
|
|
69
|
+
errors.push(`spends.${i}.token: Must be a valid 0x address (40 hex chars)`);
|
|
70
|
+
}
|
|
71
|
+
if (typeof s.allowance !== "string" || !ALLOWANCE_RE.test(s.allowance)) {
|
|
72
|
+
errors.push(`spends.${i}.allowance: Must be a decimal or 0x hex integer`);
|
|
73
|
+
}
|
|
74
|
+
if (typeof s.unit !== "string" || !VALID_SPEND_UNITS.has(s.unit)) {
|
|
75
|
+
errors.push(`spends.${i}.unit: Must be one of: ${[...VALID_SPEND_UNITS].join(", ")}`);
|
|
76
|
+
}
|
|
77
|
+
if (s.multiplier !== void 0 && (!Number.isInteger(s.multiplier) || s.multiplier < 1)) {
|
|
78
|
+
errors.push(`spends.${i}.multiplier: Must be a positive integer`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (errors.length > 0) {
|
|
84
|
+
throw new Error(`Invalid permissions:
|
|
85
|
+
${errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
86
|
+
}
|
|
87
|
+
return raw;
|
|
88
|
+
}
|
|
89
|
+
function isValidKeysUrl(url) {
|
|
90
|
+
try {
|
|
91
|
+
const parsed = new URL(url);
|
|
92
|
+
const isTrustedHost = parsed.hostname.endsWith(".jaw.id") || parsed.hostname === "jaw.id" || parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
93
|
+
const isSecure = parsed.protocol === "https:" || parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
94
|
+
return isTrustedHost && isSecure;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function isValidRelayUrl(url) {
|
|
100
|
+
try {
|
|
101
|
+
const parsed = new URL(url);
|
|
102
|
+
const isTrustedHost = parsed.hostname.endsWith(".jaw.id") || parsed.hostname === "jaw.id" || parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
103
|
+
const isSecure = parsed.protocol === "wss:" || parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
104
|
+
const isWebSocket = parsed.protocol === "wss:" || parsed.protocol === "ws:";
|
|
105
|
+
return isTrustedHost && isSecure && isWebSocket;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/lib/config.ts
|
|
112
|
+
function ensureDir(dir) {
|
|
113
|
+
fs.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
114
|
+
fs.chmodSync(dir, 448);
|
|
115
|
+
}
|
|
116
|
+
function migrateConfig(config) {
|
|
117
|
+
if (config.paymasterUrl && !config.paymasters) {
|
|
118
|
+
const chainId = config.defaultChain ?? 1;
|
|
119
|
+
config.paymasters = { [chainId]: { url: config.paymasterUrl } };
|
|
120
|
+
delete config.paymasterUrl;
|
|
121
|
+
saveConfig(config);
|
|
122
|
+
}
|
|
123
|
+
return config;
|
|
124
|
+
}
|
|
125
|
+
function loadConfig() {
|
|
126
|
+
if (!fs.existsSync(PATHS.config)) {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
const raw = fs.readFileSync(PATHS.config, "utf-8");
|
|
130
|
+
try {
|
|
131
|
+
const config = JSON.parse(raw);
|
|
132
|
+
return migrateConfig(config);
|
|
133
|
+
} catch {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Config file at ${PATHS.config} is not valid JSON. Run \`jaw config set apiKey=<key>\` to reset it.`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function saveConfig(config) {
|
|
140
|
+
ensureDir(PATHS.root);
|
|
141
|
+
fs.writeFileSync(PATHS.config, JSON.stringify(config, null, 2) + "\n", {
|
|
142
|
+
encoding: "utf-8",
|
|
143
|
+
mode: 384
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/lib/output.ts
|
|
148
|
+
function formatOutput(data, format) {
|
|
149
|
+
if (format === "json") {
|
|
150
|
+
return JSON.stringify(data, replaceBigInt, 2);
|
|
151
|
+
}
|
|
152
|
+
return formatHuman(data);
|
|
153
|
+
}
|
|
154
|
+
function replaceBigInt(_key, value) {
|
|
155
|
+
if (typeof value === "bigint") {
|
|
156
|
+
return value.toString();
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
function formatHuman(data, indent = 0) {
|
|
161
|
+
if (data === null || data === void 0) {
|
|
162
|
+
return "null";
|
|
163
|
+
}
|
|
164
|
+
if (typeof data === "string" || typeof data === "number" || typeof data === "boolean" || typeof data === "bigint") {
|
|
165
|
+
return String(data);
|
|
166
|
+
}
|
|
167
|
+
if (Array.isArray(data)) {
|
|
168
|
+
if (data.length === 0) return "(empty)";
|
|
169
|
+
return data.map((item, i) => `${i + 1}. ${formatHuman(item, indent + 2)}`).join("\n");
|
|
170
|
+
}
|
|
171
|
+
if (typeof data === "object") {
|
|
172
|
+
const entries = Object.entries(data);
|
|
173
|
+
if (entries.length === 0) return "(empty)";
|
|
174
|
+
const pad = " ".repeat(indent);
|
|
175
|
+
const maxKeyLen = Math.max(...entries.map(([k]) => k.length));
|
|
176
|
+
return entries.map(([key, val]) => {
|
|
177
|
+
const paddedKey = key.padEnd(maxKeyLen);
|
|
178
|
+
const valStr = typeof val === "object" && val !== null ? "\n" + formatHuman(val, indent + 2) : String(val);
|
|
179
|
+
return `${pad}${paddedKey} ${valStr}`;
|
|
180
|
+
}).join("\n");
|
|
181
|
+
}
|
|
182
|
+
return String(data);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/base-command.ts
|
|
186
|
+
var BaseCommand = class extends Command {
|
|
187
|
+
static baseFlags = {
|
|
188
|
+
output: Flags.string({
|
|
189
|
+
char: "o",
|
|
190
|
+
description: "Output format",
|
|
191
|
+
options: ["json", "human"],
|
|
192
|
+
default: "human",
|
|
193
|
+
env: "JAW_OUTPUT"
|
|
194
|
+
}),
|
|
195
|
+
chain: Flags.integer({
|
|
196
|
+
char: "c",
|
|
197
|
+
description: "Chain ID",
|
|
198
|
+
env: "JAW_CHAIN_ID"
|
|
199
|
+
}),
|
|
200
|
+
"api-key": Flags.string({
|
|
201
|
+
description: "JAW API key",
|
|
202
|
+
env: "JAW_API_KEY"
|
|
203
|
+
}),
|
|
204
|
+
yes: Flags.boolean({
|
|
205
|
+
char: "y",
|
|
206
|
+
description: "Skip confirmations (for AI agents)",
|
|
207
|
+
default: false
|
|
208
|
+
}),
|
|
209
|
+
quiet: Flags.boolean({
|
|
210
|
+
char: "q",
|
|
211
|
+
description: "Suppress non-essential output",
|
|
212
|
+
default: false
|
|
213
|
+
})
|
|
214
|
+
};
|
|
215
|
+
resolveApiKey(flags) {
|
|
216
|
+
const apiKey = flags["api-key"] ?? loadConfig().apiKey;
|
|
217
|
+
if (!apiKey) {
|
|
218
|
+
this.error("API key required. Set via --api-key, JAW_API_KEY env, or `jaw config set apiKey <key>`");
|
|
219
|
+
}
|
|
220
|
+
return apiKey;
|
|
221
|
+
}
|
|
222
|
+
resolveChainId(flags) {
|
|
223
|
+
const chainId = flags.chain ?? loadConfig().defaultChain;
|
|
224
|
+
if (!chainId) {
|
|
225
|
+
this.error("Chain ID required. Set via --chain, JAW_CHAIN_ID env, or `jaw config set defaultChain <id>`");
|
|
226
|
+
}
|
|
227
|
+
return chainId;
|
|
228
|
+
}
|
|
229
|
+
outputResult(data, format) {
|
|
230
|
+
const output = formatOutput(data, format);
|
|
231
|
+
this.log(output);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// src/lib/crypto.ts
|
|
236
|
+
var subtle = globalThis.crypto.subtle;
|
|
237
|
+
async function generateKeyPair() {
|
|
238
|
+
return subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveKey"]);
|
|
239
|
+
}
|
|
240
|
+
async function deriveSharedSecret(privateKey, peerPublicKey) {
|
|
241
|
+
return subtle.deriveKey(
|
|
242
|
+
{ name: "ECDH", public: peerPublicKey },
|
|
243
|
+
privateKey,
|
|
244
|
+
{ name: "AES-GCM", length: 256 },
|
|
245
|
+
false,
|
|
246
|
+
["encrypt", "decrypt"]
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
async function encryptMessage(sharedSecret, payload) {
|
|
250
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
251
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
252
|
+
const cipherBuf = await subtle.encrypt({ name: "AES-GCM", iv }, sharedSecret, plaintext);
|
|
253
|
+
return {
|
|
254
|
+
iv: bufferToBase64(iv),
|
|
255
|
+
ciphertext: bufferToBase64(new Uint8Array(cipherBuf))
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
async function decryptMessage(sharedSecret, envelope) {
|
|
259
|
+
const iv = Buffer.from(envelope.iv, "base64");
|
|
260
|
+
const ciphertext = Buffer.from(envelope.ciphertext, "base64");
|
|
261
|
+
const plainBuf = await subtle.decrypt({ name: "AES-GCM", iv }, sharedSecret, ciphertext);
|
|
262
|
+
return JSON.parse(new TextDecoder().decode(plainBuf));
|
|
263
|
+
}
|
|
264
|
+
async function exportKeyToHex(type, key) {
|
|
265
|
+
const format = type === "private" ? "pkcs8" : "spki";
|
|
266
|
+
const buf = await subtle.exportKey(format, key);
|
|
267
|
+
return bytesToHex(new Uint8Array(buf));
|
|
268
|
+
}
|
|
269
|
+
async function importKeyFromHex(type, hex) {
|
|
270
|
+
const format = type === "private" ? "pkcs8" : "spki";
|
|
271
|
+
return subtle.importKey(
|
|
272
|
+
format,
|
|
273
|
+
Buffer.from(hexToBytes(hex)),
|
|
274
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
275
|
+
true,
|
|
276
|
+
type === "private" ? ["deriveKey"] : []
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
function bytesToHex(bytes) {
|
|
280
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
281
|
+
}
|
|
282
|
+
function hexToBytes(hex) {
|
|
283
|
+
if (hex.length % 2 !== 0) throw new Error("Invalid hex: odd length");
|
|
284
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
285
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
286
|
+
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
287
|
+
}
|
|
288
|
+
return bytes;
|
|
289
|
+
}
|
|
290
|
+
function bufferToBase64(buf) {
|
|
291
|
+
return Buffer.from(buf).toString("base64");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/lib/ws-bridge.ts
|
|
295
|
+
function buildInitPayload(config) {
|
|
296
|
+
return {
|
|
297
|
+
type: "init",
|
|
298
|
+
apiKey: config.apiKey,
|
|
299
|
+
chainId: config.chainId,
|
|
300
|
+
ens: config.ens,
|
|
301
|
+
paymasterUrl: config.paymasterUrl,
|
|
302
|
+
...config.paymasterUrl && config.paymasterContext ? { paymasterContext: config.paymasterContext } : {}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
306
|
+
var DEFAULT_CONNECT_TIMEOUT_MS = 3e4;
|
|
307
|
+
var MAX_MESSAGE_BYTES = 5 * 1024 * 1024;
|
|
308
|
+
var BROWSER_REOPEN_COOLDOWN_MS = 5e3;
|
|
309
|
+
var MAX_RECONNECT_ATTEMPTS = 3;
|
|
310
|
+
var RECONNECT_BASE_DELAY_MS = 1e3;
|
|
311
|
+
var WSBridge = class {
|
|
312
|
+
relayUrl;
|
|
313
|
+
session;
|
|
314
|
+
timeout;
|
|
315
|
+
connectTimeout;
|
|
316
|
+
config;
|
|
317
|
+
privateKeyHex;
|
|
318
|
+
publicKeyHex;
|
|
319
|
+
peerPublicKeyHex;
|
|
320
|
+
sharedSecret = null;
|
|
321
|
+
ws = null;
|
|
322
|
+
disposed = false;
|
|
323
|
+
// Auto-reopen browser state
|
|
324
|
+
onBrowserNeeded;
|
|
325
|
+
onPeerKeyChanged;
|
|
326
|
+
lastBrowserOpenTime = 0;
|
|
327
|
+
// Reconnection state
|
|
328
|
+
reconnectAttempts = 0;
|
|
329
|
+
/** Updated after key exchange — caller should persist this. */
|
|
330
|
+
get peerPublicKey() {
|
|
331
|
+
return this.peerPublicKeyHex;
|
|
332
|
+
}
|
|
333
|
+
constructor(options) {
|
|
334
|
+
this.relayUrl = options.relayUrl;
|
|
335
|
+
this.session = options.session;
|
|
336
|
+
this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
337
|
+
this.connectTimeout = options.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
338
|
+
this.config = options.config;
|
|
339
|
+
this.privateKeyHex = options.privateKeyHex;
|
|
340
|
+
this.publicKeyHex = options.publicKeyHex;
|
|
341
|
+
this.peerPublicKeyHex = options.peerPublicKeyHex;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Connect to the relay and wait for the browser to be ready.
|
|
345
|
+
*
|
|
346
|
+
* @param onBrowserNeeded — called when the relay reports no browser connected.
|
|
347
|
+
* @param onPeerKeyChanged — called when a key_exchange updates the peer key.
|
|
348
|
+
*/
|
|
349
|
+
async connect(onBrowserNeeded, onPeerKeyChanged) {
|
|
350
|
+
this.onBrowserNeeded = onBrowserNeeded;
|
|
351
|
+
this.onPeerKeyChanged = onPeerKeyChanged;
|
|
352
|
+
if (this.peerPublicKeyHex) {
|
|
353
|
+
await this.deriveSecret();
|
|
354
|
+
}
|
|
355
|
+
return this.connectInternal(onBrowserNeeded, onPeerKeyChanged);
|
|
356
|
+
}
|
|
357
|
+
async connectInternal(onBrowserNeeded, onPeerKeyChanged) {
|
|
358
|
+
return new Promise((resolve, reject) => {
|
|
359
|
+
const url = `${this.relayUrl}?session=${encodeURIComponent(this.session)}&role=cli`;
|
|
360
|
+
const ws = new WebSocket(url);
|
|
361
|
+
let browserOpened = false;
|
|
362
|
+
let resolved = false;
|
|
363
|
+
let expectingKeyExchange = !this.peerPublicKeyHex;
|
|
364
|
+
const timer = setTimeout(() => {
|
|
365
|
+
ws.close();
|
|
366
|
+
reject(
|
|
367
|
+
new Error(
|
|
368
|
+
`Browser did not connect within ${Math.round(this.connectTimeout / 1e3)}s.
|
|
369
|
+
Run \`jaw disconnect\` then try again, or raise JAW_BRIDGE_TIMEOUT_MS.`
|
|
370
|
+
)
|
|
371
|
+
);
|
|
372
|
+
}, this.connectTimeout);
|
|
373
|
+
const sendEncryptedInit = async () => {
|
|
374
|
+
if (!this.sharedSecret) return;
|
|
375
|
+
const envelope = await encryptMessage(this.sharedSecret, buildInitPayload(this.config));
|
|
376
|
+
this.sendRaw(ws, JSON.stringify({ type: "encrypted", ...envelope }));
|
|
377
|
+
};
|
|
378
|
+
const waitForReady = () => {
|
|
379
|
+
const readyTimer = setTimeout(() => {
|
|
380
|
+
ws.close();
|
|
381
|
+
reject(new Error("Browser SDK did not become ready in time."));
|
|
382
|
+
}, 15e3);
|
|
383
|
+
const onMsg = async (data) => {
|
|
384
|
+
const msg = safeParse(data);
|
|
385
|
+
if (!msg) return;
|
|
386
|
+
if (msg.type === "encrypted" && this.sharedSecret) {
|
|
387
|
+
try {
|
|
388
|
+
const inner = await decryptMessage(this.sharedSecret, msg);
|
|
389
|
+
if (inner.type === "ready") {
|
|
390
|
+
clearTimeout(readyTimer);
|
|
391
|
+
ws.off("message", onMsg);
|
|
392
|
+
this.reconnectAttempts = 0;
|
|
393
|
+
resolve();
|
|
394
|
+
}
|
|
395
|
+
} catch {
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
ws.on("message", onMsg);
|
|
400
|
+
};
|
|
401
|
+
const onBrowserReady = async () => {
|
|
402
|
+
if (resolved) return;
|
|
403
|
+
resolved = true;
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
waitForReady();
|
|
406
|
+
await sendEncryptedInit();
|
|
407
|
+
};
|
|
408
|
+
ws.on("open", () => {
|
|
409
|
+
this.ws = ws;
|
|
410
|
+
});
|
|
411
|
+
ws.on("message", async (data) => {
|
|
412
|
+
const msg = safeParse(data);
|
|
413
|
+
if (!msg) return;
|
|
414
|
+
if (msg.type === "status") {
|
|
415
|
+
if (msg.browserConnected) {
|
|
416
|
+
if (this.sharedSecret) {
|
|
417
|
+
await onBrowserReady();
|
|
418
|
+
} else {
|
|
419
|
+
expectingKeyExchange = true;
|
|
420
|
+
}
|
|
421
|
+
} else if (!browserOpened && onBrowserNeeded) {
|
|
422
|
+
browserOpened = true;
|
|
423
|
+
expectingKeyExchange = true;
|
|
424
|
+
onBrowserNeeded().catch(() => {
|
|
425
|
+
});
|
|
426
|
+
} else if (!onBrowserNeeded) {
|
|
427
|
+
clearTimeout(timer);
|
|
428
|
+
ws.close();
|
|
429
|
+
reject(new Error("Browser not connected \u2014 relay session is stale."));
|
|
430
|
+
}
|
|
431
|
+
} else if (msg.type === "browser_connected") {
|
|
432
|
+
expectingKeyExchange = true;
|
|
433
|
+
} else if (msg.type === "browser_disconnected") {
|
|
434
|
+
this.handleBrowserDisconnect();
|
|
435
|
+
} else if (msg.type === "key_exchange" && expectingKeyExchange) {
|
|
436
|
+
expectingKeyExchange = false;
|
|
437
|
+
const peerKey = msg.publicKey;
|
|
438
|
+
this.peerPublicKeyHex = peerKey;
|
|
439
|
+
await this.deriveSecret();
|
|
440
|
+
onPeerKeyChanged?.(peerKey);
|
|
441
|
+
await onBrowserReady();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
ws.on("error", (err) => {
|
|
445
|
+
clearTimeout(timer);
|
|
446
|
+
reject(err);
|
|
447
|
+
});
|
|
448
|
+
ws.on("close", () => {
|
|
449
|
+
clearTimeout(timer);
|
|
450
|
+
if (!this.disposed) {
|
|
451
|
+
this.handleRelayDisconnect();
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Send an encrypted RPC request through the relay to the browser SDK.
|
|
458
|
+
*/
|
|
459
|
+
async request(method, params) {
|
|
460
|
+
const ws = this.ws;
|
|
461
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
462
|
+
throw new Error("Not connected to relay");
|
|
463
|
+
}
|
|
464
|
+
if (!this.sharedSecret) {
|
|
465
|
+
throw new Error("No shared secret \u2014 key exchange not completed");
|
|
466
|
+
}
|
|
467
|
+
const id = crypto.randomUUID();
|
|
468
|
+
const envelope = await encryptMessage(this.sharedSecret, {
|
|
469
|
+
type: "rpc_request",
|
|
470
|
+
id,
|
|
471
|
+
method,
|
|
472
|
+
params
|
|
473
|
+
});
|
|
474
|
+
const serialized = JSON.stringify({ type: "encrypted", ...envelope });
|
|
475
|
+
assertMessageSize(serialized, method);
|
|
476
|
+
return new Promise((resolve, reject) => {
|
|
477
|
+
const timer = setTimeout(() => {
|
|
478
|
+
reject(
|
|
479
|
+
new Error(`Request timed out after ${this.timeout / 1e3}s. Did you complete the action in the browser?`)
|
|
480
|
+
);
|
|
481
|
+
this.close();
|
|
482
|
+
}, this.timeout);
|
|
483
|
+
const onMessage = async (data) => {
|
|
484
|
+
const msg = safeParse(data);
|
|
485
|
+
if (!msg || msg.type !== "encrypted" || !this.sharedSecret) return;
|
|
486
|
+
try {
|
|
487
|
+
const inner = await decryptMessage(this.sharedSecret, msg);
|
|
488
|
+
if (inner.type === "rpc_response" && inner.id === id) {
|
|
489
|
+
clearTimeout(timer);
|
|
490
|
+
ws.off("message", onMessage);
|
|
491
|
+
if (inner.success) {
|
|
492
|
+
resolve(inner.data);
|
|
493
|
+
} else {
|
|
494
|
+
const err = inner.error;
|
|
495
|
+
reject(new Error(err ? `[${err.code}] ${err.message}` : "Request failed"));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
} catch {
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
ws.on("message", onMessage);
|
|
502
|
+
this.sendRaw(ws, serialized);
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
async shutdown() {
|
|
506
|
+
this.disposed = true;
|
|
507
|
+
if (this.ws?.readyState === WebSocket.OPEN && this.sharedSecret) {
|
|
508
|
+
try {
|
|
509
|
+
const envelope = await encryptMessage(this.sharedSecret, {
|
|
510
|
+
type: "shutdown"
|
|
511
|
+
});
|
|
512
|
+
this.sendRaw(this.ws, JSON.stringify({ type: "encrypted", ...envelope }));
|
|
513
|
+
} catch {
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
this.close();
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Connect to relay and send shutdown directly — no init/ready handshake.
|
|
520
|
+
* Used by `jaw disconnect` when we just need to tell the browser to close.
|
|
521
|
+
*/
|
|
522
|
+
async connectAndShutdown() {
|
|
523
|
+
if (!this.peerPublicKeyHex) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
this.disposed = true;
|
|
527
|
+
await this.deriveSecret();
|
|
528
|
+
return new Promise((resolve) => {
|
|
529
|
+
const url = `${this.relayUrl}?session=${encodeURIComponent(this.session)}&role=cli`;
|
|
530
|
+
const ws = new WebSocket(url);
|
|
531
|
+
const timer = setTimeout(() => {
|
|
532
|
+
try {
|
|
533
|
+
ws.close();
|
|
534
|
+
} catch {
|
|
535
|
+
}
|
|
536
|
+
resolve();
|
|
537
|
+
}, 3e3);
|
|
538
|
+
ws.on("open", async () => {
|
|
539
|
+
this.ws = ws;
|
|
540
|
+
try {
|
|
541
|
+
await this.shutdown();
|
|
542
|
+
} catch {
|
|
543
|
+
}
|
|
544
|
+
clearTimeout(timer);
|
|
545
|
+
resolve();
|
|
546
|
+
});
|
|
547
|
+
ws.on("error", () => {
|
|
548
|
+
clearTimeout(timer);
|
|
549
|
+
resolve();
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
close() {
|
|
554
|
+
this.disposed = true;
|
|
555
|
+
if (this.ws) {
|
|
556
|
+
try {
|
|
557
|
+
this.ws.close();
|
|
558
|
+
} catch {
|
|
559
|
+
}
|
|
560
|
+
this.ws = null;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Auto-reopen browser when browser_disconnected is received from relay.
|
|
565
|
+
* Respects a cooldown to prevent rapid re-opening.
|
|
566
|
+
*/
|
|
567
|
+
handleBrowserDisconnect() {
|
|
568
|
+
if (this.disposed || !this.onBrowserNeeded) return;
|
|
569
|
+
const now = Date.now();
|
|
570
|
+
if (now - this.lastBrowserOpenTime < BROWSER_REOPEN_COOLDOWN_MS) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
this.lastBrowserOpenTime = now;
|
|
574
|
+
this.sharedSecret = null;
|
|
575
|
+
this.peerPublicKeyHex = null;
|
|
576
|
+
this.onBrowserNeeded().catch(() => {
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Attempt to reconnect to the relay with exponential backoff
|
|
581
|
+
* when the WebSocket connection drops unexpectedly.
|
|
582
|
+
*/
|
|
583
|
+
handleRelayDisconnect() {
|
|
584
|
+
if (this.disposed) return;
|
|
585
|
+
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return;
|
|
586
|
+
const delay = RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts);
|
|
587
|
+
this.reconnectAttempts++;
|
|
588
|
+
setTimeout(() => {
|
|
589
|
+
if (this.disposed) return;
|
|
590
|
+
this.connectInternal(this.onBrowserNeeded, this.onPeerKeyChanged).catch(() => {
|
|
591
|
+
});
|
|
592
|
+
}, delay);
|
|
593
|
+
}
|
|
594
|
+
/** Send a raw string over the WebSocket, enforcing message size limits. */
|
|
595
|
+
sendRaw(ws, data) {
|
|
596
|
+
ws.send(data);
|
|
597
|
+
}
|
|
598
|
+
async deriveSecret() {
|
|
599
|
+
if (!this.peerPublicKeyHex) return;
|
|
600
|
+
const privateKey = await importKeyFromHex("private", this.privateKeyHex);
|
|
601
|
+
const peerPublicKey = await importKeyFromHex("public", this.peerPublicKeyHex);
|
|
602
|
+
this.sharedSecret = await deriveSharedSecret(privateKey, peerPublicKey);
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
function assertMessageSize(serialized, method) {
|
|
606
|
+
const byteLength = Buffer.byteLength(serialized, "utf-8");
|
|
607
|
+
if (byteLength > MAX_MESSAGE_BYTES) {
|
|
608
|
+
const sizeMB = (byteLength / (1024 * 1024)).toFixed(2);
|
|
609
|
+
throw new Error(
|
|
610
|
+
`Message for ${method} is too large (${sizeMB} MB, limit ${MAX_MESSAGE_BYTES / (1024 * 1024)} MB). Try reducing the number of calls in your batch.`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
function safeParse(data) {
|
|
615
|
+
try {
|
|
616
|
+
return JSON.parse(data.toString());
|
|
617
|
+
} catch {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
function loadRelaySession() {
|
|
622
|
+
try {
|
|
623
|
+
if (!fs.existsSync(PATHS.relay)) return null;
|
|
624
|
+
const raw = fs.readFileSync(PATHS.relay, "utf-8");
|
|
625
|
+
const parsed = JSON.parse(raw);
|
|
626
|
+
if (!parsed.session || !parsed.relayUrl || !parsed.privateKey || !parsed.publicKey) {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
return parsed;
|
|
630
|
+
} catch {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function saveRelaySession(info) {
|
|
635
|
+
ensureDir(PATHS.root);
|
|
636
|
+
fs.writeFileSync(PATHS.relay, JSON.stringify(info, null, 2) + "\n", {
|
|
637
|
+
encoding: "utf-8",
|
|
638
|
+
mode: 384
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
function deleteRelaySession() {
|
|
642
|
+
try {
|
|
643
|
+
if (fs.existsSync(PATHS.relay)) fs.unlinkSync(PATHS.relay);
|
|
644
|
+
} catch {
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// src/lib/bridge-singleton.ts
|
|
649
|
+
var DEFAULT_KEYS_URL = "https://keys.jaw.id";
|
|
650
|
+
var DEFAULT_RELAY_URL = "wss://relay.jaw.id";
|
|
651
|
+
async function getBridge(options) {
|
|
652
|
+
const config = loadConfig();
|
|
653
|
+
const envTimeout = Number(process.env["JAW_BRIDGE_TIMEOUT_MS"]);
|
|
654
|
+
const fromEnv = Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : void 0;
|
|
655
|
+
const timeout = options.timeout ?? fromEnv;
|
|
656
|
+
const connectTimeout = options.connectTimeout ?? fromEnv;
|
|
657
|
+
const keysUrl = options.keysUrl ?? config.keysUrl ?? DEFAULT_KEYS_URL;
|
|
658
|
+
const relayUrl = options.relayUrl ?? config.relayUrl ?? DEFAULT_RELAY_URL;
|
|
659
|
+
const chainId = options.chainId ?? config.defaultChain ?? 1;
|
|
660
|
+
if (!isValidKeysUrl(keysUrl)) {
|
|
661
|
+
throw new Error(`Untrusted keysUrl: ${keysUrl}. Must be a *.jaw.id domain (HTTPS) or localhost.`);
|
|
662
|
+
}
|
|
663
|
+
if (!isValidRelayUrl(relayUrl)) {
|
|
664
|
+
throw new Error(`Untrusted relayUrl: ${relayUrl}. Must be wss://*.jaw.id or ws://localhost.`);
|
|
665
|
+
}
|
|
666
|
+
let relaySession = loadRelaySession();
|
|
667
|
+
if (relaySession && relaySession.relayUrl === relayUrl && relaySession.peerPublicKey) {
|
|
668
|
+
try {
|
|
669
|
+
return await connectBridge({ ...options, timeout }, relaySession, chainId, keysUrl, relayUrl, false);
|
|
670
|
+
} catch {
|
|
671
|
+
deleteRelaySession();
|
|
672
|
+
relaySession = null;
|
|
673
|
+
}
|
|
674
|
+
} else if (relaySession) {
|
|
675
|
+
deleteRelaySession();
|
|
676
|
+
}
|
|
677
|
+
const session = await createNewSession(relayUrl);
|
|
678
|
+
saveRelaySession(session);
|
|
679
|
+
return await connectBridge({ ...options, timeout, connectTimeout }, session, chainId, keysUrl, relayUrl, true);
|
|
680
|
+
}
|
|
681
|
+
async function createNewSession(relayUrl) {
|
|
682
|
+
const kp = await generateKeyPair();
|
|
683
|
+
const privateKey = await exportKeyToHex("private", kp.privateKey);
|
|
684
|
+
const publicKey = await exportKeyToHex("public", kp.publicKey);
|
|
685
|
+
return {
|
|
686
|
+
session: crypto.randomUUID(),
|
|
687
|
+
relayUrl,
|
|
688
|
+
privateKey,
|
|
689
|
+
publicKey,
|
|
690
|
+
peerPublicKey: null,
|
|
691
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
async function connectBridge(options, relaySession, chainId, keysUrl, relayUrl, openBrowser) {
|
|
695
|
+
const config = loadConfig();
|
|
696
|
+
const paymaster = config.paymasters?.[chainId];
|
|
697
|
+
const bridge = new WSBridge({
|
|
698
|
+
relayUrl,
|
|
699
|
+
session: relaySession.session,
|
|
700
|
+
timeout: options.timeout,
|
|
701
|
+
connectTimeout: options.connectTimeout,
|
|
702
|
+
config: {
|
|
703
|
+
apiKey: options.apiKey,
|
|
704
|
+
chainId,
|
|
705
|
+
ens: options.ens ?? config.ens,
|
|
706
|
+
paymasterUrl: paymaster?.url,
|
|
707
|
+
paymasterContext: paymaster?.context
|
|
708
|
+
},
|
|
709
|
+
privateKeyHex: relaySession.privateKey,
|
|
710
|
+
publicKeyHex: relaySession.publicKey,
|
|
711
|
+
peerPublicKeyHex: relaySession.peerPublicKey
|
|
712
|
+
});
|
|
713
|
+
await bridge.connect(
|
|
714
|
+
// onBrowserNeeded — only open a browser for new sessions
|
|
715
|
+
openBrowser ? async () => {
|
|
716
|
+
const bridgeUrl = buildBridgeUrl(keysUrl, relaySession.session, relayUrl, relaySession.publicKey);
|
|
717
|
+
if (process.env["JAW_NO_BROWSER"]) {
|
|
718
|
+
process.stderr.write(`Open this URL to approve:
|
|
719
|
+
${bridgeUrl}
|
|
720
|
+
`);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const { default: open } = await import('open');
|
|
724
|
+
await open(bridgeUrl);
|
|
725
|
+
} : void 0,
|
|
726
|
+
// onPeerKeyChanged
|
|
727
|
+
(newPeerKey) => {
|
|
728
|
+
relaySession.peerPublicKey = newPeerKey;
|
|
729
|
+
saveRelaySession(relaySession);
|
|
730
|
+
}
|
|
731
|
+
);
|
|
732
|
+
return bridge;
|
|
733
|
+
}
|
|
734
|
+
function buildBridgeUrl(keysUrl, session, relayUrl, cliPublicKeyHex) {
|
|
735
|
+
const url = new URL("/cli-bridge", keysUrl);
|
|
736
|
+
url.searchParams.set("session", session);
|
|
737
|
+
url.searchParams.set("relay", relayUrl);
|
|
738
|
+
url.hash = `pk=${cliPublicKeyHex}`;
|
|
739
|
+
return url.toString();
|
|
740
|
+
}
|
|
741
|
+
function keystoreExists() {
|
|
742
|
+
return fs.existsSync(PATHS.keystore);
|
|
743
|
+
}
|
|
744
|
+
var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
|
|
745
|
+
var SELECTOR_RE2 = /^0x[0-9a-fA-F]{8}$/;
|
|
746
|
+
var HEX_RE = /^0x[0-9a-fA-F]+$/;
|
|
747
|
+
var ALLOWANCE_RE2 = /^(0x[0-9a-fA-F]+|[0-9]+)$/;
|
|
748
|
+
var SPEND_UNITS = /* @__PURE__ */ new Set(["minute", "hour", "day", "week", "month", "year", "forever"]);
|
|
749
|
+
function isPositiveInt(value) {
|
|
750
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
751
|
+
}
|
|
752
|
+
function parseGrantedPermission(raw) {
|
|
753
|
+
if (typeof raw !== "object" || raw === null) return void 0;
|
|
754
|
+
const r = raw;
|
|
755
|
+
const { account, spender, salt } = r;
|
|
756
|
+
if (typeof account !== "string" || !ADDRESS_RE2.test(account)) return void 0;
|
|
757
|
+
if (typeof spender !== "string" || !ADDRESS_RE2.test(spender)) return void 0;
|
|
758
|
+
if (typeof salt !== "string" || !HEX_RE.test(salt)) return void 0;
|
|
759
|
+
if (!isPositiveInt(r.start) || !isPositiveInt(r.end)) return void 0;
|
|
760
|
+
if (!Array.isArray(r.calls) || r.calls.length === 0) return void 0;
|
|
761
|
+
const calls = [];
|
|
762
|
+
for (const entry of r.calls) {
|
|
763
|
+
if (typeof entry !== "object" || entry === null) return void 0;
|
|
764
|
+
const { target, selector } = entry;
|
|
765
|
+
if (typeof target !== "string" || !ADDRESS_RE2.test(target)) return void 0;
|
|
766
|
+
if (typeof selector !== "string" || !SELECTOR_RE2.test(selector)) return void 0;
|
|
767
|
+
calls.push({ target, selector });
|
|
768
|
+
}
|
|
769
|
+
if (!Array.isArray(r.spends)) return void 0;
|
|
770
|
+
const spends = [];
|
|
771
|
+
for (const entry of r.spends) {
|
|
772
|
+
if (typeof entry !== "object" || entry === null) return void 0;
|
|
773
|
+
const { token, allowance, unit, multiplier } = entry;
|
|
774
|
+
if (typeof token !== "string" || !ADDRESS_RE2.test(token)) return void 0;
|
|
775
|
+
if (typeof allowance !== "string" || !ALLOWANCE_RE2.test(allowance)) return void 0;
|
|
776
|
+
if (typeof unit !== "string" || !SPEND_UNITS.has(unit)) return void 0;
|
|
777
|
+
if (!isPositiveInt(multiplier) || multiplier > 65535) return void 0;
|
|
778
|
+
spends.push({ token, allowance, unit, multiplier });
|
|
779
|
+
}
|
|
780
|
+
return { account, spender, start: r.start, end: r.end, salt, calls, spends };
|
|
781
|
+
}
|
|
782
|
+
function isLegacySession(config) {
|
|
783
|
+
return config.mode !== "eip7702";
|
|
784
|
+
}
|
|
785
|
+
function liveOrphans(orphans, now = Date.now() / 1e3) {
|
|
786
|
+
return (orphans ?? []).filter((orphan) => orphan.expiry > now);
|
|
787
|
+
}
|
|
788
|
+
function saveSessionConfig(input) {
|
|
789
|
+
writeSessionConfig({ ...input, createdAt: input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString() });
|
|
790
|
+
}
|
|
791
|
+
function writeSessionConfig(config) {
|
|
792
|
+
ensureDir(PATHS.root);
|
|
793
|
+
const temp = `${PATHS.sessionConfig}.${process.pid}.tmp`;
|
|
794
|
+
fs.writeFileSync(temp, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 384 });
|
|
795
|
+
fs.chmodSync(temp, 384);
|
|
796
|
+
fs.renameSync(temp, PATHS.sessionConfig);
|
|
797
|
+
}
|
|
798
|
+
function saveRecoveredPermission(config, permission) {
|
|
799
|
+
const current = tryLoadSessionConfig();
|
|
800
|
+
if (!current || current.permissionId !== config.permissionId) return false;
|
|
801
|
+
writeSessionConfig({ ...current, permission });
|
|
802
|
+
return true;
|
|
803
|
+
}
|
|
804
|
+
function saveRevokeProgress(config, progress) {
|
|
805
|
+
const next = { ...tryLoadSessionConfig() ?? config };
|
|
806
|
+
if (progress.orphans.length > 0) next.orphanedPermissions = progress.orphans;
|
|
807
|
+
else delete next.orphanedPermissions;
|
|
808
|
+
writeSessionConfig(next);
|
|
809
|
+
}
|
|
810
|
+
function loadSessionConfig() {
|
|
811
|
+
if (!fs.existsSync(PATHS.sessionConfig)) {
|
|
812
|
+
throw new Error("No session configured. Run `jaw session setup` first.");
|
|
813
|
+
}
|
|
814
|
+
const raw = fs.readFileSync(PATHS.sessionConfig, "utf-8");
|
|
815
|
+
try {
|
|
816
|
+
return JSON.parse(raw);
|
|
817
|
+
} catch {
|
|
818
|
+
throw new Error(`Session config at ${PATHS.sessionConfig} is corrupted. Run \`jaw session setup\` to recreate it.`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
function tryLoadSessionConfig() {
|
|
822
|
+
try {
|
|
823
|
+
return loadSessionConfig();
|
|
824
|
+
} catch {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// src/x402/asset-registry.ts
|
|
830
|
+
var USDC_BY_NETWORK = {
|
|
831
|
+
"eip155:8453": {
|
|
832
|
+
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
833
|
+
chainId: 8453,
|
|
834
|
+
wireNetwork: "eip155:8453",
|
|
835
|
+
usdcName: "USD Coin",
|
|
836
|
+
usdcVersion: "2",
|
|
837
|
+
decimals: 6
|
|
838
|
+
},
|
|
839
|
+
"eip155:84532": {
|
|
840
|
+
address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
841
|
+
chainId: 84532,
|
|
842
|
+
wireNetwork: "eip155:84532",
|
|
843
|
+
usdcName: "USDC",
|
|
844
|
+
usdcVersion: "2",
|
|
845
|
+
decimals: 6
|
|
846
|
+
},
|
|
847
|
+
"eip155:137": {
|
|
848
|
+
address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
|
|
849
|
+
chainId: 137,
|
|
850
|
+
wireNetwork: "eip155:137",
|
|
851
|
+
usdcName: "USD Coin",
|
|
852
|
+
usdcVersion: "2",
|
|
853
|
+
decimals: 6
|
|
854
|
+
},
|
|
855
|
+
"eip155:80002": {
|
|
856
|
+
address: "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582",
|
|
857
|
+
chainId: 80002,
|
|
858
|
+
wireNetwork: "eip155:80002",
|
|
859
|
+
usdcName: "USDC",
|
|
860
|
+
usdcVersion: "2",
|
|
861
|
+
decimals: 6
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
function usdcForNetwork(network) {
|
|
865
|
+
return Object.hasOwn(USDC_BY_NETWORK, network) ? USDC_BY_NETWORK[network] : void 0;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// src/x402/grant-preset.ts
|
|
869
|
+
var TRANSFER_SIGNATURE = "transfer(address,uint256)";
|
|
870
|
+
var MAX_ALLOWANCE = 2n ** 160n - 1n;
|
|
871
|
+
var LIMIT_PERIODS = ["minute", "hour", "day", "week", "month", "year", "forever"];
|
|
872
|
+
var DEFAULT_X402_LIMIT = "10/day";
|
|
873
|
+
function parseLimit(input) {
|
|
874
|
+
const trimmed = input.trim();
|
|
875
|
+
if (!trimmed) throw new Error("Limit is empty. Use --limit <amount>/<period>, for example 10/day.");
|
|
876
|
+
const [rawAmount, rawPeriod = "day", ...rest] = trimmed.split("/");
|
|
877
|
+
if (rest.length > 0) {
|
|
878
|
+
throw new Error(`Invalid limit "${input}". Expected <amount>/<period>, for example 10/day.`);
|
|
879
|
+
}
|
|
880
|
+
const amount = rawAmount.trim();
|
|
881
|
+
if (!/^\d+(\.\d+)?$/.test(amount)) {
|
|
882
|
+
throw new Error(`Invalid limit amount "${rawAmount}". Expected a positive number, for example 10 or 2.5.`);
|
|
883
|
+
}
|
|
884
|
+
const period = rawPeriod.trim().toLowerCase();
|
|
885
|
+
if (!LIMIT_PERIODS.includes(period)) {
|
|
886
|
+
throw new Error(`Invalid limit period "${rawPeriod}". Expected one of: ${LIMIT_PERIODS.join(", ")}.`);
|
|
887
|
+
}
|
|
888
|
+
return { amount, period };
|
|
889
|
+
}
|
|
890
|
+
function buildX402Permissions(chainId, limit = DEFAULT_X402_LIMIT) {
|
|
891
|
+
const usdc = Object.values(USDC_BY_NETWORK).find((asset) => asset.chainId === chainId);
|
|
892
|
+
if (!usdc) {
|
|
893
|
+
const supported = Object.values(USDC_BY_NETWORK).map((a) => a.chainId).sort((a, b) => a - b).join(", ");
|
|
894
|
+
throw new Error(`No USDC configured for chain ${chainId}. x402 payments are supported on: ${supported}.`);
|
|
895
|
+
}
|
|
896
|
+
const { amount, period } = parseLimit(limit);
|
|
897
|
+
let allowance;
|
|
898
|
+
try {
|
|
899
|
+
allowance = parseUnits(amount, usdc.decimals);
|
|
900
|
+
} catch {
|
|
901
|
+
throw new Error(`Invalid limit amount "${amount}" for a token with ${usdc.decimals} decimals.`);
|
|
902
|
+
}
|
|
903
|
+
if (allowance <= 0n) {
|
|
904
|
+
throw new Error(`Limit "${limit}" resolves to zero, which would refuse every payment.`);
|
|
905
|
+
}
|
|
906
|
+
if (allowance > MAX_ALLOWANCE) {
|
|
907
|
+
throw new Error(`Limit "${limit}" is larger than a spend allowance can hold (max ${MAX_ALLOWANCE}).`);
|
|
908
|
+
}
|
|
909
|
+
return {
|
|
910
|
+
calls: [{ target: usdc.address, functionSignature: TRANSFER_SIGNATURE }],
|
|
911
|
+
spends: [{ token: usdc.address, allowance: allowance.toString(), unit: period, multiplier: 1 }]
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// src/x402/period.ts
|
|
916
|
+
function describeSpendPeriod(unit, multiplier) {
|
|
917
|
+
const n = Math.max(1, Math.floor(multiplier ?? 1));
|
|
918
|
+
return n === 1 ? unit : `${n} ${unit}s`;
|
|
919
|
+
}
|
|
920
|
+
function periodLengthSeconds(unit, multiplier, bound2) {
|
|
921
|
+
const lengths = {
|
|
922
|
+
minute: 60,
|
|
923
|
+
hour: 3600,
|
|
924
|
+
day: 86400,
|
|
925
|
+
week: 604800,
|
|
926
|
+
month: (bound2 === "min" ? 28 : 31) * 86400,
|
|
927
|
+
year: (bound2 === "min" ? 365 : 366) * 86400,
|
|
928
|
+
forever: Number.POSITIVE_INFINITY
|
|
929
|
+
};
|
|
930
|
+
if (!Object.hasOwn(lengths, unit)) return null;
|
|
931
|
+
return lengths[unit] * Math.max(1, Math.floor(multiplier));
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// src/x402/amount.ts
|
|
935
|
+
function parseBigInt(value) {
|
|
936
|
+
if (value === void 0 || value === null || value === "") return null;
|
|
937
|
+
try {
|
|
938
|
+
return BigInt(value);
|
|
939
|
+
} catch {
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// src/lib/terminal.ts
|
|
945
|
+
var INVISIBLE_AND_BIDI = /[\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069\uFEFF]/g;
|
|
946
|
+
var LINE_CONTROLS = /[\u0000-\u001F\u007F-\u009F]/g;
|
|
947
|
+
var REPLACEMENT = "\uFFFD";
|
|
948
|
+
var DEFAULT_LINE_LENGTH = 200;
|
|
949
|
+
function bound(text, maxLength) {
|
|
950
|
+
if (text.length <= maxLength) return text;
|
|
951
|
+
return `${text.slice(0, maxLength)}\u2026 (${text.length - maxLength} more characters)`;
|
|
952
|
+
}
|
|
953
|
+
function sanitizeLine(value, maxLength = DEFAULT_LINE_LENGTH) {
|
|
954
|
+
const text = typeof value === "string" ? value : String(value);
|
|
955
|
+
return bound(text.replace(LINE_CONTROLS, REPLACEMENT).replace(INVISIBLE_AND_BIDI, REPLACEMENT), maxLength);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// src/x402/status-report.ts
|
|
959
|
+
function formatUsdc(base2, decimals) {
|
|
960
|
+
if (base2 === void 0) return "unlimited";
|
|
961
|
+
const value = parseBigInt(base2);
|
|
962
|
+
if (value === null) return `${sanitizeLine(base2, 32)} (invalid)`;
|
|
963
|
+
const scale = 10n ** BigInt(decimals);
|
|
964
|
+
const whole = value / scale;
|
|
965
|
+
const frac = (value % scale).toString().padStart(decimals, "0").replace(/0+$/, "");
|
|
966
|
+
return `${whole}${frac ? `.${frac}` : ""} USDC`;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// src/x402/grant-ceiling.ts
|
|
970
|
+
function whyGrantExceedsCeiling(permissions, chainId, ceiling) {
|
|
971
|
+
if (!ceiling) return null;
|
|
972
|
+
let parsed;
|
|
973
|
+
try {
|
|
974
|
+
parsed = parseLimit(ceiling);
|
|
975
|
+
} catch {
|
|
976
|
+
return `The grant ceiling in your config is not a valid limit: ${ceiling}. Fix it with \`jaw config set grantCeiling <amount>/<period>\`, or remove it.`;
|
|
977
|
+
}
|
|
978
|
+
const usdc = Object.values(USDC_BY_NETWORK).find((asset) => asset.chainId === chainId);
|
|
979
|
+
if (!usdc) {
|
|
980
|
+
return (permissions.spends ?? []).length > 0 ? `The grant ceiling is set to ${ceiling}, and chain ${chainId} has no USDC in the registry to measure a spend against it. Grant on a supported chain, or remove the ceiling with \`jaw config set grantCeiling ""\`.` : null;
|
|
981
|
+
}
|
|
982
|
+
const maxAllowance = parseUnits(parsed.amount, usdc.decimals);
|
|
983
|
+
const ceilingSeconds = periodLengthSeconds(parsed.period, 1, "max");
|
|
984
|
+
if (ceilingSeconds === null) return null;
|
|
985
|
+
for (const spend of permissions.spends ?? []) {
|
|
986
|
+
if (spend.token.toLowerCase() !== usdc.address.toLowerCase()) {
|
|
987
|
+
return `The grant ceiling is set to ${ceiling}, and this permission spends ${spend.token}, which cannot be measured against it. Grant USDC, or remove the ceiling with \`jaw config set grantCeiling ""\`.`;
|
|
988
|
+
}
|
|
989
|
+
let allowance;
|
|
990
|
+
try {
|
|
991
|
+
allowance = BigInt(spend.allowance);
|
|
992
|
+
} catch {
|
|
993
|
+
return `This permission asks for an allowance that cannot be read: ${spend.allowance}.`;
|
|
994
|
+
}
|
|
995
|
+
if (allowance > maxAllowance) {
|
|
996
|
+
return `This grant asks for ${formatUsdc(allowance.toString(), usdc.decimals)} per period, over the ${ceiling} ceiling set on this machine. Lower it, or raise the ceiling with \`jaw config set grantCeiling <amount>/<period>\`.`;
|
|
997
|
+
}
|
|
998
|
+
const grantSeconds = periodLengthSeconds(spend.unit, spend.multiplier ?? 1, "min");
|
|
999
|
+
if (grantSeconds === null) {
|
|
1000
|
+
return `This permission uses a spend period this CLI does not recognise: ${spend.unit}.`;
|
|
1001
|
+
}
|
|
1002
|
+
const sameUnit = spend.unit === parsed.period;
|
|
1003
|
+
if (!sameUnit && grantSeconds < ceilingSeconds) {
|
|
1004
|
+
return `This grant resets its allowance every ${describeSpendPeriod(spend.unit, spend.multiplier ?? 1)}, which is more often than the ${ceiling} ceiling set on this machine allows. A shorter period is more money over the same time, even at the same allowance.`;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
var JAW_RPC_URL = "https://api.justaname.id/proxy/v1/rpc";
|
|
1010
|
+
var CHAINS = {
|
|
1011
|
+
[base.id]: base,
|
|
1012
|
+
[baseSepolia.id]: baseSepolia,
|
|
1013
|
+
[polygon.id]: polygon,
|
|
1014
|
+
[polygonAmoy.id]: polygonAmoy
|
|
1015
|
+
};
|
|
1016
|
+
for (const chainId of Object.values(USDC_BY_NETWORK).map((a) => a.chainId)) {
|
|
1017
|
+
if (!CHAINS[chainId]) {
|
|
1018
|
+
throw new Error(
|
|
1019
|
+
`x402 balance: USDC registry has chain ${chainId} but no viem chain is mapped for it in balance.ts`
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
var clients = /* @__PURE__ */ new Map();
|
|
1024
|
+
function rpcTransport(chainId, apiKey) {
|
|
1025
|
+
if (!apiKey) return http();
|
|
1026
|
+
return http(`${JAW_RPC_URL}?chainId=${chainId}&api-key=${apiKey}`);
|
|
1027
|
+
}
|
|
1028
|
+
function publicClientFor(chainId) {
|
|
1029
|
+
const chain = CHAINS[chainId];
|
|
1030
|
+
if (!chain) throw new Error(`x402: no viem chain configured for chainId ${chainId}`);
|
|
1031
|
+
const apiKey = loadConfig().apiKey;
|
|
1032
|
+
const key = `${chainId}:${apiKey ?? ""}`;
|
|
1033
|
+
let client = clients.get(key);
|
|
1034
|
+
if (!client) {
|
|
1035
|
+
client = createPublicClient({ chain, transport: rpcTransport(chainId, apiKey) });
|
|
1036
|
+
clients.set(key, client);
|
|
1037
|
+
}
|
|
1038
|
+
return client;
|
|
1039
|
+
}
|
|
1040
|
+
var readOnChain = (asset, owner) => publicClientFor(asset.chainId).readContract({
|
|
1041
|
+
address: asset.address,
|
|
1042
|
+
abi: erc20Abi,
|
|
1043
|
+
functionName: "balanceOf",
|
|
1044
|
+
args: [owner]
|
|
1045
|
+
});
|
|
1046
|
+
async function usdcBalance(network, owner, read = readOnChain) {
|
|
1047
|
+
const asset = usdcForNetwork(network);
|
|
1048
|
+
if (!asset) throw new Error(`Unsupported x402 network: ${network}`);
|
|
1049
|
+
const raw = await read(asset, owner);
|
|
1050
|
+
return { network, asset: asset.address, raw: raw.toString(), formatted: formatUnits(raw, asset.decimals) };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// src/x402/gas-reserve.ts
|
|
1054
|
+
function gasReserve(asset) {
|
|
1055
|
+
return 10n ** BigInt(asset.decimals) / 10n;
|
|
1056
|
+
}
|
|
1057
|
+
function firstOperationCost(asset) {
|
|
1058
|
+
return 10n ** BigInt(asset.decimals) / 100n;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// src/x402/funded-owner.ts
|
|
1062
|
+
async function whyOwnerCannotFundSession(check) {
|
|
1063
|
+
const asset = usdcForNetwork(`eip155:${check.chainId}`);
|
|
1064
|
+
if (!asset) return null;
|
|
1065
|
+
const accounts = await check.request("eth_requestAccounts");
|
|
1066
|
+
const owner = accounts?.[0];
|
|
1067
|
+
if (!owner) return null;
|
|
1068
|
+
const read = check.readBalance ?? (async (network, address) => BigInt((await usdcBalance(network, address)).raw));
|
|
1069
|
+
let held;
|
|
1070
|
+
try {
|
|
1071
|
+
held = await read(asset.wireNetwork, owner);
|
|
1072
|
+
} catch {
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1075
|
+
const needed = gasReserve(asset);
|
|
1076
|
+
if (held >= needed) return null;
|
|
1077
|
+
return `${owner} holds ${formatUsdc(held.toString(), asset.decimals)} on chain ${check.chainId}, and setting up a session needs at least ${formatUsdc(needed.toString(), asset.decimals)} there. That much rides along in the grant so the session can pay for its own first transaction. Fund the account and run this again.`;
|
|
1078
|
+
}
|
|
1079
|
+
async function whySpenderCannotPay(check) {
|
|
1080
|
+
const asset = usdcForNetwork(`eip155:${check.chainId}`);
|
|
1081
|
+
if (!asset) return null;
|
|
1082
|
+
const read = check.readBalance ?? (async (network, address) => BigInt((await usdcBalance(network, address)).raw));
|
|
1083
|
+
let held;
|
|
1084
|
+
let timer;
|
|
1085
|
+
try {
|
|
1086
|
+
const expired = new Promise((_, reject) => {
|
|
1087
|
+
timer = setTimeout(() => reject(new Error("timed out")), check.timeoutMs ?? 5e3);
|
|
1088
|
+
});
|
|
1089
|
+
held = await Promise.race([read(asset.wireNetwork, check.spender), expired]);
|
|
1090
|
+
} catch {
|
|
1091
|
+
return null;
|
|
1092
|
+
} finally {
|
|
1093
|
+
clearTimeout(timer);
|
|
1094
|
+
}
|
|
1095
|
+
const needed = firstOperationCost(asset);
|
|
1096
|
+
if (held >= needed) return null;
|
|
1097
|
+
return `${check.spender} holds ${formatUsdc(held.toString(), asset.decimals)} on chain ${check.chainId}, which is not enough to pay for its first operation. The session pays its own gas, and the grant asked this wallet to send it a little along with the permission. Nothing usable arrived: the wallet may not implement that yet, or it could not price the transfer, or the granted allowance was too small to cover one. Send ${formatUsdc(needed.toString(), asset.decimals)} to that address and the session works from there.`;
|
|
1098
|
+
}
|
|
1099
|
+
var PERMISSION_MANAGER_ABI = parseAbi([
|
|
1100
|
+
"struct CallPermission { address target; bytes4 selector; address checker; }",
|
|
1101
|
+
"struct SpendLimit { address token; uint160 allowance; uint8 unit; uint16 multiplier; }",
|
|
1102
|
+
"struct Permission { address account; address spender; uint48 start; uint48 end; uint256 salt; CallPermission[] calls; SpendLimit[] spends; }",
|
|
1103
|
+
"struct PeriodSpend { uint48 start; uint48 end; uint160 spend; }",
|
|
1104
|
+
"function getHash(Permission permission) view returns (bytes32)",
|
|
1105
|
+
"function isApproved(Permission permission) view returns (bool)",
|
|
1106
|
+
"function isRevoked(Permission permission) view returns (bool)",
|
|
1107
|
+
"function getCurrentPeriod(Permission permission, SpendLimit spendLimit) view returns (PeriodSpend)",
|
|
1108
|
+
// Carried so the two time-bound reverts can be told apart from a node that
|
|
1109
|
+
// did not answer. Everything else the manager can revert with decodes to an
|
|
1110
|
+
// unnamed error, which is treated as unavailable rather than guessed at.
|
|
1111
|
+
"error JustaPermissionManager_BeforePermissionStart(uint48 currentTimestamp, uint48 start)",
|
|
1112
|
+
"error JustaPermissionManager_AfterPermissionEnd(uint48 currentTimestamp, uint48 end)"
|
|
1113
|
+
]);
|
|
1114
|
+
var PERIOD_UNIT_ENUM = {
|
|
1115
|
+
minute: 0,
|
|
1116
|
+
hour: 1,
|
|
1117
|
+
day: 2,
|
|
1118
|
+
week: 3,
|
|
1119
|
+
month: 4,
|
|
1120
|
+
forever: 5
|
|
1121
|
+
};
|
|
1122
|
+
function toContractSpendLimit(spend) {
|
|
1123
|
+
const unit = spend.unit === "year" ? "month" : spend.unit;
|
|
1124
|
+
const multiplier = spend.unit === "year" ? spend.multiplier * 12 : spend.multiplier;
|
|
1125
|
+
if (!Object.hasOwn(PERIOD_UNIT_ENUM, unit)) return null;
|
|
1126
|
+
return {
|
|
1127
|
+
token: spend.token,
|
|
1128
|
+
allowance: BigInt(spend.allowance),
|
|
1129
|
+
unit: PERIOD_UNIT_ENUM[unit],
|
|
1130
|
+
multiplier
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
function toContractPermission(permission) {
|
|
1134
|
+
const spends = [];
|
|
1135
|
+
for (const spend of permission.spends) {
|
|
1136
|
+
const converted = toContractSpendLimit(spend);
|
|
1137
|
+
if (!converted) return null;
|
|
1138
|
+
spends.push(converted);
|
|
1139
|
+
}
|
|
1140
|
+
let salt;
|
|
1141
|
+
try {
|
|
1142
|
+
salt = BigInt(permission.salt);
|
|
1143
|
+
} catch {
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
return {
|
|
1147
|
+
account: permission.account,
|
|
1148
|
+
spender: permission.spender,
|
|
1149
|
+
start: permission.start,
|
|
1150
|
+
end: permission.end,
|
|
1151
|
+
salt,
|
|
1152
|
+
calls: permission.calls.map((call) => ({
|
|
1153
|
+
target: call.target,
|
|
1154
|
+
selector: call.selector,
|
|
1155
|
+
checker: zeroAddress
|
|
1156
|
+
})),
|
|
1157
|
+
spends
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
var DEFAULT_TIMEOUT_MS2 = 5e3;
|
|
1161
|
+
async function within(work, timeoutMs = DEFAULT_TIMEOUT_MS2) {
|
|
1162
|
+
let timer;
|
|
1163
|
+
try {
|
|
1164
|
+
const expired = new Promise((_, reject) => {
|
|
1165
|
+
timer = setTimeout(() => reject(new Error("timed out")), timeoutMs);
|
|
1166
|
+
});
|
|
1167
|
+
return await Promise.race([work, expired]);
|
|
1168
|
+
} finally {
|
|
1169
|
+
clearTimeout(timer);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
async function managerAddress(override) {
|
|
1173
|
+
if (override) return override;
|
|
1174
|
+
const { PERMISSIONS_MANAGER_ADDRESS } = await import('@jaw.id/core');
|
|
1175
|
+
return PERMISSIONS_MANAGER_ADDRESS;
|
|
1176
|
+
}
|
|
1177
|
+
function reader(chainId, deps) {
|
|
1178
|
+
if (deps.readContract) return deps.readContract;
|
|
1179
|
+
try {
|
|
1180
|
+
const client = publicClientFor(chainId);
|
|
1181
|
+
return (args) => client.readContract(args);
|
|
1182
|
+
} catch {
|
|
1183
|
+
return null;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
async function readPermissionState(target, deps = {}) {
|
|
1187
|
+
if (!target.permission) return { status: "unavailable" };
|
|
1188
|
+
const permission = toContractPermission(target.permission);
|
|
1189
|
+
if (!permission) return { status: "unavailable" };
|
|
1190
|
+
const read = reader(target.chainId, deps);
|
|
1191
|
+
if (!read) return { status: "unavailable" };
|
|
1192
|
+
try {
|
|
1193
|
+
const address = await managerAddress(deps.manager);
|
|
1194
|
+
const [hash, approved, revoked] = await within(
|
|
1195
|
+
Promise.all([
|
|
1196
|
+
read({ address, abi: PERMISSION_MANAGER_ABI, functionName: "getHash", args: [permission] }),
|
|
1197
|
+
read({ address, abi: PERMISSION_MANAGER_ABI, functionName: "isApproved", args: [permission] }),
|
|
1198
|
+
read({ address, abi: PERMISSION_MANAGER_ABI, functionName: "isRevoked", args: [permission] })
|
|
1199
|
+
]),
|
|
1200
|
+
deps.timeoutMs
|
|
1201
|
+
);
|
|
1202
|
+
if (typeof hash !== "string" || hash.toLowerCase() !== target.permissionId.toLowerCase()) {
|
|
1203
|
+
return { status: "mismatch" };
|
|
1204
|
+
}
|
|
1205
|
+
return { status: "ok", approved: approved === true, revoked: revoked === true };
|
|
1206
|
+
} catch {
|
|
1207
|
+
return { status: "unavailable" };
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
async function readLiveness(session, deps = {}) {
|
|
1211
|
+
const state = await readPermissionState(session, deps);
|
|
1212
|
+
if (state.status === "unavailable") return "unknown";
|
|
1213
|
+
if (state.status === "mismatch") return "mismatch";
|
|
1214
|
+
if (state.revoked) return "revoked";
|
|
1215
|
+
return state.approved ? "active" : "unapproved";
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// src/x402/permission-recovery.ts
|
|
1219
|
+
var RECOVERY_TIMEOUT_MS = 5e3;
|
|
1220
|
+
async function recoverPermission(session, apiKey, deps = {}) {
|
|
1221
|
+
if (session.permission) return session.permission;
|
|
1222
|
+
if (!apiKey) return void 0;
|
|
1223
|
+
const fetchPermission = deps.fetchPermission ?? (async (id, key) => {
|
|
1224
|
+
const { getPermissionFromRelay } = await import('@jaw.id/core');
|
|
1225
|
+
return getPermissionFromRelay(id, key);
|
|
1226
|
+
});
|
|
1227
|
+
let timer;
|
|
1228
|
+
try {
|
|
1229
|
+
const expired = new Promise((_, reject) => {
|
|
1230
|
+
timer = setTimeout(() => reject(new Error("timed out")), deps.timeoutMs ?? RECOVERY_TIMEOUT_MS);
|
|
1231
|
+
});
|
|
1232
|
+
const relayed = await Promise.race([fetchPermission(session.permissionId, apiKey), expired]);
|
|
1233
|
+
const permission = parseGrantedPermission(relayed);
|
|
1234
|
+
if (!permission) return void 0;
|
|
1235
|
+
if (permission.account.toLowerCase() !== session.ownerAddress.toLowerCase() || permission.spender.toLowerCase() !== session.sessionAddress.toLowerCase() || permission.end !== session.expiry) {
|
|
1236
|
+
return void 0;
|
|
1237
|
+
}
|
|
1238
|
+
return saveRecoveredPermission(session, permission) ? permission : void 0;
|
|
1239
|
+
} catch {
|
|
1240
|
+
return void 0;
|
|
1241
|
+
} finally {
|
|
1242
|
+
clearTimeout(timer);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
function callKey(call) {
|
|
1246
|
+
const selector = call.selector ?? (call.functionSignature ? safeSelector(call.functionSignature) : void 0);
|
|
1247
|
+
return selector ? `${call.target.toLowerCase()}:${selector.toLowerCase()}` : null;
|
|
1248
|
+
}
|
|
1249
|
+
function safeSelector(signature) {
|
|
1250
|
+
try {
|
|
1251
|
+
return toFunctionSelector(signature);
|
|
1252
|
+
} catch {
|
|
1253
|
+
return void 0;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
function spendKey(spend) {
|
|
1257
|
+
return `${spend.token.toLowerCase()}:${spend.unit}:${spend.multiplier ?? 1}`;
|
|
1258
|
+
}
|
|
1259
|
+
function tokenKey(spend) {
|
|
1260
|
+
return spend.token.toLowerCase();
|
|
1261
|
+
}
|
|
1262
|
+
function mergePermissions(existing, addition) {
|
|
1263
|
+
const calls = existing.calls.map((call) => ({
|
|
1264
|
+
target: call.target,
|
|
1265
|
+
selector: call.selector
|
|
1266
|
+
}));
|
|
1267
|
+
const seenCalls = new Set(existing.calls.map(callKey));
|
|
1268
|
+
for (const call of addition.calls ?? []) {
|
|
1269
|
+
const key = callKey(call);
|
|
1270
|
+
if (key && seenCalls.has(key)) continue;
|
|
1271
|
+
if (key) seenCalls.add(key);
|
|
1272
|
+
calls.push(call);
|
|
1273
|
+
}
|
|
1274
|
+
const superseded = new Set((addition.spends ?? []).map(spendKey));
|
|
1275
|
+
const spends = existing.spends.filter((spend) => !superseded.has(spendKey(spend))).map((spend) => ({
|
|
1276
|
+
token: spend.token,
|
|
1277
|
+
// Normalised out of the hex the grant response carries, so the merged
|
|
1278
|
+
// document reads the way a hand-written one does.
|
|
1279
|
+
allowance: BigInt(spend.allowance).toString(),
|
|
1280
|
+
unit: spend.unit,
|
|
1281
|
+
multiplier: spend.multiplier
|
|
1282
|
+
}));
|
|
1283
|
+
for (const spend of addition.spends ?? []) {
|
|
1284
|
+
spends.push({
|
|
1285
|
+
token: spend.token,
|
|
1286
|
+
allowance: BigInt(spend.allowance).toString(),
|
|
1287
|
+
unit: spend.unit,
|
|
1288
|
+
multiplier: spend.multiplier ?? 1
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
return {
|
|
1292
|
+
...calls.length > 0 ? { calls } : {},
|
|
1293
|
+
...spends.length > 0 ? { spends } : {}
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
function describeMerge(existing, merged) {
|
|
1297
|
+
const lines = [];
|
|
1298
|
+
const hadCalls = new Set(existing.calls.map(callKey));
|
|
1299
|
+
for (const call of merged.calls ?? []) {
|
|
1300
|
+
const key = callKey(call);
|
|
1301
|
+
if (key && hadCalls.has(key)) continue;
|
|
1302
|
+
lines.push(` + call ${call.target} ${call.selector ?? call.functionSignature ?? ""}`.trimEnd());
|
|
1303
|
+
}
|
|
1304
|
+
const had = new Map(existing.spends.map((spend) => [spendKey(spend), BigInt(spend.allowance)]));
|
|
1305
|
+
const changed = [];
|
|
1306
|
+
for (const spend of merged.spends ?? []) {
|
|
1307
|
+
const was = had.get(spendKey(spend));
|
|
1308
|
+
const now = BigInt(spend.allowance);
|
|
1309
|
+
if (was === void 0) {
|
|
1310
|
+
lines.push(
|
|
1311
|
+
` + spend ${spend.allowance} of ${spend.token} per ${describeSpendPeriod(spend.unit, spend.multiplier)}`
|
|
1312
|
+
);
|
|
1313
|
+
changed.push(spend);
|
|
1314
|
+
} else if (was !== now) {
|
|
1315
|
+
lines.push(
|
|
1316
|
+
` ~ spend ${spend.token} per ${describeSpendPeriod(spend.unit, spend.multiplier)}: ${was} to ${now}`
|
|
1317
|
+
);
|
|
1318
|
+
changed.push(spend);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
for (const spend of changed) {
|
|
1322
|
+
const others = (merged.spends ?? []).filter((o) => o !== spend && tokenKey(o) === tokenKey(spend));
|
|
1323
|
+
for (const other of others) {
|
|
1324
|
+
lines.push(
|
|
1325
|
+
` ! note ${other.allowance} per ${describeSpendPeriod(other.unit, other.multiplier)} on the same token still applies; every limit on a token is charged, so the tightest of them binds`
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
return lines;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// src/commands/session/add.ts
|
|
1333
|
+
var SessionAdd = class _SessionAdd extends BaseCommand {
|
|
1334
|
+
static description = "Add permissions to the current session, keeping the ones it already has (one browser approval to grant, one to revoke the old).";
|
|
1335
|
+
static examples = [
|
|
1336
|
+
"<%= config.bin %> session add --x402",
|
|
1337
|
+
"<%= config.bin %> session add --x402 --limit 10/day",
|
|
1338
|
+
`<%= config.bin %> session add --permissions '{"calls":[...]}'`
|
|
1339
|
+
];
|
|
1340
|
+
static flags = {
|
|
1341
|
+
...BaseCommand.baseFlags,
|
|
1342
|
+
permissions: Flags.string({
|
|
1343
|
+
description: "Permissions to add (inline JSON or file path).",
|
|
1344
|
+
exclusive: ["x402"]
|
|
1345
|
+
}),
|
|
1346
|
+
x402: Flags.boolean({
|
|
1347
|
+
description: "Add exactly what x402 payments need on the session chain. Tune the cap with --limit.",
|
|
1348
|
+
default: false,
|
|
1349
|
+
exclusive: ["permissions"]
|
|
1350
|
+
}),
|
|
1351
|
+
limit: Flags.string({
|
|
1352
|
+
description: `Spend cap for --x402, as <amount>/<period> (default ${DEFAULT_X402_LIMIT}).`
|
|
1353
|
+
})
|
|
1354
|
+
};
|
|
1355
|
+
async run() {
|
|
1356
|
+
const { flags } = await this.parse(_SessionAdd);
|
|
1357
|
+
const config = loadConfig();
|
|
1358
|
+
const format = flags.output;
|
|
1359
|
+
const apiKey = this.resolveApiKey(flags);
|
|
1360
|
+
if (flags.limit && !flags.x402) {
|
|
1361
|
+
this.error("--limit only applies to --x402. Re-run with --x402, or set the cap inside --permissions.");
|
|
1362
|
+
}
|
|
1363
|
+
if (!flags.x402 && !flags.permissions) {
|
|
1364
|
+
this.error("Nothing to add. Pass --x402 for the payment preset, or --permissions with a scope.");
|
|
1365
|
+
}
|
|
1366
|
+
if (!keystoreExists()) {
|
|
1367
|
+
this.error("No session to add to. Run `jaw session setup` first.");
|
|
1368
|
+
}
|
|
1369
|
+
const session = loadSessionConfig();
|
|
1370
|
+
if (session.expiry <= Date.now() / 1e3) {
|
|
1371
|
+
this.error("The session expired, so there is nothing to add to. Run `jaw session setup` to create a new one.");
|
|
1372
|
+
}
|
|
1373
|
+
if (isLegacySession(session)) {
|
|
1374
|
+
this.error(
|
|
1375
|
+
"This session was created by an older CLI and cannot be added to: its permission belongs to an address separate from the session key. Run `jaw session setup` to recreate it."
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
const existing = await recoverPermission(session, apiKey);
|
|
1379
|
+
if (!existing) {
|
|
1380
|
+
this.error(
|
|
1381
|
+
"This session does not carry the permission it was granted, so what it already allows cannot be read. Run `jaw session setup` to recreate it, and adding will work from then on."
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
const liveness = await readLiveness({ ...session, permission: existing });
|
|
1385
|
+
if (liveness === "revoked") {
|
|
1386
|
+
this.error(
|
|
1387
|
+
"The permission this session names was revoked on chain. Run `jaw session setup` to create a new one."
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
if (liveness === "mismatch") {
|
|
1391
|
+
this.error(
|
|
1392
|
+
"The permission stored for this session does not match the one that was granted, so what it already allows cannot be read. Run `jaw session setup` to recreate it."
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
const addition = this.resolveAddition(flags, session.chainId);
|
|
1396
|
+
const merged = mergePermissions(existing, addition);
|
|
1397
|
+
const changes = describeMerge(existing, merged);
|
|
1398
|
+
if (changes.length === 0) {
|
|
1399
|
+
this.log("The session already allows all of this. Nothing to do.");
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
const overCeiling = whyGrantExceedsCeiling(addition, session.chainId, config.grantCeiling);
|
|
1403
|
+
if (overCeiling) this.error(overCeiling);
|
|
1404
|
+
const mergeOverCeiling = whyGrantExceedsCeiling(merged, session.chainId, config.grantCeiling);
|
|
1405
|
+
if (mergeOverCeiling) {
|
|
1406
|
+
this.logToStderr(
|
|
1407
|
+
// Not "holds more than the ceiling allows": three of the five reasons
|
|
1408
|
+
// this returns are "cannot be measured against it" rather than "is over
|
|
1409
|
+
// it", and a chain with no registry USDC produced a sentence that
|
|
1410
|
+
// contradicted itself.
|
|
1411
|
+
`Warning: what this session already holds does not clear the grant ceiling on this machine, and re-granting carries it over. ${mergeOverCeiling}`
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
if (!flags.quiet && format !== "json") {
|
|
1415
|
+
this.log("Adding to the current session:\n");
|
|
1416
|
+
for (const change of changes) this.log(change);
|
|
1417
|
+
this.log(
|
|
1418
|
+
"\nThe union is granted as a new permission and the old one is revoked, so the browser asks twice.\nThe session key is kept, so the address and its balance do not change.\n"
|
|
1419
|
+
);
|
|
1420
|
+
this.log("Opening browser to approve...");
|
|
1421
|
+
}
|
|
1422
|
+
const permissions = parsePermissionsConfig(merged);
|
|
1423
|
+
const bridge = await getBridge({ keysUrl: config.keysUrl, apiKey, chainId: session.chainId, ens: config.ens });
|
|
1424
|
+
let granted;
|
|
1425
|
+
try {
|
|
1426
|
+
const accounts = await bridge.request("eth_requestAccounts");
|
|
1427
|
+
const connected = accounts?.[0];
|
|
1428
|
+
if (connected && connected.toLowerCase() !== session.ownerAddress.toLowerCase()) {
|
|
1429
|
+
this.error(
|
|
1430
|
+
`This session's permission belongs to ${session.ownerAddress}, but ${connected} is connected in the browser. Connect that account, or run \`jaw session setup\` to start a session on this one.`
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
if (flags.x402) {
|
|
1434
|
+
const blocked = await whyOwnerCannotFundSession({
|
|
1435
|
+
chainId: session.chainId,
|
|
1436
|
+
request: (m, p) => bridge.request(m, p)
|
|
1437
|
+
});
|
|
1438
|
+
if (blocked) this.error(blocked);
|
|
1439
|
+
}
|
|
1440
|
+
granted = await bridge.request("wallet_grantPermissions", [
|
|
1441
|
+
{
|
|
1442
|
+
spender: session.sessionAddress,
|
|
1443
|
+
expiry: session.expiry,
|
|
1444
|
+
permissions,
|
|
1445
|
+
chainId: session.chainId,
|
|
1446
|
+
capabilities: { prefundSpender: true }
|
|
1447
|
+
}
|
|
1448
|
+
]);
|
|
1449
|
+
} finally {
|
|
1450
|
+
bridge.close();
|
|
1451
|
+
}
|
|
1452
|
+
const response = granted;
|
|
1453
|
+
const permission = parseGrantedPermission(granted);
|
|
1454
|
+
const orphans = liveOrphans(session.orphanedPermissions);
|
|
1455
|
+
const updated = {
|
|
1456
|
+
ownerAddress: response.account,
|
|
1457
|
+
sessionAddress: session.sessionAddress,
|
|
1458
|
+
permissionId: response.permissionId,
|
|
1459
|
+
chainId: session.chainId,
|
|
1460
|
+
expiry: session.expiry,
|
|
1461
|
+
mode: "eip7702",
|
|
1462
|
+
// Kept, not restamped: it is what the session spend total is counted
|
|
1463
|
+
// from, and adding a capability must not hand the session cap a clean
|
|
1464
|
+
// slate.
|
|
1465
|
+
createdAt: session.createdAt,
|
|
1466
|
+
...permission ? { permission } : {},
|
|
1467
|
+
orphanedPermissions: [{ id: session.permissionId, chainId: session.chainId, expiry: session.expiry }, ...orphans]
|
|
1468
|
+
};
|
|
1469
|
+
saveSessionConfig(updated);
|
|
1470
|
+
let revoked = false;
|
|
1471
|
+
try {
|
|
1472
|
+
const revokeBridge = await getBridge({
|
|
1473
|
+
keysUrl: config.keysUrl,
|
|
1474
|
+
apiKey,
|
|
1475
|
+
chainId: session.chainId,
|
|
1476
|
+
ens: config.ens
|
|
1477
|
+
});
|
|
1478
|
+
try {
|
|
1479
|
+
await revokeBridge.request("wallet_revokePermissions", [{ id: session.permissionId }]);
|
|
1480
|
+
revoked = true;
|
|
1481
|
+
} finally {
|
|
1482
|
+
revokeBridge.close();
|
|
1483
|
+
}
|
|
1484
|
+
} catch (err) {
|
|
1485
|
+
this.logToStderr(
|
|
1486
|
+
`Warning: the new permission was granted, but revoking the old one failed: ${err instanceof Error ? err.message : String(err)}. It stays live until it expires, and \`jaw session revoke\` will revoke it.`
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
if (revoked) {
|
|
1490
|
+
saveRevokeProgress({ ...updated, createdAt: session.createdAt }, { orphans});
|
|
1491
|
+
}
|
|
1492
|
+
if (flags.x402) {
|
|
1493
|
+
const unfunded = await whySpenderCannotPay({
|
|
1494
|
+
chainId: session.chainId,
|
|
1495
|
+
spender: session.sessionAddress
|
|
1496
|
+
});
|
|
1497
|
+
if (unfunded) this.logToStderr(`
|
|
1498
|
+
Warning: ${unfunded}`);
|
|
1499
|
+
}
|
|
1500
|
+
const summary = {
|
|
1501
|
+
sessionAddress: session.sessionAddress,
|
|
1502
|
+
permissionId: response.permissionId,
|
|
1503
|
+
previousPermissionId: session.permissionId,
|
|
1504
|
+
previousRevoked: revoked,
|
|
1505
|
+
expiry: session.expiry
|
|
1506
|
+
};
|
|
1507
|
+
if (format === "json" || flags.quiet) {
|
|
1508
|
+
this.outputResult(summary, format);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
this.log("\nSession updated.\n");
|
|
1512
|
+
this.log(` Session address: ${session.sessionAddress}`);
|
|
1513
|
+
this.log(` Permission ID: ${response.permissionId}`);
|
|
1514
|
+
this.log(` Chain: ${session.chainId}`);
|
|
1515
|
+
this.log(` Expires: ${new Date(session.expiry * 1e3).toISOString()}`);
|
|
1516
|
+
}
|
|
1517
|
+
resolveAddition(flags, chainId) {
|
|
1518
|
+
if (flags.x402) {
|
|
1519
|
+
try {
|
|
1520
|
+
return parsePermissionsConfig(buildX402Permissions(chainId, flags.limit));
|
|
1521
|
+
} catch (err) {
|
|
1522
|
+
this.error(err instanceof Error ? err.message : String(err));
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
const value = flags.permissions;
|
|
1526
|
+
let raw;
|
|
1527
|
+
if (value.trimStart().startsWith("{")) {
|
|
1528
|
+
try {
|
|
1529
|
+
raw = JSON.parse(value);
|
|
1530
|
+
} catch {
|
|
1531
|
+
this.error(`--permissions is not valid JSON: ${value}`);
|
|
1532
|
+
}
|
|
1533
|
+
} else {
|
|
1534
|
+
const content = fs.readFileSync(value, "utf-8");
|
|
1535
|
+
try {
|
|
1536
|
+
raw = JSON.parse(content);
|
|
1537
|
+
} catch {
|
|
1538
|
+
this.error(`Permissions file at ${value} is not valid JSON.`);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
return parsePermissionsConfig(raw);
|
|
1542
|
+
}
|
|
1543
|
+
};
|
|
1544
|
+
|
|
1545
|
+
export { SessionAdd as default };
|
|
1546
|
+
//# sourceMappingURL=add.js.map
|
|
1547
|
+
//# sourceMappingURL=add.js.map
|