@metalntwx/metal-pay 0.3.0 → 0.3.2

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.
@@ -19,12 +19,15 @@ import {
19
19
  decodeFunctionData,
20
20
  formatLog,
21
21
  getAbiItem,
22
+ getAddress,
22
23
  hexToBigInt,
23
24
  isAddressEqual,
24
25
  numberToHex,
25
26
  erc20Abi,
27
+ parseAbi,
26
28
  parseEventLogs,
27
29
  slice,
30
+ toEventSelector,
28
31
  toFunctionSelector,
29
32
  } from "viem";
30
33
  import {
@@ -46,7 +49,7 @@ import {
46
49
  import { accountFactoryFor, metalNetwork } from "../networks.js";
47
50
  import { assertMetalAccount, metalAccountCode, metalAccountFactoryAbi } from "./account.js";
48
51
  import { publicMessage } from "./chain.js";
49
- import { verifyCheckoutMandate, x402Offer, lineItemOf } from "./checkout.js";
52
+ import { checkoutHash, readL3b, verifyCheckoutMandate, x402Offer, lineItemOf } from "./checkout.js";
50
53
  import { type Json, jcs } from "./jcs.js";
51
54
  import { buildL3a, mandateNonce, serializeL3a, transactionIdOf, verifyL3aSignature } from "./l3a.js";
52
55
  import { type Credential, type TrustedIssuer, extractTerms, hashCredential, issuerKeyId, verifyCredential } from "./vi/credential.js";
@@ -230,6 +233,54 @@ function carriedL3a(input: Hex, chainId: number, m: BoundMandate, o: Pick<Outcom
230
233
 
231
234
  type MandateEvent = ParseEventLogsReturnType<typeof mandateBindingAbi, undefined, true>[number];
232
235
 
236
+ const MANDATE_EVENTS = new Set(mandateBindingAbi.filter((item) => item.type === "event").map((item) => toEventSelector(item)));
237
+
238
+ /** The events a MetalAccount emits besides Mandate Binding's: its quorum, scheduling, sessions and OpenZeppelin bases. */
239
+ const ACCOUNT_EVENTS = new Set([
240
+ ...MANDATE_EVENTS,
241
+ ...parseAbi([
242
+ "event EIP712DomainChanged()",
243
+ "event ERC7579TryExecuteFail(uint256 batchExecutionIndex, bytes returndata)",
244
+ "event ERC7913SignerAdded(bytes indexed signers)",
245
+ "event ERC7913SignerRemoved(bytes indexed signers)",
246
+ "event ERC7913SignerWeightChanged(bytes indexed signer, uint64 weight)",
247
+ "event ERC7913ThresholdSet(uint64 threshold)",
248
+ "event Executed(uint256 indexed nonce)",
249
+ "event Initialized(uint64 version)",
250
+ "event ScheduleCanceled(bytes32 indexed operationId)",
251
+ "event ScheduleConfigured(uint64 threshold, uint32 delay)",
252
+ "event Scheduled(bytes32 indexed operationId, uint256 indexed nonce, bytes32 mode, bytes executionData, uint48 readyAt)",
253
+ "event ScheduledExecuted(bytes32 indexed operationId)",
254
+ "event SessionGranted(address indexed agent, address indexed token, uint64 expiresAt, uint256 perTransferCap, uint256 periodBudget, uint32 period)",
255
+ "event SessionRevoked(address indexed agent, address indexed token)",
256
+ "event SessionTransfer(address indexed agent, address indexed token, address indexed to, uint256 amount)",
257
+ ]).map((item) => toEventSelector(item)),
258
+ ]);
259
+
260
+ /** What a verifier that cannot decode an account's events says: a deployed account of another version than the SDK's can emit them. */
261
+ const OTHER_VERSIONS = "the SDK and the deployed account may be different versions";
262
+
263
+ /** A log's place in the chain: its transaction and its index there. */
264
+ const placeOf = (log: { transactionHash: Hash | null; logIndex: number | null }): string => `${log.transactionHash}:${log.logIndex}`;
265
+
266
+ /**
267
+ * The account's events under `ref` that Mandate Binding's ABI does not
268
+ * decode, by topic. Each puts the reference in its first topic, so an
269
+ * account of another version emits its outcomes there under another topic,
270
+ * or under the same topic in another layout, and a scan that dropped them
271
+ * would see a mandate with no payments. Each log is matched to its decoding
272
+ * by its place.
273
+ */
274
+ function undecoded(principal: Address, ref: Hex, logs: readonly RpcLog[], decoded: readonly MandateEvent[]): string[] {
275
+ const read = new Set(decoded.map(placeOf));
276
+ const counts = new Map<Hex, number>();
277
+ for (const log of logs.map((raw) => formatLog(raw))) {
278
+ const topic = log.topics[0];
279
+ if (topic !== undefined && !read.has(placeOf(log))) counts.set(topic, (counts.get(topic) ?? 0) + 1);
280
+ }
281
+ return [...counts].map(([topic, count]) => `the account ${getAddress(principal)} emitted ${count} event${count === 1 ? "" : "s"} under ${short(ref)} that this SDK does not decode, under topic ${topic}; ${OTHER_VERSIONS}`);
282
+ }
283
+
233
284
  /**
234
285
  * The mandate's events on the principal between two blocks, oldest first: one
235
286
  * `eth_getLogs` for every event kind, since each puts the reference in its
@@ -237,21 +288,23 @@ type MandateEvent = ParseEventLogsReturnType<typeof mandateBindingAbi, undefined
237
288
  * rule and in its own words, so a refused range is halved until the node
238
289
  * answers; an error that holds for a single block is the node's.
239
290
  */
240
- async function mandateEvents(client: PublicClient, principal: Address, ref: Hex, fromBlock: bigint, toBlock: bigint): Promise<MandateEvent[]> {
291
+ async function mandateEvents(client: PublicClient, principal: Address, ref: Hex, fromBlock: bigint, toBlock: bigint): Promise<{ events: MandateEvent[]; unknown: string[] }> {
241
292
  const out: MandateEvent[] = [];
293
+ const raw: RpcLog[] = [];
242
294
  let span = toBlock - fromBlock + 1n;
243
295
  for (let start = fromBlock; start <= toBlock;) {
244
296
  const end = start + span - 1n > toBlock ? toBlock : start + span - 1n;
245
297
  try {
246
- const logs = await client.request({ method: "eth_getLogs", params: [{ address: principal, fromBlock: numberToHex(start), toBlock: numberToHex(end), topics: [null, ref] }] });
247
- out.push(...parseEventLogs({ abi: mandateBindingAbi, logs: (logs as RpcLog[]).map((log) => formatLog(log)), strict: true }));
298
+ const logs = (await client.request({ method: "eth_getLogs", params: [{ address: principal, fromBlock: numberToHex(start), toBlock: numberToHex(end), topics: [null, ref] }] })) as RpcLog[];
299
+ raw.push(...logs);
300
+ out.push(...parseEventLogs({ abi: mandateBindingAbi, logs: logs.map((log) => formatLog(log)), strict: true }));
248
301
  start = end + 1n;
249
302
  } catch (error) {
250
303
  if (span <= 1n) throw error;
251
304
  span /= 2n;
252
305
  }
253
306
  }
254
- return out;
307
+ return { events: out, unknown: undecoded(principal, ref, raw, out) };
255
308
  }
256
309
 
257
310
  /**
@@ -259,18 +312,18 @@ async function mandateEvents(client: PublicClient, principal: Address, ref: Hex,
259
312
  * block, so the scan for its events starts there and never at genesis.
260
313
  * `toBlock` bounds the scan when only the events up to one block matter.
261
314
  */
262
- async function boundMandate(client: PublicClient, principal: Address, ref: Hex, factory: Address, toBlock?: bigint): Promise<{ mandate?: BoundMandate; events: MandateEvent[]; problems: string[] }> {
315
+ async function boundMandate(client: PublicClient, principal: Address, ref: Hex, factory: Address, toBlock?: bigint): Promise<Found> {
263
316
  const state = await client.readContract({ address: principal, abi: mandateBindingAbi, functionName: "mandates", args: [ref] });
264
- if (!isAddressEqual(state.principal, principal)) return { events: [], problems: [`no mandate ${short(ref)} is bound on ${principal}`] };
317
+ if (!isAddressEqual(state.principal, principal)) return { events: [], unknown: [], problems: [`no mandate ${short(ref)} is bound on ${principal}`] };
265
318
  const from = state.boundBlock;
266
319
  const to = toBlock ?? (await client.getBlockNumber());
267
- const events = await mandateEvents(client, principal, ref, from, to);
320
+ const { events, unknown } = await mandateEvents(client, principal, ref, from, to);
268
321
  const bound = events.filter((e) => e.eventName === "MandateBound");
269
- if (bound.length !== 1) return { events, problems: [bound.length === 0 ? `no MandateBound for ${short(ref)} on ${principal} in block ${from}, where its state says it was bound` : `${bound.length} MandateBound events for ${short(ref)}`] };
322
+ if (bound.length !== 1) return { events, unknown, problems: [bound.length === 0 ? `no MandateBound for ${short(ref)} on ${principal} in block ${from}, where its state says it was bound` : `${bound.length} MandateBound events for ${short(ref)}`] };
270
323
  const [log] = bound;
271
- if (log === undefined || log.eventName !== "MandateBound" || log.blockNumber === null || log.transactionHash === null) return { events, problems: ["the MandateBound event is pending"] };
324
+ if (log === undefined || log.eventName !== "MandateBound" || log.blockNumber === null || log.transactionHash === null) return { events, unknown, problems: ["the MandateBound event is pending"] };
272
325
  try { await assertMetalAccount(client, principal, factory); }
273
- catch (error) { return { events, problems: [message(error)] }; }
326
+ catch (error) { return { events, unknown, problems: [message(error)] }; }
274
327
  const agentKey = decodeAgentKey(log.args.agentKey);
275
328
  const problems: string[] = [];
276
329
  if (mandateReference(principal, agentKey, log.args.hashC).toLowerCase() !== ref.toLowerCase()) problems.push("the reference does not derive from the principal, the mandate key and hash(C)");
@@ -293,6 +346,7 @@ async function boundMandate(client: PublicClient, principal: Address, ref: Hex,
293
346
  ...(successor === undefined ? {} : { successor }),
294
347
  },
295
348
  events,
349
+ unknown,
296
350
  problems,
297
351
  };
298
352
  }
@@ -350,7 +404,11 @@ async function verifyOutcome(client: PublicClient, chainId: number, m: BoundMand
350
404
  if (!verifyL3aSignature(m.agentKey, l3a.digest, fields.r, fields.s)) found.push("not the bound mandate key's signature");
351
405
  if (!isAddressEqual(pull.to, o.to)) found.push("another payee than the event records");
352
406
  if (pull.value !== o.amount) found.push("another amount than the event records");
353
- if (one.checkoutMandate !== undefined) {
407
+ const other = one.checkoutMandate === undefined ? undefined : othersOrder(one.checkoutMandate, fields.transactionId, pull);
408
+ if (other !== undefined) {
409
+ // Its other checks would only list how that other payment differs.
410
+ found.push(`the checkout mandate belongs to another payment: ${other}`);
411
+ } else if (one.checkoutMandate !== undefined) {
354
412
  // The offer served, when the verifier served it; the signed order's own otherwise.
355
413
  const offer = one.requirements !== null && typeof one.requirements === "object" && !Array.isArray(one.requirements) ? x402Offer(one.requirements) : undefined;
356
414
  found.push(...verifyCheckoutMandate(one.checkoutMandate, {
@@ -389,7 +447,28 @@ async function verifyOutcome(client: PublicClient, chainId: number, m: BoundMand
389
447
  return verified;
390
448
  }
391
449
 
392
- type Found = { mandate?: BoundMandate; events: readonly MandateEvent[]; problems: string[] };
450
+ /**
451
+ * How a checkout mandate that parses is another payment's: a checkout hash
452
+ * other than the per-payment mandate's `transaction_id`, or another window,
453
+ * as both are signed together for one payment. Undefined when it is this
454
+ * payment's, or does not parse, which its own check names.
455
+ */
456
+ function othersOrder(checkoutMandate: string, transactionId: Hex, pull: Pull): string | undefined {
457
+ try {
458
+ const l3b = readL3b(checkoutMandate);
459
+ if (!Number.isSafeInteger(l3b.iat) || !Number.isSafeInteger(l3b.exp)) return undefined;
460
+ const differs = [
461
+ ...(checkoutHash(l3b.checkoutJwt) === transactionId.toLowerCase() ? [] : ["its checkout hash is not the transaction_id this payment's per-payment mandate signed"]),
462
+ ...(BigInt(l3b.iat as number) === pull.validAfter && BigInt(l3b.exp as number) === pull.validBefore ? [] : ["its window is not this payment's"]),
463
+ ];
464
+ return differs.length === 0 ? undefined : differs.join(", and ");
465
+ } catch {
466
+ return undefined;
467
+ }
468
+ }
469
+
470
+ /** The mandate's binding and events, with what the binding's checks found and, apart, the events under it that did not decode. */
471
+ type Found = { mandate?: BoundMandate; events: readonly MandateEvent[]; unknown: string[]; problems: string[] };
393
472
 
394
473
  async function verify(client: PublicClient, found: Found, opts: VerifyMandateOptions & { accountFactory: Address }, only: { tx: Hash; events: readonly MandateEvent[] } | undefined): Promise<MandateVerification> {
395
474
  const steps: VerifyStep[] = [];
@@ -399,7 +478,7 @@ async function verify(client: PublicClient, found: Found, opts: VerifyMandateOpt
399
478
 
400
479
  const m = found.mandate;
401
480
  if (m === undefined) {
402
- record(1, found.problems, "");
481
+ record(1, [...found.problems, ...found.unknown], "");
403
482
  skip(2, "no binding to check against");
404
483
  skip(3, "no binding to check against");
405
484
  skip(4, "no binding to check against");
@@ -457,11 +536,13 @@ async function verify(client: PublicClient, found: Found, opts: VerifyMandateOpt
457
536
  const checkoutSdHash = c !== undefined && credentialIsBound ? extractTerms(c).checkoutSdHash : undefined;
458
537
  const one = outcomes.length === 1 ? { ...opts, ...(checkoutSdHash === undefined ? {} : { checkoutSdHash }) } : {};
459
538
  for (const o of outcomes) payments.push(await verifyOutcome(client, chainId, m, o, one));
460
- const problems = payments.filter((p) => p.problems.length > 0).map((p) => `${p.kind} in ${short(p.txHash)}: ${p.problems.join(", ")}`);
539
+ const problems = [...found.unknown, ...payments.filter((p) => p.problems.length > 0).map((p) => `${p.kind} in ${short(p.txHash)}: ${p.problems.join(", ")}`)];
461
540
  if (only !== undefined && outcomes.length === 0) problems.push(`transaction ${only.tx} carries no payment or refusal under ${short(m.ref)}`);
462
541
  if ((opts.requirements !== undefined || opts.checkoutMandate !== undefined) && outcomes.length > 1) problems.push("requirements or a signed offer given, but more than one payment to check them against");
463
542
  const paid = payments.filter((p) => p.kind === "payment").length;
464
- record(4, problems, `${paid} payment(s) and ${payments.length - paid} refusal(s), each the bound mandate key's per-payment mandate rebuilt byte for byte from its transaction, inside the mandate at its block`);
543
+ // One payment's check says whether it covered the signed order.
544
+ const order = only === undefined ? "" : opts.checkoutMandate === undefined ? "; no checkout mandate was given, so the order was not checked" : "; the checkout mandate is this payment's signed order";
545
+ record(4, problems, `${paid} payment(s) and ${payments.length - paid} refusal(s), each the bound mandate key's per-payment mandate rebuilt byte for byte from its transaction, inside the mandate at its block${order}`);
465
546
 
466
547
  return { ok: steps.every((s) => s.outcome !== "fail"), steps, mandate: m, payments };
467
548
  }
@@ -505,11 +586,23 @@ export async function verifyMandatePayment(client: PublicClient, opts: VerifyMan
505
586
  // passes on part of a transaction's outcomes.
506
587
  const clone = metalAccountCode(await client.readContract({ address: accountFactory, abi: metalAccountFactoryAbi, functionName: "implementation" }));
507
588
  const emitters = new Map<string, boolean>();
508
- for (const address of new Set(logs.map((log) => log.address.toLowerCase() as Address))) {
509
- emitters.set(address, (await client.getCode({ address }))?.toLowerCase() === clone);
510
- }
589
+ const trusted = async (address: string): Promise<boolean> => {
590
+ if (!emitters.has(address)) emitters.set(address, (await client.getCode({ address: address as Address }))?.toLowerCase() === clone);
591
+ return emitters.get(address) === true;
592
+ };
593
+ for (const address of new Set(logs.map((log) => log.address.toLowerCase()))) await trusted(address);
511
594
  const carried = logs.filter((log) => emitters.get(log.address.toLowerCase()) === true);
512
- if (carried.length === 0) throw new Error(logs.length === 0 ? `transaction ${opts.tx} carries no payment or refusal under a mandate` : `transaction ${opts.tx} carries mandate outcomes only from ${[...emitters.keys()].join(", ")}, no account of the trusted factory ${accountFactory}`);
595
+ if (carried.length === 0) {
596
+ // An account of another version than the SDK's emits its outcomes under
597
+ // topics the ABI does not decode, or under its topics in another layout.
598
+ const read = new Set(parseEventLogs({ abi: mandateBindingAbi, logs: receipt.logs, strict: true }).map(placeOf));
599
+ for (const log of receipt.logs) {
600
+ const topic = log.topics[0]?.toLowerCase() as Hex | undefined;
601
+ if (topic === undefined || (MANDATE_EVENTS.has(topic) ? read.has(placeOf(log)) : ACCOUNT_EVENTS.has(topic)) || !(await trusted(log.address.toLowerCase()))) continue;
602
+ throw new Error(`transaction ${opts.tx} carries no payment or refusal this SDK decodes: the account ${getAddress(log.address)} emitted an event under topic ${topic}, which this SDK does not decode; ${OTHER_VERSIONS}`);
603
+ }
604
+ throw new Error(logs.length === 0 ? `transaction ${opts.tx} carries no payment or refusal under a mandate` : `transaction ${opts.tx} carries mandate outcomes only from ${[...new Set(logs.map((log) => log.address.toLowerCase()))].join(", ")}, no account of the trusted factory ${accountFactory}`);
605
+ }
513
606
  // One transaction may settle payments under several mandates (a sponsor's
514
607
  // batch). `ref` or the credential in hand names the one to verify; a
515
608
  // credential that names none of them is still checked against a single
@@ -1,5 +1,8 @@
1
- import { describe, expect, it } from "vitest";
2
- import { METAL_NETWORKS, rpcUrlFor } from "./networks.js";
1
+ import { createServer } from "node:http";
2
+ import { inspect } from "node:util";
3
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
4
+ import { createPublicClient, erc20Abi, http, type HttpTransport } from "viem";
5
+ import { METAL_NETWORKS, rpcTransport, rpcUrlFor } from "./networks.js";
3
6
 
4
7
  describe("rpcUrlFor", () => {
5
8
  it("prefers the override, then the network's default RPC", () => {
@@ -14,3 +17,161 @@ describe("rpcUrlFor", () => {
14
17
  );
15
18
  });
16
19
  });
20
+
21
+ const PASSWORD_ECHO = "ops:s3cr3t-pass";
22
+ const PASSWORD = "s3cr3t-pass";
23
+
24
+ describe("rpcTransport", () => {
25
+ /** An endpoint that fails each request over HTTP, never answers it, or answers it with a JSON-RPC error. */
26
+ const node = createServer((req, res) => {
27
+ let body = "";
28
+ req.on("data", (c) => (body += c));
29
+ req.on("end", () => {
30
+ const { id } = JSON.parse(body);
31
+ if (req.url?.startsWith("/http-error/")) return res.writeHead(502).end("bad gateway");
32
+ if (req.url?.startsWith("/hang/")) return;
33
+ res.setHeader("content-type", "application/json");
34
+ // An endpoint whose error names a different path on its own host than the one requested,
35
+ // as an Express-style 404 or a proxy's own error page can.
36
+ // A server error that echoes the request path alone, or a header that names it, or the credentials it was given.
37
+ if (req.url?.startsWith("/echoes-path-only/"))
38
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `Cannot POST ${req.url}` } }));
39
+ if (req.url?.startsWith("/echoes-header/")) return res.writeHead(502, { "content-type": "text/plain", location: req.url, "x-upstream": req.url }).end("bad gateway");
40
+ // A provider that names the key alone, a percent-encoded path as it decodes it, or the password alone.
41
+ if (req.url?.startsWith("/echoes-segment/"))
42
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `Unauthorized: invalid project id ${req.url.split("?")[0]!.slice("/echoes-segment/".length)}` } }));
43
+ if (req.url?.startsWith("/echoes-query/"))
44
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `Unauthorized: invalid api key ${new URL(req.url, "http://x").searchParams.get("key")}` } }));
45
+ if (req.url?.startsWith("/echoes-decoded/"))
46
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `Unauthorized: ${decodeURIComponent(req.url.slice("/echoes-decoded/".length))}` } }));
47
+ if (req.url?.startsWith("/echoes-password/"))
48
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `bad password ${PASSWORD}` } }));
49
+ if (req.url?.startsWith("/echoes-in-data/"))
50
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: "upstream failed", data: { reason: `Cannot POST ${req.url}`, trace: [`at ${req.url}`] } } }));
51
+ if (req.url?.startsWith("/echoes-userinfo/"))
52
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `bad credentials ${PASSWORD_ECHO}` } }));
53
+ if (req.url?.startsWith("/echoes-another-path/"))
54
+ return res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: `Cannot POST ${origin}/leaked-key-in-another-path/${req.url.slice("/echoes-another-path/".length)}` } }));
55
+ res.end(JSON.stringify({ jsonrpc: "2.0", id, error: { code: -32000, message: "header not found" } }));
56
+ });
57
+ });
58
+ let origin: string;
59
+ beforeAll(async () => {
60
+ origin = await new Promise<string>((resolve) =>
61
+ node.listen(0, "127.0.0.1", () => resolve(`http://127.0.0.1:${(node.address() as { port: number }).port}`)),
62
+ );
63
+ });
64
+ afterAll(() => new Promise<void>((resolve) => node.close(() => resolve()) && node.closeAllConnections()));
65
+ const CREDENTIAL = "k3y-0f-the-endpoint";
66
+ /** The error, its causes and their fields, as a log prints them. */
67
+ const printed = (error: unknown) => inspect(error, { depth: Infinity });
68
+ const failures = (url: string, transport = rpcTransport) => {
69
+ const client = createPublicClient({ transport: transport(url) });
70
+ return Promise.all([
71
+ client.getChainId().catch((e: unknown) => e),
72
+ client.readContract({ address: `0x${"33".repeat(20)}`, abi: erc20Abi, functionName: "totalSupply" }).catch((e: unknown) => e),
73
+ ]);
74
+ };
75
+
76
+ it("throws viem's errors without the RPC URL, in them or their causes", async () => {
77
+ const urls = [`${origin}/http-error/${CREDENTIAL}`, `${origin}/rpc-error/${CREDENTIAL}`, `${origin}/q?key=${CREDENTIAL}`, `http://127.0.0.1:1/${CREDENTIAL}`];
78
+ for (const url of urls) {
79
+ for (const error of await failures(url)) {
80
+ expect(error).toBeInstanceOf(Error);
81
+ expect(printed(error)).toContain("[RPC URL hidden]");
82
+ expect(printed(error)).not.toContain(CREDENTIAL);
83
+ expect(printed(error)).not.toMatch(/https?:\/\/127\.0\.0\.1/);
84
+ }
85
+ }
86
+ });
87
+
88
+ it("names an RPC endpoint that gives no answer, or a server error, as unreachable, and one that answers with an RPC error as answering", async () => {
89
+ // A short timeout and no retries, so a request that hangs fails at once.
90
+ const quick = (url: string) => ((options) => rpcTransport(url)({ ...options, timeout: 300, retryCount: 0 })) as HttpTransport;
91
+ for (const url of [`${origin}/hang/${CREDENTIAL}`, `${origin}/http-error/${CREDENTIAL}`, `http://127.0.0.1:1/${CREDENTIAL}`]) {
92
+ for (const error of await failures(url, quick)) {
93
+ expect(String(error)).toMatch(/^\w+: the RPC endpoint could not be reached: /);
94
+ expect(printed(error)).not.toContain(CREDENTIAL);
95
+ }
96
+ }
97
+ for (const error of await failures(`${origin}/rpc-error/${CREDENTIAL}`, quick)) expect(String(error)).not.toMatch(/could not be reached/);
98
+ });
99
+
100
+ it("is the check that fails: viem's own transport prints the URL", async () => {
101
+ for (const error of await failures(`${origin}/http-error/${CREDENTIAL}`, (url) => http(url))) expect(printed(error)).toContain(CREDENTIAL);
102
+ });
103
+
104
+ /** Every string an error carries, in its fields, its causes and its headers, as a log or a catch block could read them. */
105
+ const strings = (value: unknown, seen = new Set<unknown>()): string[] => {
106
+ if (typeof value === "string") return [value];
107
+ if (value === null || typeof value !== "object" || seen.has(value)) return [];
108
+ seen.add(value);
109
+ if (value instanceof Headers) return [...value].flat();
110
+ const fields = Object.getOwnPropertyNames(value).map((key) => (value as Record<string, unknown>)[key]);
111
+ return fields.flatMap((field) => strings(field, seen));
112
+ };
113
+
114
+ it("hides the path and query of the RPC URL where the endpoint echoes them without the host", async () => {
115
+ for (const url of [`${origin}/echoes-path-only/${CREDENTIAL}`, `${origin}/echoes-path-only/v1?key=${CREDENTIAL}`]) {
116
+ for (const error of await failures(url)) {
117
+ expect(error).toBeInstanceOf(Error);
118
+ expect(strings(error).join("\n")).not.toContain(CREDENTIAL);
119
+ }
120
+ }
121
+ });
122
+
123
+ it("hides the RPC URL where the endpoint names it in a response header", async () => {
124
+ for (const error of await failures(`${origin}/echoes-header/${CREDENTIAL}`)) {
125
+ expect(error).toBeInstanceOf(Error);
126
+ expect(strings(error).join("\n")).not.toContain(CREDENTIAL);
127
+ }
128
+ });
129
+
130
+ it("hides the RPC URL wherever the endpoint puts it in its error, nested objects and lists included", async () => {
131
+ for (const error of await failures(`${origin}/echoes-in-data/${CREDENTIAL}`)) {
132
+ expect(error).toBeInstanceOf(Error);
133
+ expect(strings(error).join("\n")).not.toContain(CREDENTIAL);
134
+ }
135
+ });
136
+
137
+ it("hides a key the endpoint echoes alone: a path segment, a query value, or a percent-encoded segment as it decodes it", async () => {
138
+ for (const url of [
139
+ `${origin}/echoes-segment/${CREDENTIAL}`,
140
+ `${origin}/echoes-segment/v1/${CREDENTIAL}/rpc?x=1`,
141
+ `${origin}/echoes-query/v1?key=${CREDENTIAL}`,
142
+ `${origin}/echoes-decoded/${encodeURIComponent("key with space and more")}`,
143
+ ]) {
144
+ for (const error of await failures(url)) {
145
+ expect(error).toBeInstanceOf(Error);
146
+ const text = strings(error).join("\n");
147
+ expect(text).not.toContain(CREDENTIAL);
148
+ expect(text).not.toContain("key with space and more");
149
+ }
150
+ }
151
+ });
152
+
153
+ it("hides a password the endpoint echoes alone", async () => {
154
+ const [user] = PASSWORD_ECHO.split(":");
155
+ for (const error of await failures(`http://${user}:${PASSWORD}@${origin.slice("http://".length)}/echoes-password/x`)) {
156
+ expect(error).toBeInstanceOf(Error);
157
+ expect(strings(error).join("\n")).not.toContain(PASSWORD);
158
+ }
159
+ });
160
+
161
+ it("hides the user and password of the RPC URL where the endpoint echoes them", async () => {
162
+ const [user, password] = PASSWORD_ECHO.split(":");
163
+ for (const error of await failures(`http://${user}:${password}@${origin.slice("http://".length)}/echoes-userinfo/x`)) {
164
+ expect(error).toBeInstanceOf(Error);
165
+ expect(strings(error).join("\n")).not.toContain(PASSWORD_ECHO);
166
+ expect(strings(error).join("\n")).not.toContain(password);
167
+ }
168
+ });
169
+
170
+ it("hides a same-host URL that differs from rpcUrl, which only the same-host check catches", async () => {
171
+ for (const error of await failures(`${origin}/echoes-another-path/${CREDENTIAL}`)) {
172
+ expect(printed(error)).toContain("[RPC URL hidden]");
173
+ expect(printed(error)).not.toContain(CREDENTIAL);
174
+ expect(printed(error)).not.toMatch(/https?:\/\/127\.0\.0\.1/);
175
+ }
176
+ });
177
+ });
package/src/networks.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Network } from "@x402/core/types";
2
- import type { Address } from "viem";
2
+ import { HttpRequestError, TimeoutError, http, type Address, type HttpTransport } from "viem";
3
3
 
