@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,2122 @@
|
|
|
1
|
+
import { Command, Flags, Args } from '@oclif/core';
|
|
2
|
+
import * as fs5 from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import * as crypto2 from 'crypto';
|
|
6
|
+
import { randomBytes } from 'crypto';
|
|
7
|
+
import { parseAbi, encodeFunctionData, maxUint256, erc20Abi, createPublicClient, isAddress, http, zeroAddress, BaseError, ContractFunctionRevertedError, formatUnits } from 'viem';
|
|
8
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
9
|
+
import { hashTypedData, wrapTypedDataSignature } from 'viem/experimental/erc7739';
|
|
10
|
+
import { polygonAmoy, polygon, baseSepolia, base } from 'viem/chains';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
|
|
13
|
+
// src/commands/x402/pay.ts
|
|
14
|
+
var JAW_DIR = path.join(os.homedir(), ".jaw");
|
|
15
|
+
var PATHS = {
|
|
16
|
+
root: JAW_DIR,
|
|
17
|
+
config: path.join(JAW_DIR, "config.json"),
|
|
18
|
+
session: path.join(JAW_DIR, "session.json"),
|
|
19
|
+
relay: path.join(JAW_DIR, "relay.json"),
|
|
20
|
+
keystore: path.join(JAW_DIR, "keystore.json"),
|
|
21
|
+
sessionConfig: path.join(JAW_DIR, "session-config.json"),
|
|
22
|
+
x402Log: path.join(JAW_DIR, "x402-log.jsonl"),
|
|
23
|
+
paymentLock: path.join(JAW_DIR, "x402-payment.lock")
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// src/lib/config.ts
|
|
27
|
+
function ensureDir(dir) {
|
|
28
|
+
fs5.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
29
|
+
fs5.chmodSync(dir, 448);
|
|
30
|
+
}
|
|
31
|
+
function migrateConfig(config) {
|
|
32
|
+
if (config.paymasterUrl && !config.paymasters) {
|
|
33
|
+
const chainId = config.defaultChain ?? 1;
|
|
34
|
+
config.paymasters = { [chainId]: { url: config.paymasterUrl } };
|
|
35
|
+
delete config.paymasterUrl;
|
|
36
|
+
saveConfig(config);
|
|
37
|
+
}
|
|
38
|
+
return config;
|
|
39
|
+
}
|
|
40
|
+
function loadConfig() {
|
|
41
|
+
if (!fs5.existsSync(PATHS.config)) {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
const raw = fs5.readFileSync(PATHS.config, "utf-8");
|
|
45
|
+
try {
|
|
46
|
+
const config = JSON.parse(raw);
|
|
47
|
+
return migrateConfig(config);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Config file at ${PATHS.config} is not valid JSON. Run \`jaw config set apiKey=<key>\` to reset it.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function saveConfig(config) {
|
|
55
|
+
ensureDir(PATHS.root);
|
|
56
|
+
fs5.writeFileSync(PATHS.config, JSON.stringify(config, null, 2) + "\n", {
|
|
57
|
+
encoding: "utf-8",
|
|
58
|
+
mode: 384
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/lib/output.ts
|
|
63
|
+
function formatOutput(data, format) {
|
|
64
|
+
if (format === "json") {
|
|
65
|
+
return JSON.stringify(data, replaceBigInt, 2);
|
|
66
|
+
}
|
|
67
|
+
return formatHuman(data);
|
|
68
|
+
}
|
|
69
|
+
function replaceBigInt(_key, value) {
|
|
70
|
+
if (typeof value === "bigint") {
|
|
71
|
+
return value.toString();
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
function formatHuman(data, indent = 0) {
|
|
76
|
+
if (data === null || data === void 0) {
|
|
77
|
+
return "null";
|
|
78
|
+
}
|
|
79
|
+
if (typeof data === "string" || typeof data === "number" || typeof data === "boolean" || typeof data === "bigint") {
|
|
80
|
+
return String(data);
|
|
81
|
+
}
|
|
82
|
+
if (Array.isArray(data)) {
|
|
83
|
+
if (data.length === 0) return "(empty)";
|
|
84
|
+
return data.map((item, i) => `${i + 1}. ${formatHuman(item, indent + 2)}`).join("\n");
|
|
85
|
+
}
|
|
86
|
+
if (typeof data === "object") {
|
|
87
|
+
const entries = Object.entries(data);
|
|
88
|
+
if (entries.length === 0) return "(empty)";
|
|
89
|
+
const pad = " ".repeat(indent);
|
|
90
|
+
const maxKeyLen = Math.max(...entries.map(([k]) => k.length));
|
|
91
|
+
return entries.map(([key, val]) => {
|
|
92
|
+
const paddedKey = key.padEnd(maxKeyLen);
|
|
93
|
+
const valStr = typeof val === "object" && val !== null ? "\n" + formatHuman(val, indent + 2) : String(val);
|
|
94
|
+
return `${pad}${paddedKey} ${valStr}`;
|
|
95
|
+
}).join("\n");
|
|
96
|
+
}
|
|
97
|
+
return String(data);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/base-command.ts
|
|
101
|
+
var BaseCommand = class extends Command {
|
|
102
|
+
static baseFlags = {
|
|
103
|
+
output: Flags.string({
|
|
104
|
+
char: "o",
|
|
105
|
+
description: "Output format",
|
|
106
|
+
options: ["json", "human"],
|
|
107
|
+
default: "human",
|
|
108
|
+
env: "JAW_OUTPUT"
|
|
109
|
+
}),
|
|
110
|
+
chain: Flags.integer({
|
|
111
|
+
char: "c",
|
|
112
|
+
description: "Chain ID",
|
|
113
|
+
env: "JAW_CHAIN_ID"
|
|
114
|
+
}),
|
|
115
|
+
"api-key": Flags.string({
|
|
116
|
+
description: "JAW API key",
|
|
117
|
+
env: "JAW_API_KEY"
|
|
118
|
+
}),
|
|
119
|
+
yes: Flags.boolean({
|
|
120
|
+
char: "y",
|
|
121
|
+
description: "Skip confirmations (for AI agents)",
|
|
122
|
+
default: false
|
|
123
|
+
}),
|
|
124
|
+
quiet: Flags.boolean({
|
|
125
|
+
char: "q",
|
|
126
|
+
description: "Suppress non-essential output",
|
|
127
|
+
default: false
|
|
128
|
+
})
|
|
129
|
+
};
|
|
130
|
+
resolveApiKey(flags) {
|
|
131
|
+
const apiKey = flags["api-key"] ?? loadConfig().apiKey;
|
|
132
|
+
if (!apiKey) {
|
|
133
|
+
this.error("API key required. Set via --api-key, JAW_API_KEY env, or `jaw config set apiKey <key>`");
|
|
134
|
+
}
|
|
135
|
+
return apiKey;
|
|
136
|
+
}
|
|
137
|
+
resolveChainId(flags) {
|
|
138
|
+
const chainId = flags.chain ?? loadConfig().defaultChain;
|
|
139
|
+
if (!chainId) {
|
|
140
|
+
this.error("Chain ID required. Set via --chain, JAW_CHAIN_ID env, or `jaw config set defaultChain <id>`");
|
|
141
|
+
}
|
|
142
|
+
return chainId;
|
|
143
|
+
}
|
|
144
|
+
outputResult(data, format) {
|
|
145
|
+
const output = formatOutput(data, format);
|
|
146
|
+
this.log(output);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
function isLegacySession(config) {
|
|
150
|
+
return config.mode !== "eip7702";
|
|
151
|
+
}
|
|
152
|
+
function loadSessionConfig() {
|
|
153
|
+
if (!fs5.existsSync(PATHS.sessionConfig)) {
|
|
154
|
+
throw new Error("No session configured. Run `jaw session setup` first.");
|
|
155
|
+
}
|
|
156
|
+
const raw = fs5.readFileSync(PATHS.sessionConfig, "utf-8");
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(raw);
|
|
159
|
+
} catch {
|
|
160
|
+
throw new Error(`Session config at ${PATHS.sessionConfig} is corrupted. Run \`jaw session setup\` to recreate it.`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function tryLoadSessionConfig() {
|
|
164
|
+
try {
|
|
165
|
+
return loadSessionConfig();
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function loadSessionKey() {
|
|
171
|
+
if (!fs5.existsSync(PATHS.keystore)) {
|
|
172
|
+
throw new Error("No session configured. Run `jaw session setup` first.");
|
|
173
|
+
}
|
|
174
|
+
const contents = fs5.readFileSync(PATHS.keystore, "utf-8");
|
|
175
|
+
let parsed;
|
|
176
|
+
try {
|
|
177
|
+
parsed = JSON.parse(contents);
|
|
178
|
+
} catch {
|
|
179
|
+
throw new Error(`Keystore at ${PATHS.keystore} is corrupted. Run \`jaw session setup\` to recreate it.`);
|
|
180
|
+
}
|
|
181
|
+
return parsed.privateKey;
|
|
182
|
+
}
|
|
183
|
+
function keystoreExists() {
|
|
184
|
+
return fs5.existsSync(PATHS.keystore);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/x402/asset-registry.ts
|
|
188
|
+
var USDC_BY_NETWORK = {
|
|
189
|
+
"eip155:8453": {
|
|
190
|
+
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
191
|
+
chainId: 8453,
|
|
192
|
+
wireNetwork: "eip155:8453",
|
|
193
|
+
usdcName: "USD Coin",
|
|
194
|
+
usdcVersion: "2",
|
|
195
|
+
decimals: 6
|
|
196
|
+
},
|
|
197
|
+
"eip155:84532": {
|
|
198
|
+
address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
199
|
+
chainId: 84532,
|
|
200
|
+
wireNetwork: "eip155:84532",
|
|
201
|
+
usdcName: "USDC",
|
|
202
|
+
usdcVersion: "2",
|
|
203
|
+
decimals: 6
|
|
204
|
+
},
|
|
205
|
+
"eip155:137": {
|
|
206
|
+
address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
|
|
207
|
+
chainId: 137,
|
|
208
|
+
wireNetwork: "eip155:137",
|
|
209
|
+
usdcName: "USD Coin",
|
|
210
|
+
usdcVersion: "2",
|
|
211
|
+
decimals: 6
|
|
212
|
+
},
|
|
213
|
+
"eip155:80002": {
|
|
214
|
+
address: "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582",
|
|
215
|
+
chainId: 80002,
|
|
216
|
+
wireNetwork: "eip155:80002",
|
|
217
|
+
usdcName: "USDC",
|
|
218
|
+
usdcVersion: "2",
|
|
219
|
+
decimals: 6
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
function usdcForNetwork(network) {
|
|
223
|
+
return Object.hasOwn(USDC_BY_NETWORK, network) ? USDC_BY_NETWORK[network] : void 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/x402/permit2.ts
|
|
227
|
+
var PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
|
|
228
|
+
var X402_UPTO_PROXY_ADDRESS = "0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002";
|
|
229
|
+
var UPTO_VERIFIED_CHAIN_IDS = [8453, 84532];
|
|
230
|
+
var PERMIT_WITNESS_TRANSFER_FROM_TYPES = {
|
|
231
|
+
PermitWitnessTransferFrom: [
|
|
232
|
+
{ name: "permitted", type: "TokenPermissions" },
|
|
233
|
+
{ name: "spender", type: "address" },
|
|
234
|
+
{ name: "nonce", type: "uint256" },
|
|
235
|
+
{ name: "deadline", type: "uint256" },
|
|
236
|
+
{ name: "witness", type: "Witness" }
|
|
237
|
+
],
|
|
238
|
+
TokenPermissions: [
|
|
239
|
+
{ name: "token", type: "address" },
|
|
240
|
+
{ name: "amount", type: "uint256" }
|
|
241
|
+
],
|
|
242
|
+
Witness: [
|
|
243
|
+
{ name: "to", type: "address" },
|
|
244
|
+
{ name: "facilitator", type: "address" },
|
|
245
|
+
{ name: "validAfter", type: "uint256" }
|
|
246
|
+
]
|
|
247
|
+
};
|
|
248
|
+
function permit2Domain(chainId) {
|
|
249
|
+
return { name: "Permit2", chainId, verifyingContract: PERMIT2_ADDRESS };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/lib/session-bridge.ts
|
|
253
|
+
var JAW_ERC20_PAYMASTER_URL = "https://api.justaname.id/proxy/v1/rpc/erc20-paymaster";
|
|
254
|
+
function resolvePaymaster(options) {
|
|
255
|
+
if (options.paymasterUrl) {
|
|
256
|
+
return { paymasterUrl: options.paymasterUrl, paymasterContext: options.paymasterContext };
|
|
257
|
+
}
|
|
258
|
+
const configured = loadConfig().paymasters?.[options.chainId];
|
|
259
|
+
if (configured) {
|
|
260
|
+
return { paymasterUrl: configured.url, paymasterContext: configured.context };
|
|
261
|
+
}
|
|
262
|
+
if (!options.apiKey) return {};
|
|
263
|
+
const asset = usdcForNetwork(`eip155:${options.chainId}`);
|
|
264
|
+
if (!asset) {
|
|
265
|
+
console.warn(
|
|
266
|
+
`[jaw] No USDC in the x402 asset registry for chain ${options.chainId}, so no ERC-20 paymaster can be engaged. Gas will come out of the account\u2019s native balance. Set \`paymasters\` in your config to sponsor this chain.`
|
|
267
|
+
);
|
|
268
|
+
return {};
|
|
269
|
+
}
|
|
270
|
+
const url = new URL(JAW_ERC20_PAYMASTER_URL);
|
|
271
|
+
url.searchParams.set("chainId", String(options.chainId));
|
|
272
|
+
url.searchParams.set("api-key", options.apiKey);
|
|
273
|
+
return { paymasterUrl: url.toString(), paymasterContext: { token: asset.address } };
|
|
274
|
+
}
|
|
275
|
+
function explainUnchargeableSender(err, sessionAddress) {
|
|
276
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
277
|
+
if (!message.includes("Could not size the ERC-20 paymaster approval")) return err;
|
|
278
|
+
return new Error(
|
|
279
|
+
`${message}
|
|
280
|
+
|
|
281
|
+
If ${sessionAddress} holds no USDC, that is why: it pays for its own gas and cannot be charged with an empty balance. Send it 0.1 USDC, or run \`jaw session setup\` again.`,
|
|
282
|
+
{ cause: err }
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
var SessionBridge = class {
|
|
286
|
+
options;
|
|
287
|
+
session = null;
|
|
288
|
+
constructor(options) {
|
|
289
|
+
this.options = { ...options, ...resolvePaymaster(options) };
|
|
290
|
+
}
|
|
291
|
+
async getSession() {
|
|
292
|
+
if (this.session) {
|
|
293
|
+
this.checkExpiry(this.session.config);
|
|
294
|
+
return this.session;
|
|
295
|
+
}
|
|
296
|
+
const config = loadSessionConfig();
|
|
297
|
+
this.checkExpiry(config);
|
|
298
|
+
if (isLegacySession(config)) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
"This session was created by an older CLI and uses a session address separate from the session key. Run `jaw session setup` to recreate it, which offers to revoke the old permission first. `jaw session status` still shows the old session, and `jaw session revoke` still revokes it."
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (config.chainId !== this.options.chainId) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Session was created for chain ${config.chainId}, but --chain ${this.options.chainId} was requested. Run \`jaw session setup --chain ${this.options.chainId}\` to create a session for that chain.`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
let privateKeyHex = loadSessionKey();
|
|
309
|
+
const { privateKeyToAccount: privateKeyToAccount2 } = await import('viem/accounts');
|
|
310
|
+
const localAccount = privateKeyToAccount2(privateKeyHex);
|
|
311
|
+
privateKeyHex = null;
|
|
312
|
+
const { Account } = await import('@jaw.id/core');
|
|
313
|
+
const account = await Account.fromLocalAccount(
|
|
314
|
+
{
|
|
315
|
+
chainId: this.options.chainId,
|
|
316
|
+
apiKey: this.options.apiKey,
|
|
317
|
+
paymasterUrl: this.options.paymasterUrl,
|
|
318
|
+
paymasterContext: this.options.paymasterContext
|
|
319
|
+
},
|
|
320
|
+
localAccount,
|
|
321
|
+
{ eip7702: true }
|
|
322
|
+
);
|
|
323
|
+
if (account.address.toLowerCase() !== config.sessionAddress.toLowerCase()) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`Session key derives ${account.address}, but the stored session address is ${config.sessionAddress}. The keystore and session config are out of sync. Run \`jaw session setup\` to recreate the session.`
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
this.session = { account, config };
|
|
329
|
+
return this.session;
|
|
330
|
+
}
|
|
331
|
+
checkExpiry(config) {
|
|
332
|
+
if (config.expiry <= Date.now() / 1e3) {
|
|
333
|
+
const expiryDate = new Date(config.expiry * 1e3).toISOString();
|
|
334
|
+
throw new Error(`Session expired on ${expiryDate}. Run \`jaw session setup\` to create a new session.`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Approve Permit2 to move one of the payer's tokens, and return the batch id.
|
|
339
|
+
*
|
|
340
|
+
* The only call this session sends outside its permission, and the only one
|
|
341
|
+
* that can be: `JustaPermissionManager` checks every call's selector against
|
|
342
|
+
* the grant, and the x402 grant permits `transfer` alone, so an approval
|
|
343
|
+
* routed through the permission reverts before anything else happens. Sent by
|
|
344
|
+
* the session on its own balance it never reaches the manager at all, whose
|
|
345
|
+
* approval revocation and Permit2 lockdown act on the granting account and
|
|
346
|
+
* only within their own execution.
|
|
347
|
+
*
|
|
348
|
+
* Being outside the permission is exactly why it is not a general send. It
|
|
349
|
+
* takes a token and nothing else: the spender is Permit2 and the amount is
|
|
350
|
+
* the maximum, neither reachable by a caller, and the token has to be the
|
|
351
|
+
* registry's USDC for this session's chain. There is no shape of argument
|
|
352
|
+
* that turns this into an arbitrary transfer, which matters because an agent
|
|
353
|
+
* reaches the tools that reach this.
|
|
354
|
+
*/
|
|
355
|
+
async approvePermit2(token) {
|
|
356
|
+
const { account, config } = await this.getSession();
|
|
357
|
+
const usdc = usdcForNetwork(`eip155:${config.chainId}`);
|
|
358
|
+
if (!usdc || token.toLowerCase() !== usdc.address.toLowerCase()) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`Refusing to approve Permit2 for ${token}: only the registry USDC on chain ${config.chainId} is allowed.`
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
const data = encodeFunctionData({
|
|
364
|
+
abi: erc20Abi,
|
|
365
|
+
functionName: "approve",
|
|
366
|
+
args: [PERMIT2_ADDRESS, maxUint256]
|
|
367
|
+
});
|
|
368
|
+
try {
|
|
369
|
+
const sent = await account.sendCalls([{ to: usdc.address, data }]);
|
|
370
|
+
const id = typeof sent === "string" ? sent : sent?.id;
|
|
371
|
+
if (!id) throw new Error("approval submitted but no call id was returned");
|
|
372
|
+
return id;
|
|
373
|
+
} catch (err) {
|
|
374
|
+
throw explainUnchargeableSender(err, config.sessionAddress);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async request(method, params) {
|
|
378
|
+
const { account, config } = await this.getSession();
|
|
379
|
+
switch (method) {
|
|
380
|
+
case "eth_requestAccounts":
|
|
381
|
+
case "eth_accounts":
|
|
382
|
+
return [config.sessionAddress];
|
|
383
|
+
case "wallet_sendCalls": {
|
|
384
|
+
const payload = Array.isArray(params) ? params[0] : params;
|
|
385
|
+
const { calls } = payload;
|
|
386
|
+
const sendOptions = { permissionId: config.permissionId };
|
|
387
|
+
try {
|
|
388
|
+
return await account.sendCalls(calls, sendOptions);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
throw explainUnchargeableSender(err, config.sessionAddress);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
case "wallet_getCallsStatus": {
|
|
394
|
+
const batchId = Array.isArray(params) ? params[0] : params;
|
|
395
|
+
return account.getCallStatus(batchId);
|
|
396
|
+
}
|
|
397
|
+
// Refused rather than absent, so the reason is on screen instead of a
|
|
398
|
+
// caller reading "not supported in auto mode" and looking for a flag. See
|
|
399
|
+
// `supportsSessionMode` in rpc-classifier.ts for why.
|
|
400
|
+
case "personal_sign":
|
|
401
|
+
case "eth_signTypedData_v4":
|
|
402
|
+
throw new Error(
|
|
403
|
+
`${method} is not available in auto mode: a signature the session makes is not a call, so it never reaches the spend caps or the ledger. Run it through the browser instead.`
|
|
404
|
+
);
|
|
405
|
+
case "wallet_grantPermissions":
|
|
406
|
+
throw new Error("Requires browser \u2014 run `jaw session setup`.");
|
|
407
|
+
case "wallet_revokePermissions":
|
|
408
|
+
throw new Error("Requires browser \u2014 run `jaw session revoke`.");
|
|
409
|
+
default:
|
|
410
|
+
throw new Error(`Method ${method} is not supported in auto mode.`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
close() {
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
var isPayableAddress = (value) => typeof value === "string" && isAddress(value);
|
|
417
|
+
var isHexShaped = (value) => typeof value === "string" && /^0x[0-9a-fA-F]{40}$/.test(value);
|
|
418
|
+
var isZeroAddress = (value) => /^0x0{40}$/.test(value);
|
|
419
|
+
|
|
420
|
+
// src/x402/scheme-exact-evm.ts
|
|
421
|
+
var TRANSFER_WITH_AUTHORIZATION_TYPES = {
|
|
422
|
+
TransferWithAuthorization: [
|
|
423
|
+
{ name: "from", type: "address" },
|
|
424
|
+
{ name: "to", type: "address" },
|
|
425
|
+
{ name: "value", type: "uint256" },
|
|
426
|
+
{ name: "validAfter", type: "uint256" },
|
|
427
|
+
{ name: "validBefore", type: "uint256" },
|
|
428
|
+
{ name: "nonce", type: "bytes32" }
|
|
429
|
+
]
|
|
430
|
+
};
|
|
431
|
+
async function buildExactPayment(requirement, from, sign, opts = {}) {
|
|
432
|
+
if (requirement.scheme !== "exact") {
|
|
433
|
+
throw new Error(`Not an exact requirement: ${requirement.scheme}`);
|
|
434
|
+
}
|
|
435
|
+
const asset = usdcForNetwork(requirement.network);
|
|
436
|
+
if (!asset) throw new Error(`Unsupported x402 network: ${requirement.network}`);
|
|
437
|
+
if (requirement.asset.toLowerCase() !== asset.address.toLowerCase()) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`x402 asset mismatch on ${requirement.network}: server asked for ${requirement.asset}, known USDC is ${asset.address}`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
for (const [field, value] of [
|
|
443
|
+
["asset", requirement.asset],
|
|
444
|
+
["payTo", requirement.payTo]
|
|
445
|
+
]) {
|
|
446
|
+
if (!isPayableAddress(value)) {
|
|
447
|
+
throw new Error(`x402 ${field} is not a readable address on ${requirement.network}: ${value}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (isZeroAddress(requirement.payTo)) {
|
|
451
|
+
throw new Error(`x402 payTo is the zero address on ${requirement.network}`);
|
|
452
|
+
}
|
|
453
|
+
const verifyingContract = asset.address;
|
|
454
|
+
const name = typeof requirement.extra?.["name"] === "string" ? requirement.extra["name"] : asset.usdcName;
|
|
455
|
+
const version = typeof requirement.extra?.["version"] === "string" ? requirement.extra["version"] : asset.usdcVersion;
|
|
456
|
+
const nowSec = opts.now ?? Math.floor(Date.now() / 1e3);
|
|
457
|
+
const validAfter = "0";
|
|
458
|
+
const SETTLEMENT_WINDOW_FLOOR2 = 600;
|
|
459
|
+
const window = Math.max(requirement.maxTimeoutSeconds || 0, SETTLEMENT_WINDOW_FLOOR2);
|
|
460
|
+
const validBefore = String(nowSec + window);
|
|
461
|
+
const nonce = opts.nonce ?? `0x${randomBytes(32).toString("hex")}`;
|
|
462
|
+
const authorization = {
|
|
463
|
+
from,
|
|
464
|
+
to: requirement.payTo,
|
|
465
|
+
value: requirement.amount,
|
|
466
|
+
validAfter,
|
|
467
|
+
validBefore,
|
|
468
|
+
nonce
|
|
469
|
+
};
|
|
470
|
+
const signature = await sign({
|
|
471
|
+
domain: { name, version, chainId: asset.chainId, verifyingContract },
|
|
472
|
+
types: TRANSFER_WITH_AUTHORIZATION_TYPES,
|
|
473
|
+
primaryType: "TransferWithAuthorization",
|
|
474
|
+
message: {
|
|
475
|
+
from,
|
|
476
|
+
to: requirement.payTo,
|
|
477
|
+
value: BigInt(requirement.amount),
|
|
478
|
+
validAfter: BigInt(validAfter),
|
|
479
|
+
validBefore: BigInt(validBefore),
|
|
480
|
+
nonce
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
return { x402Version: 2, accepted: requirement, payload: { signature, authorization } };
|
|
484
|
+
}
|
|
485
|
+
function encodePaymentPayload(payload) {
|
|
486
|
+
return Buffer.from(JSON.stringify(payload)).toString("base64");
|
|
487
|
+
}
|
|
488
|
+
var SETTLEMENT_WINDOW_FLOOR = 600;
|
|
489
|
+
var SETTLEMENT_WINDOW_CEILING = 3600;
|
|
490
|
+
var VALID_AFTER_SLACK = 60;
|
|
491
|
+
async function buildUptoPayment(requirement, from, sign, opts = {}) {
|
|
492
|
+
if (requirement.scheme !== "upto") {
|
|
493
|
+
throw new Error(`Not an upto requirement: ${requirement.scheme}`);
|
|
494
|
+
}
|
|
495
|
+
const asset = usdcForNetwork(requirement.network);
|
|
496
|
+
if (!asset) throw new Error(`Unsupported x402 network: ${requirement.network}`);
|
|
497
|
+
if (!UPTO_VERIFIED_CHAIN_IDS.includes(asset.chainId)) {
|
|
498
|
+
throw new Error(
|
|
499
|
+
`x402 upto is not available on ${requirement.network}: the settlement proxy is only verified on chain ids ${UPTO_VERIFIED_CHAIN_IDS.join(", ")}`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
if (requirement.asset.toLowerCase() !== asset.address.toLowerCase()) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
`x402 asset mismatch on ${requirement.network}: server asked for ${requirement.asset}, known USDC is ${asset.address}`
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
for (const [field, value] of [
|
|
508
|
+
["asset", requirement.asset],
|
|
509
|
+
["payTo", requirement.payTo]
|
|
510
|
+
]) {
|
|
511
|
+
if (!isPayableAddress(value)) {
|
|
512
|
+
throw new Error(`x402 ${field} is not a readable address on ${requirement.network}: ${value}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (isZeroAddress(requirement.payTo)) {
|
|
516
|
+
throw new Error(`x402 payTo is the zero address on ${requirement.network}`);
|
|
517
|
+
}
|
|
518
|
+
const advertisedFacilitator = requirement.extra?.["facilitatorAddress"];
|
|
519
|
+
if (!isHexShaped(advertisedFacilitator) || isZeroAddress(advertisedFacilitator)) {
|
|
520
|
+
throw new Error(
|
|
521
|
+
`x402 upto needs a settling facilitator in extra.facilitatorAddress on ${requirement.network}, got ${JSON.stringify(advertisedFacilitator)}`
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
if (!isPayableAddress(advertisedFacilitator)) {
|
|
525
|
+
throw new Error(
|
|
526
|
+
`x402 extra.facilitatorAddress is not a readable address on ${requirement.network}: ${advertisedFacilitator}`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
const nowSec = opts.now ?? Math.floor(Date.now() / 1e3);
|
|
530
|
+
const window = Math.min(
|
|
531
|
+
Math.max(requirement.maxTimeoutSeconds || 0, SETTLEMENT_WINDOW_FLOOR),
|
|
532
|
+
SETTLEMENT_WINDOW_CEILING
|
|
533
|
+
);
|
|
534
|
+
const deadline = BigInt(nowSec + window);
|
|
535
|
+
const validAfter = BigInt(Math.max(nowSec - VALID_AFTER_SLACK, 0));
|
|
536
|
+
const nonce = opts.nonce ?? `0x${randomBytes(32).toString("hex")}`;
|
|
537
|
+
const message = {
|
|
538
|
+
permitted: { token: asset.address, amount: BigInt(requirement.amount) },
|
|
539
|
+
spender: X402_UPTO_PROXY_ADDRESS,
|
|
540
|
+
nonce: BigInt(nonce),
|
|
541
|
+
deadline,
|
|
542
|
+
witness: { to: requirement.payTo, facilitator: advertisedFacilitator, validAfter }
|
|
543
|
+
};
|
|
544
|
+
const signature = await sign({
|
|
545
|
+
domain: permit2Domain(asset.chainId),
|
|
546
|
+
types: PERMIT_WITNESS_TRANSFER_FROM_TYPES,
|
|
547
|
+
primaryType: "PermitWitnessTransferFrom",
|
|
548
|
+
message
|
|
549
|
+
});
|
|
550
|
+
const permit2Authorization = {
|
|
551
|
+
permitted: { token: requirement.asset, amount: message.permitted.amount.toString() },
|
|
552
|
+
from,
|
|
553
|
+
spender: message.spender,
|
|
554
|
+
nonce,
|
|
555
|
+
deadline: deadline.toString(),
|
|
556
|
+
witness: { to: requirement.payTo, facilitator: advertisedFacilitator, validAfter: validAfter.toString() }
|
|
557
|
+
};
|
|
558
|
+
return { x402Version: 2, accepted: requirement, payload: { signature, permit2Authorization } };
|
|
559
|
+
}
|
|
560
|
+
var JAW_RPC_URL = "https://api.justaname.id/proxy/v1/rpc";
|
|
561
|
+
var CHAINS = {
|
|
562
|
+
[base.id]: base,
|
|
563
|
+
[baseSepolia.id]: baseSepolia,
|
|
564
|
+
[polygon.id]: polygon,
|
|
565
|
+
[polygonAmoy.id]: polygonAmoy
|
|
566
|
+
};
|
|
567
|
+
for (const chainId of Object.values(USDC_BY_NETWORK).map((a) => a.chainId)) {
|
|
568
|
+
if (!CHAINS[chainId]) {
|
|
569
|
+
throw new Error(
|
|
570
|
+
`x402 balance: USDC registry has chain ${chainId} but no viem chain is mapped for it in balance.ts`
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
var clients = /* @__PURE__ */ new Map();
|
|
575
|
+
function rpcTransport(chainId, apiKey) {
|
|
576
|
+
if (!apiKey) return http();
|
|
577
|
+
return http(`${JAW_RPC_URL}?chainId=${chainId}&api-key=${apiKey}`);
|
|
578
|
+
}
|
|
579
|
+
function publicClientFor(chainId) {
|
|
580
|
+
const chain = CHAINS[chainId];
|
|
581
|
+
if (!chain) throw new Error(`x402: no viem chain configured for chainId ${chainId}`);
|
|
582
|
+
const apiKey = loadConfig().apiKey;
|
|
583
|
+
const key = `${chainId}:${apiKey ?? ""}`;
|
|
584
|
+
let client = clients.get(key);
|
|
585
|
+
if (!client) {
|
|
586
|
+
client = createPublicClient({ chain, transport: rpcTransport(chainId, apiKey) });
|
|
587
|
+
clients.set(key, client);
|
|
588
|
+
}
|
|
589
|
+
return client;
|
|
590
|
+
}
|
|
591
|
+
var readOnChain = (asset, owner) => publicClientFor(asset.chainId).readContract({
|
|
592
|
+
address: asset.address,
|
|
593
|
+
abi: erc20Abi,
|
|
594
|
+
functionName: "balanceOf",
|
|
595
|
+
args: [owner]
|
|
596
|
+
});
|
|
597
|
+
async function usdcBalance(network, owner, read = readOnChain) {
|
|
598
|
+
const asset = usdcForNetwork(network);
|
|
599
|
+
if (!asset) throw new Error(`Unsupported x402 network: ${network}`);
|
|
600
|
+
const raw = await read(asset, owner);
|
|
601
|
+
return { network, asset: asset.address, raw: raw.toString(), formatted: formatUnits(raw, asset.decimals) };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/x402/payer.ts
|
|
605
|
+
var EIP7702_CODE_PREFIX = "0xef0100";
|
|
606
|
+
var ERC20_ALLOWANCE_ABI = parseAbi(["function allowance(address owner, address spender) view returns (uint256)"]);
|
|
607
|
+
var EIP712_DOMAIN_ABI = parseAbi([
|
|
608
|
+
"function eip712Domain() view returns (bytes1 fields, string name, string version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] extensions)"
|
|
609
|
+
]);
|
|
610
|
+
var Eip3009EoaPayer = class _Eip3009EoaPayer {
|
|
611
|
+
address;
|
|
612
|
+
signTypedData;
|
|
613
|
+
signHash;
|
|
614
|
+
/**
|
|
615
|
+
* eip712Domain() of the delegate, cached per chain after the first wrapped
|
|
616
|
+
* payment there. Keyed by chainId: the domain embeds block.chainid, so a
|
|
617
|
+
* domain read on one chain must never sign an envelope for another.
|
|
618
|
+
*/
|
|
619
|
+
accountDomainByChain = /* @__PURE__ */ new Map();
|
|
620
|
+
constructor(address, signTypedData, signHash) {
|
|
621
|
+
this.address = address;
|
|
622
|
+
this.signTypedData = signTypedData;
|
|
623
|
+
this.signHash = signHash;
|
|
624
|
+
}
|
|
625
|
+
/** Load the session key from the keystore and build a pull-mode payer. */
|
|
626
|
+
static fromSessionKey() {
|
|
627
|
+
if (!keystoreExists()) {
|
|
628
|
+
throw new Error("No session key. Run `jaw session setup` to enable autonomous payments.");
|
|
629
|
+
}
|
|
630
|
+
const account = privateKeyToAccount(loadSessionKey());
|
|
631
|
+
const signTypedData = (typedData) => account.signTypedData(typedData);
|
|
632
|
+
const signHash = (hash) => account.sign({ hash });
|
|
633
|
+
return new _Eip3009EoaPayer(account.address, signTypedData, signHash);
|
|
634
|
+
}
|
|
635
|
+
async pay(requirement, opts) {
|
|
636
|
+
const sign = await this.isDelegated(requirement.network) ? this.wrappedSigner() : this.signTypedData;
|
|
637
|
+
if (requirement.scheme === "upto") {
|
|
638
|
+
await this.assertPermit2Approved(requirement, opts?.permit2Allowance);
|
|
639
|
+
return buildUptoPayment(requirement, this.address, sign, opts);
|
|
640
|
+
}
|
|
641
|
+
return buildExactPayment(requirement, this.address, sign, opts);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Refuse an `upto` payment the payer has not enabled, before signing it.
|
|
645
|
+
*
|
|
646
|
+
* Permit2 moves tokens through the canonical ERC-20 allowance, so a payer that
|
|
647
|
+
* never approved it produces an authorization the proxy cannot execute. The
|
|
648
|
+
* settlement then fails, and by the ledger's rule a failed attempt reserves its
|
|
649
|
+
* whole ceiling against the cap, which spends the user's budget on a payment
|
|
650
|
+
* that could never have worked. This is the same trade the delegation check
|
|
651
|
+
* above already makes: refusing before signing costs a retry, guessing costs
|
|
652
|
+
* the budget.
|
|
653
|
+
*
|
|
654
|
+
* The approval is granted once per chain and is not automatic yet.
|
|
655
|
+
*
|
|
656
|
+
* `known` is the figure the funder already read. It is taken only when it
|
|
657
|
+
* covers this payment, so the check can be satisfied early but never talked
|
|
658
|
+
* down: anything short falls through to the chain. The read stays for every
|
|
659
|
+
* caller that arrives without one, since a payer signing outside the funding
|
|
660
|
+
* hook has nothing else between it and an unsettleable signature.
|
|
661
|
+
*/
|
|
662
|
+
async assertPermit2Approved(requirement, known) {
|
|
663
|
+
const asset = usdcForNetwork(requirement.network);
|
|
664
|
+
if (!asset) return;
|
|
665
|
+
const needed = BigInt(requirement.amount);
|
|
666
|
+
if (known !== void 0 && known >= needed) return;
|
|
667
|
+
const allowance = await this.permit2Allowance(asset);
|
|
668
|
+
if (allowance < needed) {
|
|
669
|
+
throw new Error(
|
|
670
|
+
`The payer ${this.address} has approved Permit2 for ${allowance} of ${asset.address} on ${requirement.network}, and this payment authorizes up to ${needed}. Permit2 moves the token through that allowance, so the payment could not settle. Approve Permit2 once on this chain and retry.`
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
permit2Allowance(asset) {
|
|
675
|
+
return publicClientFor(asset.chainId).readContract({
|
|
676
|
+
address: asset.address,
|
|
677
|
+
abi: ERC20_ALLOWANCE_ABI,
|
|
678
|
+
functionName: "allowance",
|
|
679
|
+
args: [this.address, PERMIT2_ADDRESS]
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* True once the EOA carries an EIP-7702 delegation designator on-chain, which
|
|
684
|
+
* decides whether USDC will route this signature through ecrecover or
|
|
685
|
+
* EIP-1271, and so which of the two signatures to produce.
|
|
686
|
+
*
|
|
687
|
+
* Throws rather than guessing when the chain cannot be read. Guessing raw was
|
|
688
|
+
* the old default, from when a session was usually never delegated; a session
|
|
689
|
+
* is delegated from its first userOp now, so the guess is wrong nearly every
|
|
690
|
+
* time it is made. And the guess is not free: a raw signature against a
|
|
691
|
+
* delegated account is refused by the settlement endpoint, which reads as a
|
|
692
|
+
* failed payment, and a failed payment counts against the session cap on the
|
|
693
|
+
* grounds that the facilitator may have broadcast it anyway. It cannot have,
|
|
694
|
+
* since USDC rejects the signature, so guessing spends the user's budget on a
|
|
695
|
+
* payment that could never have settled. Refusing before signing costs a
|
|
696
|
+
* retry instead.
|
|
697
|
+
*/
|
|
698
|
+
async isDelegated(network) {
|
|
699
|
+
const asset = usdcForNetwork(network);
|
|
700
|
+
if (!asset) return false;
|
|
701
|
+
const code = await publicClientFor(asset.chainId).getCode({ address: this.address });
|
|
702
|
+
return (code ?? "0x").toLowerCase().startsWith(EIP7702_CODE_PREFIX);
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* ERC-7739 wrapped signer for the delegated (EIP-1271) validation path.
|
|
706
|
+
*
|
|
707
|
+
* JustanAccount answers 1271 with Solady's ERC-7739 validation, which rejects
|
|
708
|
+
* raw signatures from on-chain callers by design (anti cross-account replay).
|
|
709
|
+
* So the key signs a nested TypedDataSign envelope carrying the account's own
|
|
710
|
+
* domain, and ships a blob the account unwraps. USDC v2.2 accepts
|
|
711
|
+
* arbitrary-length `bytes` signatures, so it travels on the normal x402 wire.
|
|
712
|
+
*
|
|
713
|
+
* The envelope and the blob come from viem, which derives the contents type
|
|
714
|
+
* from the typed data instead of taking a hand-written string, so the type
|
|
715
|
+
* cannot drift from what is being signed. `erc7739.vectors.test.ts` pins the
|
|
716
|
+
* bytes both produce against a payment that settled on chain.
|
|
717
|
+
*/
|
|
718
|
+
wrappedSigner() {
|
|
719
|
+
return async (typedData) => {
|
|
720
|
+
const verifierDomain = await this.readAccountDomain(typedData.domain.chainId);
|
|
721
|
+
const digest = hashTypedData({ ...typedData, verifierDomain });
|
|
722
|
+
const signature = await this.signHash(digest);
|
|
723
|
+
return wrapTypedDataSignature({ ...typedData, signature });
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
/** Read (once per chain) the delegate's EIP-712 domain from the account. */
|
|
727
|
+
async readAccountDomain(chainId) {
|
|
728
|
+
const cached = this.accountDomainByChain.get(chainId);
|
|
729
|
+
if (cached) return cached;
|
|
730
|
+
const [, name, version, domainChainId, verifyingContract, salt] = await publicClientFor(chainId).readContract({
|
|
731
|
+
address: this.address,
|
|
732
|
+
abi: EIP712_DOMAIN_ABI,
|
|
733
|
+
functionName: "eip712Domain"
|
|
734
|
+
});
|
|
735
|
+
const domain = { name, version, chainId: domainChainId, verifyingContract, salt };
|
|
736
|
+
this.accountDomainByChain.set(chainId, domain);
|
|
737
|
+
return domain;
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
// src/lib/errors.ts
|
|
742
|
+
function errorMessage(err) {
|
|
743
|
+
return err instanceof Error ? err.message : String(err);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// src/x402/amount.ts
|
|
747
|
+
function parseBigInt(value) {
|
|
748
|
+
if (value === void 0 || value === null || value === "") return null;
|
|
749
|
+
try {
|
|
750
|
+
return BigInt(value);
|
|
751
|
+
} catch {
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
function parseNonNegativeBigInt(value) {
|
|
756
|
+
const parsed = parseBigInt(value);
|
|
757
|
+
return parsed !== null && parsed >= 0n ? parsed : void 0;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// src/x402/period.ts
|
|
761
|
+
var PERIOD_UNITS = ["minute", "hour", "day", "week", "month", "forever"];
|
|
762
|
+
function isPeriodUnit(value) {
|
|
763
|
+
return typeof value === "string" && PERIOD_UNITS.includes(value);
|
|
764
|
+
}
|
|
765
|
+
function normalizePeriod(unit, multiplier) {
|
|
766
|
+
const m = Math.max(1, Math.floor(multiplier ?? 1));
|
|
767
|
+
if (unit === "year") return { unit: "month", multiplier: m * 12 };
|
|
768
|
+
if (isPeriodUnit(unit)) return { unit, multiplier: m };
|
|
769
|
+
return void 0;
|
|
770
|
+
}
|
|
771
|
+
var FIXED_UNIT_SECONDS = {
|
|
772
|
+
minute: 60,
|
|
773
|
+
hour: 3600,
|
|
774
|
+
day: 86400,
|
|
775
|
+
week: 604800
|
|
776
|
+
};
|
|
777
|
+
function addMonths(unixSeconds, months) {
|
|
778
|
+
const d = new Date(unixSeconds * 1e3);
|
|
779
|
+
const day = d.getUTCDate();
|
|
780
|
+
const target = new Date(
|
|
781
|
+
Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months, 1, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds())
|
|
782
|
+
);
|
|
783
|
+
const daysInTarget = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate();
|
|
784
|
+
target.setUTCDate(Math.min(day, daysInTarget));
|
|
785
|
+
return Math.floor(target.getTime() / 1e3);
|
|
786
|
+
}
|
|
787
|
+
function currentPeriodWindow(input) {
|
|
788
|
+
const { anchor, unit, now, permissionEnd } = input;
|
|
789
|
+
const multiplier = Math.max(1, Math.floor(input.multiplier ?? 1));
|
|
790
|
+
if (unit === "forever") {
|
|
791
|
+
return { start: anchor, end: permissionEnd };
|
|
792
|
+
}
|
|
793
|
+
let start;
|
|
794
|
+
let end;
|
|
795
|
+
if (unit === "month") {
|
|
796
|
+
let index = 0;
|
|
797
|
+
let cursor = anchor;
|
|
798
|
+
let next = addMonths(anchor, multiplier);
|
|
799
|
+
while (next <= now) {
|
|
800
|
+
index += 1;
|
|
801
|
+
cursor = next;
|
|
802
|
+
next = addMonths(anchor, (index + 1) * multiplier);
|
|
803
|
+
}
|
|
804
|
+
start = cursor;
|
|
805
|
+
end = next;
|
|
806
|
+
} else {
|
|
807
|
+
const duration = FIXED_UNIT_SECONDS[unit] * multiplier;
|
|
808
|
+
const elapsed = Math.max(0, now - anchor);
|
|
809
|
+
const index = Math.floor(elapsed / duration);
|
|
810
|
+
start = anchor + index * duration;
|
|
811
|
+
end = start + duration;
|
|
812
|
+
}
|
|
813
|
+
return { start, end: Math.min(end, permissionEnd) };
|
|
814
|
+
}
|
|
815
|
+
function describePeriod(unit, multiplier) {
|
|
816
|
+
if (unit === "forever") return "the whole permission";
|
|
817
|
+
return describeSpendPeriod(unit, multiplier);
|
|
818
|
+
}
|
|
819
|
+
function describeSpendPeriod(unit, multiplier) {
|
|
820
|
+
const n = Math.max(1, Math.floor(multiplier ?? 1));
|
|
821
|
+
return n === 1 ? unit : `${n} ${unit}s`;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// src/x402/types.ts
|
|
825
|
+
var X402_SCHEMES = ["exact", "upto"];
|
|
826
|
+
function isX402Scheme(value) {
|
|
827
|
+
return typeof value === "string" && X402_SCHEMES.includes(value);
|
|
828
|
+
}
|
|
829
|
+
var X402_HEADERS = {
|
|
830
|
+
required: "PAYMENT-REQUIRED",
|
|
831
|
+
signature: "PAYMENT-SIGNATURE",
|
|
832
|
+
response: "PAYMENT-RESPONSE"
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
// src/x402/policy.ts
|
|
836
|
+
var DEFAULT_X402_POLICY = {
|
|
837
|
+
maxAmountPerPayment: "1000000",
|
|
838
|
+
// 1 USDC per payment
|
|
839
|
+
maxTotalPerSession: "10000000",
|
|
840
|
+
// 10 USDC per process
|
|
841
|
+
allowedAssets: Object.values(USDC_BY_NETWORK).map((asset) => asset.address),
|
|
842
|
+
allowedNetworks: Object.keys(USDC_BY_NETWORK)
|
|
843
|
+
};
|
|
844
|
+
function policyFromPermission(permission, chainId) {
|
|
845
|
+
if (!permission) return {};
|
|
846
|
+
const usdc = Object.values(USDC_BY_NETWORK).find((asset) => asset.chainId === chainId);
|
|
847
|
+
if (!usdc) return {};
|
|
848
|
+
const forToken = permission.spends.filter((spend) => spend.token.toLowerCase() === usdc.address.toLowerCase());
|
|
849
|
+
if (forToken.length === 0) return {};
|
|
850
|
+
const start = new Date(permission.start * 1e3);
|
|
851
|
+
if (Number.isNaN(start.getTime())) return {};
|
|
852
|
+
const anchor = start.toISOString();
|
|
853
|
+
const perPeriod = [];
|
|
854
|
+
for (const spend of forToken) {
|
|
855
|
+
let allowance;
|
|
856
|
+
try {
|
|
857
|
+
const parsed = BigInt(spend.allowance);
|
|
858
|
+
if (parsed < 0n) continue;
|
|
859
|
+
allowance = parsed.toString();
|
|
860
|
+
} catch {
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
const period = normalizePeriod(spend.unit, spend.multiplier);
|
|
864
|
+
if (!period) continue;
|
|
865
|
+
perPeriod.push({ allowance, unit: period.unit, multiplier: period.multiplier, anchor });
|
|
866
|
+
}
|
|
867
|
+
if (perPeriod.length === 0) return {};
|
|
868
|
+
return {
|
|
869
|
+
// The registry's canonical address, not the permission's literal string:
|
|
870
|
+
// they match case-insensitively and this seeds an allowlist compared that
|
|
871
|
+
// way.
|
|
872
|
+
allowedAssets: [usdc.address],
|
|
873
|
+
allowedNetworks: [usdc.wireNetwork],
|
|
874
|
+
perPeriod
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
function resolveX402Policy(configPolicy, grantPolicy) {
|
|
878
|
+
const merged = { ...DEFAULT_X402_POLICY, ...grantPolicy ?? {}, ...configPolicy ?? {} };
|
|
879
|
+
if (grantPolicy?.perPeriod !== void 0 && configPolicy?.maxTotalPerSession === void 0) {
|
|
880
|
+
delete merged.maxTotalPerSession;
|
|
881
|
+
}
|
|
882
|
+
return merged;
|
|
883
|
+
}
|
|
884
|
+
function resolveSessionX402Policy(configPolicy, session) {
|
|
885
|
+
return resolveX402Policy(configPolicy, policyFromPermission(session?.permission, session?.chainId ?? 0));
|
|
886
|
+
}
|
|
887
|
+
function sameLimit(a, b) {
|
|
888
|
+
return a.unit === b.unit && a.multiplier === b.multiplier && a.allowance === b.allowance;
|
|
889
|
+
}
|
|
890
|
+
function topUpCeiling(policy, used = {}) {
|
|
891
|
+
const left = (cap, alreadyUsed = 0n) => {
|
|
892
|
+
const parsed = parseNonNegativeBigInt(cap);
|
|
893
|
+
if (parsed === void 0) return void 0;
|
|
894
|
+
return parsed > alreadyUsed ? parsed - alreadyUsed : 0n;
|
|
895
|
+
};
|
|
896
|
+
const caps = [
|
|
897
|
+
// Every limit the policy holds, not every entry the caller built. The
|
|
898
|
+
// contract charges all of them, so a refill sized against any single one
|
|
899
|
+
// can still be refused by another, and a limit whose usage could not be
|
|
900
|
+
// computed still bounds the pull at its full width rather than vanishing.
|
|
901
|
+
// An allowance that cannot be read bounds at zero rather than dropping out.
|
|
902
|
+
// `checkPolicy` refuses outright on the same input, and letting it vanish
|
|
903
|
+
// here is the shape this set out to remove: with the session default
|
|
904
|
+
// deleted by a seeded grant, nothing local would bound the pull.
|
|
905
|
+
...(policy.perPeriod ?? []).map(
|
|
906
|
+
(limit) => left(limit.allowance, (used.periodUsage ?? []).find((entry) => sameLimit(entry, limit))?.toppedUp) ?? 0n
|
|
907
|
+
),
|
|
908
|
+
left(policy.maxTotalPerSession, used.spentThisSession)
|
|
909
|
+
].filter((cap) => cap !== void 0);
|
|
910
|
+
return caps.length > 0 ? caps.reduce((a, b) => a < b ? a : b) : void 0;
|
|
911
|
+
}
|
|
912
|
+
var has = (list) => Array.isArray(list) && list.length > 0;
|
|
913
|
+
var eqAddr = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
914
|
+
var asks = (requirement) => requirement.scheme === "upto" ? `up to ${requirement.amount}` : requirement.amount;
|
|
915
|
+
function checkPolicy(requirement, policy, ctx = {}) {
|
|
916
|
+
if (!isX402Scheme(requirement.scheme)) {
|
|
917
|
+
return { ok: false, reason: `unsupported scheme: ${String(requirement.scheme)}` };
|
|
918
|
+
}
|
|
919
|
+
if (requirement.scheme === "upto") {
|
|
920
|
+
const asset = usdcForNetwork(requirement.network);
|
|
921
|
+
if (!asset) {
|
|
922
|
+
return { ok: false, reason: `unsupported x402 network: ${requirement.network}` };
|
|
923
|
+
}
|
|
924
|
+
if (!UPTO_VERIFIED_CHAIN_IDS.includes(asset.chainId)) {
|
|
925
|
+
return {
|
|
926
|
+
ok: false,
|
|
927
|
+
reason: `x402 upto is not available on ${requirement.network}: the settlement proxy is only verified on chain ids ${UPTO_VERIFIED_CHAIN_IDS.join(", ")}`
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
const facilitator = requirement.extra?.["facilitatorAddress"];
|
|
931
|
+
if (!isHexShaped(facilitator) || isZeroAddress(facilitator)) {
|
|
932
|
+
return {
|
|
933
|
+
ok: false,
|
|
934
|
+
reason: `x402 upto needs a settling facilitator in extra.facilitatorAddress on ${requirement.network}, got ${JSON.stringify(facilitator)}`
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
if (!isPayableAddress(facilitator)) {
|
|
938
|
+
return {
|
|
939
|
+
ok: false,
|
|
940
|
+
reason: `extra.facilitatorAddress is not a readable address on ${requirement.network}: ${facilitator}`
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
for (const [field, value] of [
|
|
945
|
+
["asset", requirement.asset],
|
|
946
|
+
["payTo", requirement.payTo]
|
|
947
|
+
]) {
|
|
948
|
+
if (!isPayableAddress(value)) {
|
|
949
|
+
return { ok: false, reason: `${field} is not a readable address on ${requirement.network}: ${value}` };
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
if (isZeroAddress(requirement.payTo)) {
|
|
953
|
+
return { ok: false, reason: `payTo is the zero address on ${requirement.network}` };
|
|
954
|
+
}
|
|
955
|
+
if (has(policy.allowedNetworks) && !policy.allowedNetworks.includes(requirement.network)) {
|
|
956
|
+
return { ok: false, reason: `network not allowed: ${requirement.network}` };
|
|
957
|
+
}
|
|
958
|
+
if (has(policy.allowedAssets) && !policy.allowedAssets.some((a) => eqAddr(a, requirement.asset))) {
|
|
959
|
+
return { ok: false, reason: `asset not allowed: ${requirement.asset}` };
|
|
960
|
+
}
|
|
961
|
+
if (has(policy.allowedPayTo) && !policy.allowedPayTo.some((a) => eqAddr(a, requirement.payTo))) {
|
|
962
|
+
return { ok: false, reason: `payTo not allowed: ${requirement.payTo}` };
|
|
963
|
+
}
|
|
964
|
+
if (has(policy.allowedHosts) && (!ctx.host || !policy.allowedHosts.includes(ctx.host))) {
|
|
965
|
+
return { ok: false, reason: `host not allowed: ${ctx.host ?? "(unknown)"}` };
|
|
966
|
+
}
|
|
967
|
+
const amount = parseBigInt(requirement.amount);
|
|
968
|
+
if (amount === null) {
|
|
969
|
+
return { ok: false, reason: `invalid amount: ${requirement.amount}` };
|
|
970
|
+
}
|
|
971
|
+
if (amount < 0n) {
|
|
972
|
+
return { ok: false, reason: `negative amount: ${requirement.amount}` };
|
|
973
|
+
}
|
|
974
|
+
if (policy.maxAmountPerPayment !== void 0) {
|
|
975
|
+
const cap = parseBigInt(policy.maxAmountPerPayment);
|
|
976
|
+
if (cap === null) {
|
|
977
|
+
return { ok: false, reason: `invalid maxAmountPerPayment in config: ${policy.maxAmountPerPayment}` };
|
|
978
|
+
}
|
|
979
|
+
if (amount > cap) {
|
|
980
|
+
return {
|
|
981
|
+
ok: false,
|
|
982
|
+
reason: `amount ${asks(requirement)} exceeds maxAmountPerPayment ${policy.maxAmountPerPayment}`
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
const exceeded = [];
|
|
987
|
+
for (const limit of policy.perPeriod ?? []) {
|
|
988
|
+
const cap = parseBigInt(limit.allowance);
|
|
989
|
+
if (cap === null) {
|
|
990
|
+
return { ok: false, reason: `invalid allowance from grant: ${limit.allowance}` };
|
|
991
|
+
}
|
|
992
|
+
const usage = (ctx.periodUsage ?? []).find((entry) => sameLimit(entry, limit));
|
|
993
|
+
const spent = usage?.spent ?? 0n;
|
|
994
|
+
if (spent + amount > cap) exceeded.push({ limit, usage });
|
|
995
|
+
}
|
|
996
|
+
if (exceeded.length > 0) {
|
|
997
|
+
const latest = exceeded.reduce(
|
|
998
|
+
(a, b) => (a.usage?.endsAt?.getTime() ?? 0) >= (b.usage?.endsAt?.getTime() ?? 0) ? a : b
|
|
999
|
+
);
|
|
1000
|
+
const others = exceeded.length - 1;
|
|
1001
|
+
const window = describePeriod(latest.limit.unit, latest.limit.multiplier);
|
|
1002
|
+
const resets = latest.usage ? `, which resets ${latest.usage.endsAt.toISOString()}` : "";
|
|
1003
|
+
return {
|
|
1004
|
+
ok: false,
|
|
1005
|
+
reason: `payment ${asks(requirement)} would exceed the granted ${latest.limit.allowance} per ${window}${resets}` + (others > 0 ? ` (${others} other limit${others === 1 ? "" : "s"} also applies)` : "")
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
if (policy.maxTotalPerSession !== void 0) {
|
|
1009
|
+
const cap = parseBigInt(policy.maxTotalPerSession);
|
|
1010
|
+
if (cap === null) {
|
|
1011
|
+
return { ok: false, reason: `invalid maxTotalPerSession in config: ${policy.maxTotalPerSession}` };
|
|
1012
|
+
}
|
|
1013
|
+
const spent = ctx.spentThisSession ?? 0n;
|
|
1014
|
+
if (spent + amount > cap) {
|
|
1015
|
+
return {
|
|
1016
|
+
ok: false,
|
|
1017
|
+
reason: `payment ${asks(requirement)} would exceed maxTotalPerSession ${policy.maxTotalPerSession} (already spent ${spent} since the session was created; raise it with \`jaw config set x402.maxTotalPerSession <base units>\`)`
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return { ok: true };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// src/x402/http.ts
|
|
1025
|
+
var b64json = (header) => {
|
|
1026
|
+
if (!header) return null;
|
|
1027
|
+
try {
|
|
1028
|
+
return JSON.parse(Buffer.from(header, "base64").toString());
|
|
1029
|
+
} catch {
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
function paymentNonceOf(payload) {
|
|
1034
|
+
const inner = payload.payload;
|
|
1035
|
+
return "authorization" in inner ? inner.authorization.nonce : inner.permit2Authorization.nonce;
|
|
1036
|
+
}
|
|
1037
|
+
function paymentDeadlineOf(payload) {
|
|
1038
|
+
const inner = payload.payload;
|
|
1039
|
+
return "authorization" in inner ? inner.authorization.validBefore : inner.permit2Authorization.deadline;
|
|
1040
|
+
}
|
|
1041
|
+
function settledAmountOf(receipt, scheme, authorized) {
|
|
1042
|
+
if (scheme !== "upto") return authorized;
|
|
1043
|
+
if (receipt?.success !== true || !settledTxHash(receipt)) return authorized;
|
|
1044
|
+
const reported = parseBigInt(receipt.amount ?? "");
|
|
1045
|
+
if (reported === null || reported < 0n) return authorized;
|
|
1046
|
+
const ceiling = parseBigInt(authorized);
|
|
1047
|
+
return ceiling !== null && reported > ceiling ? authorized : reported.toString();
|
|
1048
|
+
}
|
|
1049
|
+
function settledTxHash(receipt) {
|
|
1050
|
+
const tx = receipt?.transaction;
|
|
1051
|
+
return tx && /^0x[0-9a-fA-F]{64}$/.test(tx) ? tx : void 0;
|
|
1052
|
+
}
|
|
1053
|
+
var MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
1054
|
+
async function readBody(res) {
|
|
1055
|
+
const reader2 = res.body?.getReader();
|
|
1056
|
+
if (!reader2) {
|
|
1057
|
+
const text2 = await res.text();
|
|
1058
|
+
if (text2.length === 0) return {};
|
|
1059
|
+
try {
|
|
1060
|
+
return JSON.parse(text2);
|
|
1061
|
+
} catch {
|
|
1062
|
+
return text2;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
const chunks = [];
|
|
1066
|
+
let total = 0;
|
|
1067
|
+
try {
|
|
1068
|
+
for (; ; ) {
|
|
1069
|
+
const { done, value } = await reader2.read();
|
|
1070
|
+
if (done) break;
|
|
1071
|
+
total += value.byteLength;
|
|
1072
|
+
if (total > MAX_BODY_BYTES) {
|
|
1073
|
+
await reader2.cancel();
|
|
1074
|
+
return { error: `response body exceeded ${MAX_BODY_BYTES} bytes` };
|
|
1075
|
+
}
|
|
1076
|
+
chunks.push(value);
|
|
1077
|
+
}
|
|
1078
|
+
} finally {
|
|
1079
|
+
reader2.releaseLock?.();
|
|
1080
|
+
}
|
|
1081
|
+
if (total === 0) return {};
|
|
1082
|
+
const text = Buffer.concat(chunks).toString("utf-8");
|
|
1083
|
+
try {
|
|
1084
|
+
return JSON.parse(text);
|
|
1085
|
+
} catch {
|
|
1086
|
+
return text;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
var FETCH_TIMEOUT_MS = 3e4;
|
|
1090
|
+
async function fetchWithTimeout(url, init) {
|
|
1091
|
+
const controller = new AbortController();
|
|
1092
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
1093
|
+
try {
|
|
1094
|
+
const res = await fetch(url, { ...init, signal: controller.signal });
|
|
1095
|
+
let body;
|
|
1096
|
+
try {
|
|
1097
|
+
body = await readBody(res);
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
if (!controller.signal.aborted) throw err;
|
|
1100
|
+
body = { error: `response body timed out after ${FETCH_TIMEOUT_MS}ms` };
|
|
1101
|
+
}
|
|
1102
|
+
return { status: res.status, url: res.url, headers: res.headers, body };
|
|
1103
|
+
} finally {
|
|
1104
|
+
clearTimeout(timer);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
function hostOf(url) {
|
|
1108
|
+
try {
|
|
1109
|
+
return new URL(url).host;
|
|
1110
|
+
} catch {
|
|
1111
|
+
return void 0;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
function isPaymentUrlSecure(url) {
|
|
1115
|
+
try {
|
|
1116
|
+
const { protocol, hostname } = new URL(url);
|
|
1117
|
+
if (protocol === "https:") return true;
|
|
1118
|
+
if (protocol === "http:") return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
1119
|
+
return false;
|
|
1120
|
+
} catch {
|
|
1121
|
+
return false;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
function idempotencyKey() {
|
|
1125
|
+
return `jaw-${randomBytes(6).toString("hex")}`;
|
|
1126
|
+
}
|
|
1127
|
+
var hexAddress = z.string().regex(/^0x[0-9a-fA-F]{40}$/, "must be a 20-byte hex address");
|
|
1128
|
+
var requirementSchema = z.object({
|
|
1129
|
+
scheme: z.string(),
|
|
1130
|
+
// CAIP-2 (`namespace:reference`). Left as a free string, an unknown
|
|
1131
|
+
// network flowed verbatim into the refusal reason, the ledger, and every
|
|
1132
|
+
// later `x402 log`. Constrained at the boundary so it cannot carry a
|
|
1133
|
+
// payload at all, which is cheaper than trusting each sink to disarm it.
|
|
1134
|
+
network: z.string().regex(/^[-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32}$/, "must be a CAIP-2 network id"),
|
|
1135
|
+
amount: z.string().regex(/^\d+$/, "amount must be a base-10 integer string"),
|
|
1136
|
+
asset: hexAddress,
|
|
1137
|
+
payTo: hexAddress,
|
|
1138
|
+
// int + finite: a server sending Infinity/NaN/float here would otherwise
|
|
1139
|
+
// reach BigInt(validBefore) in the signer and throw an obscure error.
|
|
1140
|
+
maxTimeoutSeconds: z.number().int().nonnegative().finite().optional(),
|
|
1141
|
+
extra: z.record(z.unknown()).optional()
|
|
1142
|
+
}).passthrough();
|
|
1143
|
+
function selectRequirement(accepts, opts, ctx) {
|
|
1144
|
+
const policy = opts.policy ?? {};
|
|
1145
|
+
let reason = "no acceptable payment option in the 402 challenge";
|
|
1146
|
+
let best;
|
|
1147
|
+
let bestAmount = 0n;
|
|
1148
|
+
for (const raw of accepts) {
|
|
1149
|
+
const parsed = requirementSchema.safeParse(raw);
|
|
1150
|
+
if (!parsed.success) {
|
|
1151
|
+
const issue = parsed.error.issues[0];
|
|
1152
|
+
reason = `malformed payment option${issue ? ` (${issue.path.join(".")}: ${issue.message})` : ""}`;
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
const req = parsed.data;
|
|
1156
|
+
if (!isX402Scheme(req.scheme)) {
|
|
1157
|
+
reason = `unsupported scheme: ${String(req.scheme)}`;
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
if (opts.network && req.network !== opts.network) {
|
|
1161
|
+
reason = `network ${req.network} does not match requested ${opts.network}`;
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
if (opts.asset && req.asset.toLowerCase() !== opts.asset.toLowerCase()) {
|
|
1165
|
+
reason = `asset ${req.asset} does not match requested ${opts.asset}`;
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
const amount = parseBigInt(req.amount);
|
|
1169
|
+
if (amount === null) {
|
|
1170
|
+
reason = `invalid amount: ${req.amount}`;
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
if (opts.maxAmount !== void 0) {
|
|
1174
|
+
const cap = parseBigInt(opts.maxAmount);
|
|
1175
|
+
if (cap === null) {
|
|
1176
|
+
reason = `invalid maxAmount: ${opts.maxAmount}`;
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
if (amount > cap) {
|
|
1180
|
+
reason = `amount ${asks(req)} exceeds maxAmount ${opts.maxAmount}`;
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
const verdict = checkPolicy(req, policy, ctx);
|
|
1185
|
+
if (!verdict.ok) {
|
|
1186
|
+
reason = verdict.reason ?? reason;
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
const cheaper = !best || amount < bestAmount;
|
|
1190
|
+
const fixedPriceTie = !!best && amount === bestAmount && best.scheme === "upto" && req.scheme === "exact";
|
|
1191
|
+
if (cheaper || fixedPriceTie) {
|
|
1192
|
+
best = req;
|
|
1193
|
+
bestAmount = amount;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
return best ? { requirement: best } : { reason };
|
|
1197
|
+
}
|
|
1198
|
+
async function payAndFetch(url, payer, opts = {}) {
|
|
1199
|
+
const method = opts.method ?? "GET";
|
|
1200
|
+
const baseHeaders = { Accept: "application/json", ...opts.headers ?? {} };
|
|
1201
|
+
const first = await fetchWithTimeout(url, { method, headers: baseHeaders, body: opts.body });
|
|
1202
|
+
if (first.status !== 402) {
|
|
1203
|
+
return { status: first.status, body: first.body, paid: false, payer: payer.address };
|
|
1204
|
+
}
|
|
1205
|
+
const refusal = (refusedReason, extra) => ({
|
|
1206
|
+
status: 402,
|
|
1207
|
+
body: first.body,
|
|
1208
|
+
payer: payer.address,
|
|
1209
|
+
refusedReason,
|
|
1210
|
+
...extra,
|
|
1211
|
+
// After the spread, never from it. Both front ends decide whether to write a
|
|
1212
|
+
// settled row in the ledger from this field, and the ledger is what the caps
|
|
1213
|
+
// are rebuilt from, so a refusal must not be able to claim a payment.
|
|
1214
|
+
paid: false
|
|
1215
|
+
});
|
|
1216
|
+
const resource = first.url || url;
|
|
1217
|
+
if (!isPaymentUrlSecure(resource)) {
|
|
1218
|
+
return refusal("refusing to sign a payment over a non-HTTPS URL (use https, or localhost for testing)");
|
|
1219
|
+
}
|
|
1220
|
+
const challenge = b64json(first.headers.get(X402_HEADERS.required));
|
|
1221
|
+
if (!challenge || !Array.isArray(challenge.accepts)) {
|
|
1222
|
+
return refusal("missing or malformed PAYMENT-REQUIRED challenge");
|
|
1223
|
+
}
|
|
1224
|
+
const ctx = {
|
|
1225
|
+
host: hostOf(resource),
|
|
1226
|
+
spentThisSession: opts.spentThisSession,
|
|
1227
|
+
periodUsage: opts.periodUsage
|
|
1228
|
+
};
|
|
1229
|
+
const { requirement, reason } = selectRequirement(challenge.accepts, opts, ctx);
|
|
1230
|
+
if (!requirement) {
|
|
1231
|
+
return refusal(reason);
|
|
1232
|
+
}
|
|
1233
|
+
if (opts.dryRun) {
|
|
1234
|
+
return {
|
|
1235
|
+
status: 402,
|
|
1236
|
+
body: first.body,
|
|
1237
|
+
paid: false,
|
|
1238
|
+
payer: payer.address,
|
|
1239
|
+
wouldPay: {
|
|
1240
|
+
scheme: requirement.scheme,
|
|
1241
|
+
amount: requirement.amount,
|
|
1242
|
+
authorized: requirement.amount,
|
|
1243
|
+
asset: requirement.asset,
|
|
1244
|
+
network: requirement.network,
|
|
1245
|
+
payTo: requirement.payTo
|
|
1246
|
+
}
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
let topUp;
|
|
1250
|
+
let permit2Approval;
|
|
1251
|
+
let permit2Allowance;
|
|
1252
|
+
if (opts.ensureFunds) {
|
|
1253
|
+
let funded;
|
|
1254
|
+
try {
|
|
1255
|
+
funded = await opts.ensureFunds(requirement, payer.address);
|
|
1256
|
+
} catch (err) {
|
|
1257
|
+
return refusal(`payer funding failed: ${errorMessage(err)}`);
|
|
1258
|
+
}
|
|
1259
|
+
if (!funded.ok) {
|
|
1260
|
+
return refusal(funded.reason ?? "payer funding failed", {
|
|
1261
|
+
...funded.amount || funded.batchId ? { topUp: { amount: funded.amount, batchId: funded.batchId } } : {},
|
|
1262
|
+
...funded.approvalBatchId ? { permit2Approval: { batchId: funded.approvalBatchId } } : {}
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
if (funded.approvalBatchId) {
|
|
1266
|
+
permit2Approval = { batchId: funded.approvalBatchId };
|
|
1267
|
+
}
|
|
1268
|
+
permit2Allowance = funded.permit2Allowance;
|
|
1269
|
+
if (!funded.skipped) {
|
|
1270
|
+
topUp = { amount: funded.amount, batchId: funded.batchId };
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
let payload;
|
|
1274
|
+
try {
|
|
1275
|
+
payload = await payer.pay(requirement, { permit2Allowance });
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
return refusal(`payment signing failed: ${errorMessage(err)}`, { topUp, permit2Approval });
|
|
1278
|
+
}
|
|
1279
|
+
const details = {
|
|
1280
|
+
scheme: requirement.scheme,
|
|
1281
|
+
// The ceiling until a receipt says otherwise, which is the conservative
|
|
1282
|
+
// reading for `upto` and the exact figure for `exact`.
|
|
1283
|
+
amount: requirement.amount,
|
|
1284
|
+
authorized: requirement.amount,
|
|
1285
|
+
deadline: paymentDeadlineOf(payload),
|
|
1286
|
+
asset: requirement.asset,
|
|
1287
|
+
network: requirement.network,
|
|
1288
|
+
payTo: requirement.payTo,
|
|
1289
|
+
nonce: paymentNonceOf(payload)
|
|
1290
|
+
};
|
|
1291
|
+
const proof = encodePaymentPayload(payload);
|
|
1292
|
+
const retryHeaders = {
|
|
1293
|
+
...baseHeaders,
|
|
1294
|
+
[X402_HEADERS.signature]: proof,
|
|
1295
|
+
"Idempotency-Key": idempotencyKey()
|
|
1296
|
+
};
|
|
1297
|
+
let paid;
|
|
1298
|
+
try {
|
|
1299
|
+
paid = await fetchWithTimeout(resource, {
|
|
1300
|
+
method,
|
|
1301
|
+
headers: retryHeaders,
|
|
1302
|
+
body: opts.body,
|
|
1303
|
+
redirect: "manual"
|
|
1304
|
+
});
|
|
1305
|
+
} catch (err) {
|
|
1306
|
+
return refusal(`payment sent but the response never arrived: ${errorMessage(err)}`, {
|
|
1307
|
+
body: "",
|
|
1308
|
+
attemptedPayment: details,
|
|
1309
|
+
topUp,
|
|
1310
|
+
permit2Approval
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
if (paid.status >= 300 && paid.status < 400) {
|
|
1314
|
+
return {
|
|
1315
|
+
status: paid.status,
|
|
1316
|
+
body: paid.body,
|
|
1317
|
+
paid: false,
|
|
1318
|
+
payer: payer.address,
|
|
1319
|
+
attemptedPayment: details,
|
|
1320
|
+
topUp,
|
|
1321
|
+
permit2Approval,
|
|
1322
|
+
refusedReason: `settlement endpoint attempted a redirect (${paid.status}); not following it with the signed proof`
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
const receipt = b64json(paid.headers.get(X402_HEADERS.response));
|
|
1326
|
+
const body = paid.body;
|
|
1327
|
+
if (paid.status >= 400) {
|
|
1328
|
+
const reChallenge = b64json(paid.headers.get(X402_HEADERS.required));
|
|
1329
|
+
return {
|
|
1330
|
+
status: paid.status,
|
|
1331
|
+
body,
|
|
1332
|
+
paid: false,
|
|
1333
|
+
payer: payer.address,
|
|
1334
|
+
// The payment was signed and sent; surface it so an ambiguous settlement
|
|
1335
|
+
// (facilitator may have broadcast) can be reconciled by nonce.
|
|
1336
|
+
attemptedPayment: details,
|
|
1337
|
+
topUp,
|
|
1338
|
+
permit2Approval,
|
|
1339
|
+
refusedReason: receipt?.errorReason ?? reChallenge?.error ?? `settlement failed with status ${paid.status}`
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
return {
|
|
1343
|
+
status: paid.status,
|
|
1344
|
+
body,
|
|
1345
|
+
paid: true,
|
|
1346
|
+
topUp,
|
|
1347
|
+
permit2Approval,
|
|
1348
|
+
payer: payer.address,
|
|
1349
|
+
payment: {
|
|
1350
|
+
...details,
|
|
1351
|
+
amount: settledAmountOf(receipt, requirement.scheme, details.authorized),
|
|
1352
|
+
txHash: settledTxHash(receipt)
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
function appendX402Log(entry) {
|
|
1357
|
+
try {
|
|
1358
|
+
ensureDir(PATHS.root);
|
|
1359
|
+
fs5.appendFileSync(PATHS.x402Log, "\n" + JSON.stringify(entry), { encoding: "utf-8", mode: 384 });
|
|
1360
|
+
} catch (err) {
|
|
1361
|
+
const msg = errorMessage(err);
|
|
1362
|
+
process.stderr.write(`[jaw] warning: failed to write x402 ledger (${msg}); spend audit/cap may undercount
|
|
1363
|
+
`);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
function readX402Log(limit) {
|
|
1367
|
+
let raw;
|
|
1368
|
+
try {
|
|
1369
|
+
raw = fs5.readFileSync(PATHS.x402Log, "utf-8");
|
|
1370
|
+
} catch {
|
|
1371
|
+
return [];
|
|
1372
|
+
}
|
|
1373
|
+
const entries = raw.split("\n").filter((line) => line.trim().length > 0).map((line) => {
|
|
1374
|
+
try {
|
|
1375
|
+
return JSON.parse(line);
|
|
1376
|
+
} catch {
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
1379
|
+
}).filter((e) => e !== null);
|
|
1380
|
+
return limit && limit > 0 ? entries.slice(-limit) : entries;
|
|
1381
|
+
}
|
|
1382
|
+
function spendFigureOf(entry) {
|
|
1383
|
+
if (entry.status !== "paid" && entry.status !== "failed") return 0n;
|
|
1384
|
+
const parse = (value) => {
|
|
1385
|
+
if (!value) return 0n;
|
|
1386
|
+
try {
|
|
1387
|
+
const parsed = BigInt(value);
|
|
1388
|
+
return parsed > 0n ? parsed : 0n;
|
|
1389
|
+
} catch {
|
|
1390
|
+
return 0n;
|
|
1391
|
+
}
|
|
1392
|
+
};
|
|
1393
|
+
if (entry.status === "paid") return parse(entry.amount);
|
|
1394
|
+
const ceiling = parse(entry.authorized);
|
|
1395
|
+
const charge = parse(entry.amount);
|
|
1396
|
+
return ceiling > charge ? ceiling : charge;
|
|
1397
|
+
}
|
|
1398
|
+
function sumSpentSince(payerAddress, since) {
|
|
1399
|
+
const payer = payerAddress.toLowerCase();
|
|
1400
|
+
return readX402Log().reduce((total, entry) => {
|
|
1401
|
+
if (entry.payer?.toLowerCase() !== payer) return total;
|
|
1402
|
+
if (since && entry.at < since) return total;
|
|
1403
|
+
return total + spendFigureOf(entry);
|
|
1404
|
+
}, 0n);
|
|
1405
|
+
}
|
|
1406
|
+
function sumToppedUpSince(payerAddress, since) {
|
|
1407
|
+
const payer = payerAddress.toLowerCase();
|
|
1408
|
+
return readX402Log().reduce((total, entry) => {
|
|
1409
|
+
if (!entry.topUpAmount) return total;
|
|
1410
|
+
if (entry.payer?.toLowerCase() !== payer) return total;
|
|
1411
|
+
if (since && entry.at < since) return total;
|
|
1412
|
+
try {
|
|
1413
|
+
return total + BigInt(entry.topUpAmount);
|
|
1414
|
+
} catch {
|
|
1415
|
+
return total;
|
|
1416
|
+
}
|
|
1417
|
+
}, 0n);
|
|
1418
|
+
}
|
|
1419
|
+
var PERMISSION_MANAGER_ABI = parseAbi([
|
|
1420
|
+
"struct CallPermission { address target; bytes4 selector; address checker; }",
|
|
1421
|
+
"struct SpendLimit { address token; uint160 allowance; uint8 unit; uint16 multiplier; }",
|
|
1422
|
+
"struct Permission { address account; address spender; uint48 start; uint48 end; uint256 salt; CallPermission[] calls; SpendLimit[] spends; }",
|
|
1423
|
+
"struct PeriodSpend { uint48 start; uint48 end; uint160 spend; }",
|
|
1424
|
+
"function getHash(Permission permission) view returns (bytes32)",
|
|
1425
|
+
"function isApproved(Permission permission) view returns (bool)",
|
|
1426
|
+
"function isRevoked(Permission permission) view returns (bool)",
|
|
1427
|
+
"function getCurrentPeriod(Permission permission, SpendLimit spendLimit) view returns (PeriodSpend)",
|
|
1428
|
+
// Carried so the two time-bound reverts can be told apart from a node that
|
|
1429
|
+
// did not answer. Everything else the manager can revert with decodes to an
|
|
1430
|
+
// unnamed error, which is treated as unavailable rather than guessed at.
|
|
1431
|
+
"error JustaPermissionManager_BeforePermissionStart(uint48 currentTimestamp, uint48 start)",
|
|
1432
|
+
"error JustaPermissionManager_AfterPermissionEnd(uint48 currentTimestamp, uint48 end)"
|
|
1433
|
+
]);
|
|
1434
|
+
var TIME_BOUND_ERRORS = /* @__PURE__ */ new Set([
|
|
1435
|
+
"JustaPermissionManager_BeforePermissionStart",
|
|
1436
|
+
"JustaPermissionManager_AfterPermissionEnd"
|
|
1437
|
+
]);
|
|
1438
|
+
var PERIOD_UNIT_ENUM = {
|
|
1439
|
+
minute: 0,
|
|
1440
|
+
hour: 1,
|
|
1441
|
+
day: 2,
|
|
1442
|
+
week: 3,
|
|
1443
|
+
month: 4,
|
|
1444
|
+
forever: 5
|
|
1445
|
+
};
|
|
1446
|
+
function toContractSpendLimit(spend) {
|
|
1447
|
+
const unit = spend.unit === "year" ? "month" : spend.unit;
|
|
1448
|
+
const multiplier = spend.unit === "year" ? spend.multiplier * 12 : spend.multiplier;
|
|
1449
|
+
if (!Object.hasOwn(PERIOD_UNIT_ENUM, unit)) return null;
|
|
1450
|
+
return {
|
|
1451
|
+
token: spend.token,
|
|
1452
|
+
allowance: BigInt(spend.allowance),
|
|
1453
|
+
unit: PERIOD_UNIT_ENUM[unit],
|
|
1454
|
+
multiplier
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
function toContractPermission(permission) {
|
|
1458
|
+
const spends = [];
|
|
1459
|
+
for (const spend of permission.spends) {
|
|
1460
|
+
const converted = toContractSpendLimit(spend);
|
|
1461
|
+
if (!converted) return null;
|
|
1462
|
+
spends.push(converted);
|
|
1463
|
+
}
|
|
1464
|
+
let salt;
|
|
1465
|
+
try {
|
|
1466
|
+
salt = BigInt(permission.salt);
|
|
1467
|
+
} catch {
|
|
1468
|
+
return null;
|
|
1469
|
+
}
|
|
1470
|
+
return {
|
|
1471
|
+
account: permission.account,
|
|
1472
|
+
spender: permission.spender,
|
|
1473
|
+
start: permission.start,
|
|
1474
|
+
end: permission.end,
|
|
1475
|
+
salt,
|
|
1476
|
+
calls: permission.calls.map((call) => ({
|
|
1477
|
+
target: call.target,
|
|
1478
|
+
selector: call.selector,
|
|
1479
|
+
checker: zeroAddress
|
|
1480
|
+
})),
|
|
1481
|
+
spends
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
var DEFAULT_TIMEOUT_MS = 5e3;
|
|
1485
|
+
async function within(work, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
1486
|
+
let timer;
|
|
1487
|
+
try {
|
|
1488
|
+
const expired = new Promise((_, reject) => {
|
|
1489
|
+
timer = setTimeout(() => reject(new Error("timed out")), timeoutMs);
|
|
1490
|
+
});
|
|
1491
|
+
return await Promise.race([work, expired]);
|
|
1492
|
+
} finally {
|
|
1493
|
+
clearTimeout(timer);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
async function managerAddress(override) {
|
|
1497
|
+
if (override) return override;
|
|
1498
|
+
const { PERMISSIONS_MANAGER_ADDRESS } = await import('@jaw.id/core');
|
|
1499
|
+
return PERMISSIONS_MANAGER_ADDRESS;
|
|
1500
|
+
}
|
|
1501
|
+
function reader(chainId, deps) {
|
|
1502
|
+
if (deps.readContract) return deps.readContract;
|
|
1503
|
+
try {
|
|
1504
|
+
const client = publicClientFor(chainId);
|
|
1505
|
+
return (args) => client.readContract(args);
|
|
1506
|
+
} catch {
|
|
1507
|
+
return null;
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
async function readCurrentPeriods(target, deps = {}) {
|
|
1511
|
+
if (!target.permission) return [];
|
|
1512
|
+
const permission = toContractPermission(target.permission);
|
|
1513
|
+
if (!permission) return [];
|
|
1514
|
+
const granted = target.permission.spends;
|
|
1515
|
+
const indexes = granted.map((spend, index) => ({ spend, index })).filter(({ spend }) => spend.token.toLowerCase() === target.token.toLowerCase());
|
|
1516
|
+
if (indexes.length === 0) return [];
|
|
1517
|
+
const unreadable = indexes.map(({ spend }) => ({ ...spend, period: { status: "unavailable" } }));
|
|
1518
|
+
const read = reader(target.chainId, deps);
|
|
1519
|
+
if (!read) return unreadable;
|
|
1520
|
+
try {
|
|
1521
|
+
const address = await managerAddress(deps.manager);
|
|
1522
|
+
const settled = await within(
|
|
1523
|
+
Promise.allSettled([
|
|
1524
|
+
read({ address, abi: PERMISSION_MANAGER_ABI, functionName: "getHash", args: [permission] }),
|
|
1525
|
+
...indexes.map(
|
|
1526
|
+
({ index }) => read({
|
|
1527
|
+
address,
|
|
1528
|
+
abi: PERMISSION_MANAGER_ABI,
|
|
1529
|
+
functionName: "getCurrentPeriod",
|
|
1530
|
+
args: [permission, permission.spends[index]]
|
|
1531
|
+
})
|
|
1532
|
+
)
|
|
1533
|
+
]),
|
|
1534
|
+
deps.timeoutMs
|
|
1535
|
+
);
|
|
1536
|
+
const [hashed, ...counters] = settled;
|
|
1537
|
+
const hash = hashed.status === "fulfilled" ? hashed.value : null;
|
|
1538
|
+
if (typeof hash !== "string" || hash.toLowerCase() !== target.permissionId.toLowerCase()) {
|
|
1539
|
+
return unreadable;
|
|
1540
|
+
}
|
|
1541
|
+
return indexes.map(({ spend }, i) => {
|
|
1542
|
+
const result = counters[i];
|
|
1543
|
+
if (result.status === "rejected") {
|
|
1544
|
+
return {
|
|
1545
|
+
...spend,
|
|
1546
|
+
period: isTimeBoundRevert(result.reason) ? { status: "outside-window" } : { status: "unavailable" }
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
const period = result.value;
|
|
1550
|
+
return {
|
|
1551
|
+
...spend,
|
|
1552
|
+
period: period ? {
|
|
1553
|
+
status: "ok",
|
|
1554
|
+
start: Number(period.start),
|
|
1555
|
+
end: Number(period.end),
|
|
1556
|
+
spend: BigInt(period.spend)
|
|
1557
|
+
} : { status: "unavailable" }
|
|
1558
|
+
};
|
|
1559
|
+
});
|
|
1560
|
+
} catch {
|
|
1561
|
+
return unreadable;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
function isTimeBoundRevert(err) {
|
|
1565
|
+
if (!(err instanceof BaseError)) return false;
|
|
1566
|
+
const revert = err.walk((e) => e instanceof ContractFunctionRevertedError);
|
|
1567
|
+
return revert instanceof ContractFunctionRevertedError && TIME_BOUND_ERRORS.has(revert.data?.errorName ?? "");
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// src/x402/spend-window.ts
|
|
1571
|
+
function currentLimitUsage(policy, payerAddress, session, now = /* @__PURE__ */ new Date()) {
|
|
1572
|
+
if (!session || !policy.perPeriod) return [];
|
|
1573
|
+
const usage = [];
|
|
1574
|
+
for (const limit of policy.perPeriod) {
|
|
1575
|
+
const anchorMs = Date.parse(limit.anchor);
|
|
1576
|
+
if (Number.isNaN(anchorMs)) continue;
|
|
1577
|
+
const window = currentPeriodWindow({
|
|
1578
|
+
anchor: Math.floor(anchorMs / 1e3),
|
|
1579
|
+
unit: limit.unit,
|
|
1580
|
+
multiplier: limit.multiplier,
|
|
1581
|
+
now: Math.floor(now.getTime() / 1e3),
|
|
1582
|
+
permissionEnd: session.expiry
|
|
1583
|
+
});
|
|
1584
|
+
const since = new Date(window.start * 1e3).toISOString();
|
|
1585
|
+
usage.push({
|
|
1586
|
+
...limit,
|
|
1587
|
+
spent: sumSpentSince(payerAddress, since),
|
|
1588
|
+
toppedUp: sumToppedUpSince(payerAddress, since),
|
|
1589
|
+
endsAt: new Date(window.end * 1e3),
|
|
1590
|
+
source: "ledger"
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
return usage;
|
|
1594
|
+
}
|
|
1595
|
+
async function currentLimitUsageOnChain(policy, payerAddress, session, now = /* @__PURE__ */ new Date(), deps = {}) {
|
|
1596
|
+
const local = currentLimitUsage(policy, payerAddress, session, now);
|
|
1597
|
+
if (!session || local.length === 0) return local;
|
|
1598
|
+
const asset = Object.values(USDC_BY_NETWORK).find((a) => a.chainId === session.chainId);
|
|
1599
|
+
if (!asset) return local;
|
|
1600
|
+
const onChain = await readCurrentPeriods(
|
|
1601
|
+
{
|
|
1602
|
+
chainId: session.chainId,
|
|
1603
|
+
permissionId: session.permissionId,
|
|
1604
|
+
permission: session.permission,
|
|
1605
|
+
token: asset.address
|
|
1606
|
+
},
|
|
1607
|
+
deps
|
|
1608
|
+
);
|
|
1609
|
+
if (onChain.length === 0) return local;
|
|
1610
|
+
return local.map((limit) => {
|
|
1611
|
+
const match = onChain.find((candidate) => {
|
|
1612
|
+
const normalized = normalizePeriod(candidate.unit, candidate.multiplier);
|
|
1613
|
+
if (normalized?.unit !== limit.unit || normalized.multiplier !== limit.multiplier) return false;
|
|
1614
|
+
const a = parseBigInt(candidate.allowance);
|
|
1615
|
+
const b = parseBigInt(limit.allowance);
|
|
1616
|
+
return a !== null && b !== null && a === b;
|
|
1617
|
+
});
|
|
1618
|
+
if (!match || match.period.status !== "ok") return limit;
|
|
1619
|
+
const since = new Date(match.period.start * 1e3).toISOString();
|
|
1620
|
+
const fromLedger = sumToppedUpSince(payerAddress, since);
|
|
1621
|
+
const metered = match.period.spend >= fromLedger;
|
|
1622
|
+
return {
|
|
1623
|
+
...limit,
|
|
1624
|
+
spent: sumSpentSince(payerAddress, since),
|
|
1625
|
+
toppedUp: metered ? match.period.spend : fromLedger,
|
|
1626
|
+
endsAt: new Date(match.period.end * 1e3),
|
|
1627
|
+
source: metered ? "chain" : "ledger"
|
|
1628
|
+
};
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// src/x402/gas-reserve.ts
|
|
1633
|
+
function gasReserve(asset) {
|
|
1634
|
+
return 10n ** BigInt(asset.decimals) / 10n;
|
|
1635
|
+
}
|
|
1636
|
+
function firstOperationCost(asset) {
|
|
1637
|
+
return 10n ** BigInt(asset.decimals) / 100n;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// src/x402/topup.ts
|
|
1641
|
+
var readAllowance = (asset, owner, spender) => publicClientFor(asset.chainId).readContract({
|
|
1642
|
+
address: asset.address,
|
|
1643
|
+
abi: erc20Abi,
|
|
1644
|
+
functionName: "allowance",
|
|
1645
|
+
args: [owner, spender]
|
|
1646
|
+
});
|
|
1647
|
+
function isFinalStatus(s) {
|
|
1648
|
+
if (!s) return "pending";
|
|
1649
|
+
const v = s.status;
|
|
1650
|
+
if (v === 200 || v === "200" || v === "CONFIRMED") return "ok";
|
|
1651
|
+
if (v === 100 || v === "100" || v === "PENDING" || v === void 0) return "pending";
|
|
1652
|
+
return "failed";
|
|
1653
|
+
}
|
|
1654
|
+
async function ensurePayerFunds(requirement, payerAddress, executor, opts = {}) {
|
|
1655
|
+
const asset = usdcForNetwork(requirement.network);
|
|
1656
|
+
if (!asset) {
|
|
1657
|
+
return { ok: true, skipped: true };
|
|
1658
|
+
}
|
|
1659
|
+
if (requirement.asset && requirement.asset.toLowerCase() !== asset.address.toLowerCase()) {
|
|
1660
|
+
return { ok: true, skipped: true };
|
|
1661
|
+
}
|
|
1662
|
+
if (opts.sessionChainId !== void 0 && opts.sessionChainId !== asset.chainId) {
|
|
1663
|
+
return {
|
|
1664
|
+
ok: false,
|
|
1665
|
+
reason: `session is on chain ${opts.sessionChainId} but the payment needs chain ${asset.chainId}; run \`jaw session setup --chain ${asset.chainId}\` to pay on this network`
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
const price = parseBigInt(requirement.amount);
|
|
1669
|
+
if (price === null) {
|
|
1670
|
+
return { ok: false, reason: `non-numeric payment amount: ${requirement.amount}` };
|
|
1671
|
+
}
|
|
1672
|
+
let grantApproval = null;
|
|
1673
|
+
let permit2Allowance;
|
|
1674
|
+
if (requirement.scheme === "upto") {
|
|
1675
|
+
const status = await permit2ApprovalStatus(asset, payerAddress, price, executor, opts);
|
|
1676
|
+
if (!status.ok) return { ok: false, reason: status.reason };
|
|
1677
|
+
grantApproval = status.grant;
|
|
1678
|
+
permit2Allowance = grantApproval ? void 0 : status.allowance;
|
|
1679
|
+
}
|
|
1680
|
+
const read = opts.balanceReader;
|
|
1681
|
+
const balance = read ? await read(asset, payerAddress) : BigInt((await usdcBalance(requirement.network, payerAddress)).raw);
|
|
1682
|
+
const needed = grantApproval ? price + gasReserve(asset) : price;
|
|
1683
|
+
if (balance >= needed) {
|
|
1684
|
+
if (grantApproval) {
|
|
1685
|
+
const granted = await grantPermit2Allowance(asset, payerAddress, price, grantApproval, executor, opts);
|
|
1686
|
+
if (!granted.ok) return { ok: false, reason: granted.reason, approvalBatchId: granted.batchId };
|
|
1687
|
+
return { ok: true, skipped: true, approvalBatchId: granted.batchId, permit2Allowance: granted.allowance };
|
|
1688
|
+
}
|
|
1689
|
+
return { ok: true, skipped: true, permit2Allowance };
|
|
1690
|
+
}
|
|
1691
|
+
const shortfall = needed - balance;
|
|
1692
|
+
const feePerOp = firstOperationCost(asset);
|
|
1693
|
+
if (opts.maxTopUp !== void 0 && opts.maxTopUp < shortfall + feePerOp) {
|
|
1694
|
+
return {
|
|
1695
|
+
ok: false,
|
|
1696
|
+
reason: `the tightest spend cap has ${opts.maxTopUp} base units left and this payment needs ${shortfall + feePerOp} topped up (${shortfall} short, plus the fee the payer is charged for the refill itself); wait for the period to reset, or raise the cap.`
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
const target = opts.floatTarget !== void 0 && opts.floatTarget > needed ? opts.floatTarget : needed;
|
|
1700
|
+
let amount = (target - balance > shortfall ? target - balance : shortfall) + gasReserve(asset);
|
|
1701
|
+
if (opts.maxTopUp !== void 0 && amount > opts.maxTopUp) {
|
|
1702
|
+
amount = opts.maxTopUp;
|
|
1703
|
+
}
|
|
1704
|
+
const data = encodeFunctionData({
|
|
1705
|
+
abi: erc20Abi,
|
|
1706
|
+
functionName: "transfer",
|
|
1707
|
+
args: [payerAddress, amount]
|
|
1708
|
+
});
|
|
1709
|
+
let batchId;
|
|
1710
|
+
try {
|
|
1711
|
+
const sent = await executor.request("wallet_sendCalls", [{ calls: [{ to: asset.address, data }] }]);
|
|
1712
|
+
const id = typeof sent === "string" ? sent : sent?.id;
|
|
1713
|
+
if (!id) {
|
|
1714
|
+
return {
|
|
1715
|
+
ok: false,
|
|
1716
|
+
reason: "top-up submitted but no call id returned; cannot confirm it",
|
|
1717
|
+
amount: amount.toString()
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
batchId = id;
|
|
1721
|
+
} catch (err) {
|
|
1722
|
+
const msg = errorMessage(err);
|
|
1723
|
+
return {
|
|
1724
|
+
ok: false,
|
|
1725
|
+
reason: `top-up refused on-chain (${msg}). The session permission must allow a USDC transfer to the payer and still have spend allowance this period; check the grant from \`jaw session setup\` or the remaining cap.`
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
const confirmed = await awaitCall(executor, batchId, opts, {
|
|
1729
|
+
subject: "top-up",
|
|
1730
|
+
onChainFailure: "top-up transaction failed on-chain (spending cap reached, or permission expired/revoked)"
|
|
1731
|
+
});
|
|
1732
|
+
if (!confirmed.ok) {
|
|
1733
|
+
return { ok: false, reason: confirmed.reason, amount: amount.toString(), batchId };
|
|
1734
|
+
}
|
|
1735
|
+
let approvalBatchId;
|
|
1736
|
+
if (grantApproval) {
|
|
1737
|
+
const granted = await grantPermit2Allowance(asset, payerAddress, price, grantApproval, executor, opts);
|
|
1738
|
+
approvalBatchId = granted.batchId;
|
|
1739
|
+
if (!granted.ok) {
|
|
1740
|
+
return { ok: false, reason: granted.reason, amount: amount.toString(), batchId, approvalBatchId };
|
|
1741
|
+
}
|
|
1742
|
+
permit2Allowance = granted.allowance;
|
|
1743
|
+
}
|
|
1744
|
+
return { ok: true, amount: amount.toString(), batchId, approvalBatchId, permit2Allowance };
|
|
1745
|
+
}
|
|
1746
|
+
async function permit2ApprovalStatus(asset, payerAddress, needed, executor, opts) {
|
|
1747
|
+
const read = opts.allowanceReader ?? readAllowance;
|
|
1748
|
+
let allowance;
|
|
1749
|
+
try {
|
|
1750
|
+
allowance = await read(asset, payerAddress, PERMIT2_ADDRESS);
|
|
1751
|
+
} catch (err) {
|
|
1752
|
+
return { ok: false, reason: `could not read the payer's Permit2 allowance: ${errorMessage(err)}` };
|
|
1753
|
+
}
|
|
1754
|
+
if (allowance >= needed) return { ok: true, grant: null, allowance };
|
|
1755
|
+
const grant = executor.approvePermit2?.bind(executor);
|
|
1756
|
+
if (!grant) {
|
|
1757
|
+
return {
|
|
1758
|
+
ok: false,
|
|
1759
|
+
reason: `the payer has not approved Permit2 to move ${asset.address}, and this session cannot grant it. Approve Permit2 once on this chain to pay upto challenges.`
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
return { ok: true, grant, allowance };
|
|
1763
|
+
}
|
|
1764
|
+
async function grantPermit2Allowance(asset, payerAddress, needed, grant, executor, opts) {
|
|
1765
|
+
let batchId;
|
|
1766
|
+
try {
|
|
1767
|
+
batchId = await grant(asset.address);
|
|
1768
|
+
} catch (err) {
|
|
1769
|
+
return { ok: false, reason: `Permit2 approval refused: ${errorMessage(err)}` };
|
|
1770
|
+
}
|
|
1771
|
+
const confirmed = await awaitCall(executor, batchId, opts, {
|
|
1772
|
+
subject: "Permit2 approval",
|
|
1773
|
+
onChainFailure: `Permit2 approval failed on-chain (batch ${batchId})`
|
|
1774
|
+
});
|
|
1775
|
+
if (!confirmed.ok) return { ok: false, reason: confirmed.reason, batchId };
|
|
1776
|
+
const visible = await allowanceVisible(asset, payerAddress, needed, opts);
|
|
1777
|
+
if (visible === null) {
|
|
1778
|
+
return {
|
|
1779
|
+
ok: false,
|
|
1780
|
+
batchId,
|
|
1781
|
+
reason: `the Permit2 approval confirmed (batch ${batchId}) but the allowance is not visible yet on this chain; retry the payment in a moment, the approval does not need to be sent again.`
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
return { ok: true, batchId, allowance: visible };
|
|
1785
|
+
}
|
|
1786
|
+
var ALLOWANCE_VISIBILITY_ATTEMPTS = 3;
|
|
1787
|
+
async function allowanceVisible(asset, payerAddress, needed, opts) {
|
|
1788
|
+
const read = opts.allowanceReader ?? readAllowance;
|
|
1789
|
+
const sleep2 = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1790
|
+
const pollMs = opts.pollMs ?? 2e3;
|
|
1791
|
+
for (let attempt = 0; attempt < ALLOWANCE_VISIBILITY_ATTEMPTS; attempt++) {
|
|
1792
|
+
if (attempt > 0) await sleep2(pollMs);
|
|
1793
|
+
try {
|
|
1794
|
+
const seen = await read(asset, payerAddress, PERMIT2_ADDRESS);
|
|
1795
|
+
if (seen >= needed) return seen;
|
|
1796
|
+
} catch {
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
return null;
|
|
1800
|
+
}
|
|
1801
|
+
async function awaitCall(executor, batchId, opts, labels) {
|
|
1802
|
+
const now = opts.now ?? Date.now;
|
|
1803
|
+
const sleep2 = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1804
|
+
const pollMs = opts.pollMs ?? 2e3;
|
|
1805
|
+
const timeoutMs = opts.timeoutMs ?? 9e4;
|
|
1806
|
+
const deadline = now() + timeoutMs;
|
|
1807
|
+
for (; ; ) {
|
|
1808
|
+
let status;
|
|
1809
|
+
let timer;
|
|
1810
|
+
try {
|
|
1811
|
+
const remaining = Math.max(deadline - now(), 0);
|
|
1812
|
+
const expired = new Promise((_, reject) => {
|
|
1813
|
+
timer = setTimeout(() => reject(new Error(`status check timed out after ${timeoutMs}ms`)), remaining);
|
|
1814
|
+
});
|
|
1815
|
+
status = await Promise.race([executor.request("wallet_getCallsStatus", batchId), expired]);
|
|
1816
|
+
} catch (err) {
|
|
1817
|
+
return { ok: false, reason: `${labels.subject} status check failed: ${errorMessage(err)}` };
|
|
1818
|
+
} finally {
|
|
1819
|
+
clearTimeout(timer);
|
|
1820
|
+
}
|
|
1821
|
+
const final = isFinalStatus(status);
|
|
1822
|
+
if (final === "ok") return { ok: true };
|
|
1823
|
+
if (final === "failed") return { ok: false, reason: labels.onChainFailure };
|
|
1824
|
+
if (now() >= deadline) {
|
|
1825
|
+
return { ok: false, reason: `${labels.subject} not confirmed after ${timeoutMs}ms` };
|
|
1826
|
+
}
|
|
1827
|
+
await sleep2(pollMs);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// src/lib/terminal.ts
|
|
1832
|
+
var INVISIBLE_AND_BIDI = /[\u200B-\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069\uFEFF]/g;
|
|
1833
|
+
var BLOCK_CONTROLS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
|
|
1834
|
+
var LINE_CONTROLS = /[\u0000-\u001F\u007F-\u009F]/g;
|
|
1835
|
+
var REPLACEMENT = "\uFFFD";
|
|
1836
|
+
var DEFAULT_LINE_LENGTH = 200;
|
|
1837
|
+
function bound(text, maxLength) {
|
|
1838
|
+
if (text.length <= maxLength) return text;
|
|
1839
|
+
return `${text.slice(0, maxLength)}\u2026 (${text.length - maxLength} more characters)`;
|
|
1840
|
+
}
|
|
1841
|
+
function sanitizeLine(value, maxLength = DEFAULT_LINE_LENGTH) {
|
|
1842
|
+
const text = typeof value === "string" ? value : String(value);
|
|
1843
|
+
return bound(text.replace(LINE_CONTROLS, REPLACEMENT).replace(INVISIBLE_AND_BIDI, REPLACEMENT), maxLength);
|
|
1844
|
+
}
|
|
1845
|
+
function sanitizeBlock(value) {
|
|
1846
|
+
const text = typeof value === "string" ? value : String(value);
|
|
1847
|
+
return text.replace(BLOCK_CONTROLS, REPLACEMENT).replace(INVISIBLE_AND_BIDI, REPLACEMENT);
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// src/x402/status-report.ts
|
|
1851
|
+
function formatUsdc(base2, decimals) {
|
|
1852
|
+
if (base2 === void 0) return "unlimited";
|
|
1853
|
+
const value = parseBigInt(base2);
|
|
1854
|
+
if (value === null) return `${sanitizeLine(base2, 32)} (invalid)`;
|
|
1855
|
+
const scale = 10n ** BigInt(decimals);
|
|
1856
|
+
const whole = value / scale;
|
|
1857
|
+
const frac = (value % scale).toString().padStart(decimals, "0").replace(/0+$/, "");
|
|
1858
|
+
return `${whole}${frac ? `.${frac}` : ""} USDC`;
|
|
1859
|
+
}
|
|
1860
|
+
var STALE_AFTER_MS = 3e5;
|
|
1861
|
+
var DEFAULT_ACQUIRE_TIMEOUT_MS = 12e4;
|
|
1862
|
+
var POLL_INTERVAL_MS = 100;
|
|
1863
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1864
|
+
function readLock() {
|
|
1865
|
+
try {
|
|
1866
|
+
const parsed = JSON.parse(fs5.readFileSync(PATHS.paymentLock, "utf-8"));
|
|
1867
|
+
if (typeof parsed?.pid !== "number" || typeof parsed?.at !== "number") return null;
|
|
1868
|
+
return parsed;
|
|
1869
|
+
} catch {
|
|
1870
|
+
return null;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
function isAlive(pid) {
|
|
1874
|
+
try {
|
|
1875
|
+
process.kill(pid, 0);
|
|
1876
|
+
return true;
|
|
1877
|
+
} catch (err) {
|
|
1878
|
+
return err?.code === "EPERM";
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
var TORN_GRACE_MS = 2e3;
|
|
1882
|
+
function unreadableLockIsTorn() {
|
|
1883
|
+
try {
|
|
1884
|
+
return Date.now() - fs5.statSync(PATHS.paymentLock).mtimeMs > TORN_GRACE_MS;
|
|
1885
|
+
} catch {
|
|
1886
|
+
return true;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
function isStale(lock, staleAfterMs) {
|
|
1890
|
+
if (!lock) return unreadableLockIsTorn();
|
|
1891
|
+
if (!isAlive(lock.pid)) return true;
|
|
1892
|
+
return Date.now() - lock.at > staleAfterMs;
|
|
1893
|
+
}
|
|
1894
|
+
function breakLock(observed) {
|
|
1895
|
+
const current = readLock();
|
|
1896
|
+
const sameLock = observed === null && current === null || observed !== null && current !== null && current.token === observed.token && current.at === observed.at;
|
|
1897
|
+
if (!sameLock && current !== null) return;
|
|
1898
|
+
if (current === null && !unreadableLockIsTorn()) return;
|
|
1899
|
+
try {
|
|
1900
|
+
fs5.unlinkSync(PATHS.paymentLock);
|
|
1901
|
+
} catch {
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
async function withPaymentLock(fn, options = {}) {
|
|
1905
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
|
|
1906
|
+
const staleAfterMs = options.staleAfterMs ?? STALE_AFTER_MS;
|
|
1907
|
+
const token = crypto2.randomBytes(16).toString("hex");
|
|
1908
|
+
const deadline = Date.now() + timeoutMs;
|
|
1909
|
+
ensureDir(PATHS.root);
|
|
1910
|
+
let notified = false;
|
|
1911
|
+
for (; ; ) {
|
|
1912
|
+
try {
|
|
1913
|
+
const fd = fs5.openSync(PATHS.paymentLock, "wx", 384);
|
|
1914
|
+
try {
|
|
1915
|
+
fs5.writeFileSync(fd, JSON.stringify({ pid: process.pid, token, at: Date.now() }));
|
|
1916
|
+
} finally {
|
|
1917
|
+
fs5.closeSync(fd);
|
|
1918
|
+
}
|
|
1919
|
+
break;
|
|
1920
|
+
} catch (err) {
|
|
1921
|
+
if (err?.code !== "EEXIST") throw err;
|
|
1922
|
+
const holder = readLock();
|
|
1923
|
+
if (isStale(holder, staleAfterMs)) {
|
|
1924
|
+
breakLock(holder);
|
|
1925
|
+
} else if (!notified && holder) {
|
|
1926
|
+
notified = true;
|
|
1927
|
+
options.onWait?.(holder.pid);
|
|
1928
|
+
}
|
|
1929
|
+
if (Date.now() >= deadline) {
|
|
1930
|
+
throw new Error(
|
|
1931
|
+
`Another payment has been running for ${Math.round((Date.now() - (holder?.at ?? Date.now())) / 1e3)}s (pid ${holder?.pid ?? "unknown"}). Refusing rather than paying past the session cap. Retry once it finishes, or remove ${PATHS.paymentLock} if that process is gone.`
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
await sleep(POLL_INTERVAL_MS);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
const releaseOnExit = () => release(token);
|
|
1938
|
+
process.once("exit", releaseOnExit);
|
|
1939
|
+
try {
|
|
1940
|
+
return await fn();
|
|
1941
|
+
} finally {
|
|
1942
|
+
process.removeListener("exit", releaseOnExit);
|
|
1943
|
+
release(token);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
function release(token) {
|
|
1947
|
+
const current = readLock();
|
|
1948
|
+
if (current?.token !== token) return;
|
|
1949
|
+
try {
|
|
1950
|
+
fs5.unlinkSync(PATHS.paymentLock);
|
|
1951
|
+
} catch {
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// src/commands/x402/pay.ts
|
|
1956
|
+
var X402Pay = class _X402Pay extends BaseCommand {
|
|
1957
|
+
static description = "Fetch a URL, paying an x402 challenge with the session key. Dry run by default: pass --pay to actually spend.";
|
|
1958
|
+
static examples = [
|
|
1959
|
+
"<%= config.bin %> x402 pay https://api.example.com/resource",
|
|
1960
|
+
"<%= config.bin %> x402 pay https://api.example.com/resource --pay",
|
|
1961
|
+
"<%= config.bin %> x402 pay https://api.example.com/resource --pay --max-amount 50000"
|
|
1962
|
+
];
|
|
1963
|
+
static args = {
|
|
1964
|
+
url: Args.string({ description: "Resource URL to fetch", required: true })
|
|
1965
|
+
};
|
|
1966
|
+
static flags = {
|
|
1967
|
+
...BaseCommand.baseFlags,
|
|
1968
|
+
pay: Flags.boolean({
|
|
1969
|
+
description: "Actually sign and send the payment. Without this the command stops before spending.",
|
|
1970
|
+
default: false
|
|
1971
|
+
}),
|
|
1972
|
+
"max-amount": Flags.string({
|
|
1973
|
+
description: "Hard ceiling in base units for this call, on top of the configured policy."
|
|
1974
|
+
}),
|
|
1975
|
+
method: Flags.string({ description: "HTTP method (default GET)." }),
|
|
1976
|
+
body: Flags.string({ description: "Request body." })
|
|
1977
|
+
};
|
|
1978
|
+
async run() {
|
|
1979
|
+
const { args, flags } = await this.parse(_X402Pay);
|
|
1980
|
+
const format = flags.output;
|
|
1981
|
+
const config = loadConfig();
|
|
1982
|
+
const payer = Eip3009EoaPayer.fromSessionKey();
|
|
1983
|
+
const session = tryLoadSessionConfig();
|
|
1984
|
+
const policy = resolveSessionX402Policy(config.x402, session);
|
|
1985
|
+
const spent = sumSpentSince(payer.address, session?.createdAt);
|
|
1986
|
+
if (flags.pay && (!session || !config.apiKey)) {
|
|
1987
|
+
this.warn(
|
|
1988
|
+
session ? "No apiKey configured, so a short payer cannot be topped up. Paying from its own balance." : "No session, so a short payer cannot be topped up through a permission. Paying from its own balance."
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
const run = async () => {
|
|
1992
|
+
const periodUsage = await currentLimitUsageOnChain(policy, payer.address, session);
|
|
1993
|
+
const spentThisSession = flags.pay ? sumSpentSince(payer.address, session?.createdAt) : spent;
|
|
1994
|
+
let ensureFunds;
|
|
1995
|
+
if (flags.pay && session && config.apiKey) {
|
|
1996
|
+
const bridge = new SessionBridge({ apiKey: config.apiKey, chainId: session.chainId });
|
|
1997
|
+
const floatTarget = parseNonNegativeBigInt(config.x402?.topUpFloat);
|
|
1998
|
+
const maxTopUp = topUpCeiling(policy, { periodUsage, spentThisSession });
|
|
1999
|
+
ensureFunds = (requirement, payerAddress) => ensurePayerFunds(requirement, payerAddress, bridge, {
|
|
2000
|
+
floatTarget,
|
|
2001
|
+
maxTopUp,
|
|
2002
|
+
sessionChainId: session.chainId
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
const outcome = await payAndFetch(args.url, payer, {
|
|
2006
|
+
method: flags.method,
|
|
2007
|
+
body: flags.body,
|
|
2008
|
+
policy,
|
|
2009
|
+
ensureFunds,
|
|
2010
|
+
spentThisSession,
|
|
2011
|
+
periodUsage,
|
|
2012
|
+
maxAmount: flags["max-amount"],
|
|
2013
|
+
dryRun: !flags.pay
|
|
2014
|
+
});
|
|
2015
|
+
if (flags.pay) {
|
|
2016
|
+
const settled = outcome.payment ?? outcome.attemptedPayment;
|
|
2017
|
+
const isPaymentEvent = outcome.paid || !!outcome.attemptedPayment || outcome.status === 402 && !!outcome.refusedReason;
|
|
2018
|
+
if (isPaymentEvent) {
|
|
2019
|
+
appendX402Log({
|
|
2020
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2021
|
+
url: args.url,
|
|
2022
|
+
payer: outcome.payer,
|
|
2023
|
+
status: outcome.paid ? "paid" : outcome.attemptedPayment ? "failed" : "refused",
|
|
2024
|
+
amount: settled?.amount,
|
|
2025
|
+
authorized: settled?.authorized,
|
|
2026
|
+
deadline: settled?.deadline,
|
|
2027
|
+
asset: settled?.asset,
|
|
2028
|
+
network: settled?.network,
|
|
2029
|
+
payTo: settled?.payTo,
|
|
2030
|
+
nonce: settled?.nonce,
|
|
2031
|
+
txHash: outcome.payment?.txHash,
|
|
2032
|
+
topUpAmount: outcome.topUp?.amount,
|
|
2033
|
+
topUpBatchId: outcome.topUp?.batchId,
|
|
2034
|
+
approvalBatchId: outcome.permit2Approval?.batchId,
|
|
2035
|
+
reason: outcome.refusedReason
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
return outcome;
|
|
2040
|
+
};
|
|
2041
|
+
const result = flags.pay ? await withPaymentLock(run, {
|
|
2042
|
+
onWait: (pid) => this.warn(`Waiting for another payment to finish (pid ${pid})...`)
|
|
2043
|
+
}) : await run();
|
|
2044
|
+
if (format === "json") {
|
|
2045
|
+
this.outputResult({ ...result, dryRun: !flags.pay }, format);
|
|
2046
|
+
if (result.refusedReason) this.exit(1);
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
const priceDecimals = (network) => (network ? usdcForNetwork(network)?.decimals : void 0) ?? 6;
|
|
2050
|
+
const topUpDecimals = Object.values(USDC_BY_NETWORK).find((a) => a.chainId === session?.chainId)?.decimals ?? 6;
|
|
2051
|
+
if (result.refusedReason) {
|
|
2052
|
+
this.log(`Refused.
|
|
2053
|
+
|
|
2054
|
+
${sanitizeLine(result.refusedReason)}`);
|
|
2055
|
+
if (result.topUp) {
|
|
2056
|
+
const where = result.topUp.batchId ? ` (${result.topUp.batchId})` : " (no call id returned to confirm it)";
|
|
2057
|
+
this.log(`
|
|
2058
|
+
A top-up of ${formatUsdc(result.topUp.amount, topUpDecimals)} was sent first${where}.`);
|
|
2059
|
+
}
|
|
2060
|
+
if (result.permit2Approval) {
|
|
2061
|
+
this.log(`
|
|
2062
|
+
A Permit2 approval was sent first (${result.permit2Approval.batchId}).`);
|
|
2063
|
+
}
|
|
2064
|
+
const held = result.attemptedPayment;
|
|
2065
|
+
if (held) {
|
|
2066
|
+
const figure = formatUsdc(held.authorized, priceDecimals(held.network));
|
|
2067
|
+
this.log(`
|
|
2068
|
+
${figure} stays authorized until it expires, and your caps count it as spent until then.`);
|
|
2069
|
+
}
|
|
2070
|
+
this.exit(1);
|
|
2071
|
+
}
|
|
2072
|
+
if (result.wouldPay) {
|
|
2073
|
+
this.log("Would pay.\n");
|
|
2074
|
+
const label = result.wouldPay.scheme === "upto" ? "up to " : "price ";
|
|
2075
|
+
this.log(
|
|
2076
|
+
` ${label} ${formatUsdc(result.wouldPay.amount, priceDecimals(result.wouldPay.network))} on ${sanitizeLine(result.wouldPay.network, 64)}`
|
|
2077
|
+
);
|
|
2078
|
+
this.log(` payTo ${result.wouldPay.payTo}`);
|
|
2079
|
+
this.log(` from ${payer.address}`);
|
|
2080
|
+
if (result.wouldPay.scheme === "upto") {
|
|
2081
|
+
this.log("\n The server charges anything up to that and decides after the work is done.");
|
|
2082
|
+
this.log(" Your caps are measured against the ceiling, since the charge is not knowable yet.");
|
|
2083
|
+
}
|
|
2084
|
+
this.log("\nNothing was signed or spent. Re-run with --pay to go through with it.");
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
if (!result.paid) {
|
|
2088
|
+
this.log(`${result.status} (no payment required)`);
|
|
2089
|
+
this.logBody(result.body);
|
|
2090
|
+
return;
|
|
2091
|
+
}
|
|
2092
|
+
this.log("Paid.\n");
|
|
2093
|
+
this.log(
|
|
2094
|
+
` amount ${formatUsdc(result.payment?.amount, priceDecimals(result.payment?.network))} on ${sanitizeLine(result.payment?.network, 64)}`
|
|
2095
|
+
);
|
|
2096
|
+
if (result.payment?.scheme === "upto" && BigInt(result.payment.authorized) !== BigInt(result.payment.amount)) {
|
|
2097
|
+
this.log(` of up to ${formatUsdc(result.payment.authorized, priceDecimals(result.payment.network))} authorized`);
|
|
2098
|
+
}
|
|
2099
|
+
this.log(` payTo ${result.payment?.payTo}`);
|
|
2100
|
+
if (result.topUp) {
|
|
2101
|
+
this.log(` top-up ${formatUsdc(result.topUp.amount, topUpDecimals)} pulled from the owner account`);
|
|
2102
|
+
}
|
|
2103
|
+
if (result.permit2Approval) {
|
|
2104
|
+
this.log(` approval ${result.permit2Approval.batchId} granted Permit2 the allowance upto settles through`);
|
|
2105
|
+
}
|
|
2106
|
+
if (result.payment?.txHash) {
|
|
2107
|
+
this.log(` tx ${result.payment.txHash}`);
|
|
2108
|
+
}
|
|
2109
|
+
this.log(`
|
|
2110
|
+
${result.status} OK`);
|
|
2111
|
+
this.logBody(result.body);
|
|
2112
|
+
}
|
|
2113
|
+
logBody(body) {
|
|
2114
|
+
if (body === void 0 || body === null || body === "") return;
|
|
2115
|
+
this.log("");
|
|
2116
|
+
this.log(sanitizeBlock(typeof body === "string" ? body : JSON.stringify(body, null, 2)));
|
|
2117
|
+
}
|
|
2118
|
+
};
|
|
2119
|
+
|
|
2120
|
+
export { X402Pay as default };
|
|
2121
|
+
//# sourceMappingURL=pay.js.map
|
|
2122
|
+
//# sourceMappingURL=pay.js.map
|