@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.
- package/README.md +41 -14
- package/dist/clearing-client.d.ts +51 -4
- package/dist/clearing-client.js +277 -45
- package/dist/clearing-custody.d.ts +17 -6
- package/dist/clearing-custody.js +91 -29
- package/dist/clearing-x402.d.ts +10 -1
- package/dist/clearing-x402.js +59 -13
- package/dist/client.d.ts +7 -2
- package/dist/client.js +38 -5
- package/dist/mandates/account.d.ts +9 -4
- package/dist/mandates/account.js +15 -11
- package/dist/mandates/seller.js +3 -3
- package/dist/mandates/verify.js +107 -18
- package/dist/networks.d.ts +9 -1
- package/dist/networks.js +105 -4
- package/package.json +1 -1
- package/src/clearing-client.test.ts +850 -0
- package/src/clearing-client.ts +366 -57
- package/src/clearing-custody.test.ts +240 -11
- package/src/clearing-custody.ts +106 -28
- package/src/clearing-server.test.ts +72 -10
- package/src/clearing-x402.test.ts +1 -1
- package/src/clearing-x402.ts +61 -12
- package/src/client.test.ts +8 -0
- package/src/client.ts +46 -7
- package/src/mandates/account.ts +15 -11
- package/src/mandates/seller.ts +3 -3
- package/src/mandates/verify.ts +113 -20
- package/src/networks.test.ts +163 -2
- package/src/networks.ts +95 -5
package/src/mandates/verify.ts
CHANGED
|
@@ -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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
package/src/networks.test.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
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: "
|
|
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 =
|
|
150
|
-
const fraction = (
|
|
151
|
-
return fraction ? `${whole}.${fraction}` : whole
|
|
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
|
}
|