4
4
  export type MetalNetworkName = "devnet" | "testnet";
5
5
 
@@ -65,7 +65,7 @@ export const METAL_NETWORKS: Record<MetalNetworkName, MetalNetwork> = {
65
65
  facilitatorUrl: "https://x402-testnet.metalntwx.com/x402/facilitator",
66
66
  // What `just estate-deploy-account-factory testnet` recorded in
67
67
  // deploy/estate/environments/testnet/account-factory.json for this chain.
68
- accountFactory: "0x4D188AC7815a9EAEa219a49D687B71898A487578",
68
+ accountFactory: "0x3dbED5DebA965A639bB0Fd40BE96b97922070FA8",
69
69
  },
70
70
  };
71
71
 
@@ -98,6 +98,95 @@ export function rpcUrlFor(network: MetalNetwork, override: string | undefined, h
98
98
  );
99
99
  }
100
100
 
101
+ /** What an RPC URL reads as in an error. */
102
+ const HIDDEN_RPC_URL = "[RPC URL hidden]";
103
+
104
+ /**
105
+ * viem's `http` transport to `rpcUrl`, whose errors do not carry the URL: it
106
+ * can hold the endpoint's credential, so each error it throws, and each of
107
+ * that error's causes, names it as `[RPC URL hidden]`. An endpoint that
108
+ * gives no answer, or a server error, is named as one that could not be
109
+ * reached.
110
+ */
111
+ export function rpcTransport(rpcUrl: string): HttpTransport {
112
+ const transport = http(rpcUrl);
113
+ const parsed = URL.canParse(rpcUrl) ? new URL(rpcUrl) : undefined;
114
+ const host = parsed?.host;
115
+ // What an endpoint can echo of the URL without its host: the path and
116
+ // query, which can hold a key, and the user and password.
117
+ const decoded = (text: string) => {
118
+ try {
119
+ return decodeURIComponent(text);
120
+ } catch {
121
+ return text;
122
+ }
123
+ };
124
+ const path = parsed ? parsed.pathname + parsed.search : "";
125
+ const secrets = [rpcUrl];
126
+ if (path.length > 1) secrets.push(path);
127
+ // A key is often echoed alone: each path segment and query value long enough to be one.
128
+ if (parsed) {
129
+ const parts = [...decoded(parsed.pathname).split("/"), ...[...parsed.searchParams.values()]];
130
+ for (const part of parts) if (part.length >= 8) secrets.push(part);
131
+ }
132
+ if (parsed?.username || parsed?.password) {
133
+ const [user, password] = [decoded(parsed.username), decoded(parsed.password)];
134
+ secrets.push(`${user}:${password}`);
135
+ if (password.length >= 4) secrets.push(password);
136
+ }
137
+ // viem prints the URL as given, or without its user and password.
138
+ const hide = (text: string) =>
139
+ secrets
140
+ .reduce((hidden, secret) => hidden.replaceAll(secret, HIDDEN_RPC_URL), text)
141
+ .replace(/https?:\/\/[^\s"'<>]+/g, (url) => (URL.canParse(url) && new URL(url).host === host ? HIDDEN_RPC_URL : url));
142
+ // Every string the error holds, in its fields, its causes, its arrays and
143
+ // its response headers, as viem or the endpoint put it there: the fields
144
+ // that name the URL are not a list to keep.
145
+ const scrub = (value: unknown, seen: Set<unknown>) => {
146
+ if (value === null || typeof value !== "object" || seen.has(value)) return;
147
+ seen.add(value);
148
+ for (const key of Object.getOwnPropertyNames(value)) {
149
+ const fields = value as Record<string, unknown>;
150
+ let field: unknown;
151
+ try {
152
+ field = fields[key];
153
+ } catch {
154
+ continue;
155
+ }
156
+ let hidden: unknown = field;
157
+ if (typeof field === "string") hidden = hide(field);
158
+ else if (field instanceof Headers) {
159
+ const entries = [...field].map(([name, text]) => [name, hide(text)] as [string, string]);
160
+ if (entries.some(([name, text]) => field.get(name) !== text)) hidden = new Headers(entries);
161
+ } else scrub(field, seen);
162
+ if (hidden !== field)
163
+ try {
164
+ fields[key] = hidden;
165
+ } catch {
166
+ // A property that cannot be written holds what it held.
167
+ }
168
+ }
169
+ };
170
+ return ((options) => {
171
+ const made = transport(options);
172
+ return {
173
+ ...made,
174
+ request: (async (args, requestOptions) => {
175
+ try {
176
+ return await made.request(args, requestOptions);
177
+ } catch (error) {
178
+ if (error instanceof TimeoutError || (error instanceof HttpRequestError && (error.status === undefined || error.status >= 500))) {
179
+ error.shortMessage = `the RPC endpoint could not be reached: ${error.shortMessage}`;
180
+ error.message = `the RPC endpoint could not be reached: ${error.message}`;
181
+ }
182
+ scrub(error, new Set());
183
+ throw error;
184
+ }
185
+ }) as typeof made.request,
186
+ };
187
+ }) as HttpTransport;
188
+ }
189
+
101
190
  /** A dollar token payers authorize ERC-3009 transfers of. */
102
191
  export interface DollarToken {
103
192
  address: `0x${string}`;
@@ -145,8 +234,9 @@ export function usdzBaseUnits(amount: string | number): string {
145
234
  /** Format base units back to a decimal `*USDZ` string for humans, trailing zeros trimmed. */
146
235
  export function formatUsdz(amount: bigint | string): string {
147
236
  const units = BigInt(amount);
237
+ const magnitude = units < 0n ? -units : units;
148
238
  const scale = 10n ** BigInt(USDZ.decimals);
149
- const whole = units / scale;
150
- const fraction = (units % scale).toString().padStart(USDZ.decimals, "0").replace(/0+$/, "");
151
- return fraction ? `${whole}.${fraction}` : whole.toString();
239
+ const whole = magnitude / scale;
240
+ const fraction = (magnitude % scale).toString().padStart(USDZ.decimals, "0").replace(/0+$/, "");
241
+ return `${units < 0n ? "-" : ""}${fraction ? `${whole}.${fraction}` : whole}`;
152
242
  }