@metalntwx/metal-pay 0.2.0 → 0.3.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.
Files changed (75) hide show
  1. package/README.md +104 -38
  2. package/dist/clearing-abi.d.ts +1836 -0
  3. package/dist/clearing-abi.js +2361 -0
  4. package/dist/clearing-client.d.ts +96 -159
  5. package/dist/clearing-client.js +412 -553
  6. package/dist/clearing-codec.d.ts +57 -0
  7. package/dist/clearing-codec.js +173 -0
  8. package/dist/clearing-custody.d.ts +202 -0
  9. package/dist/clearing-custody.js +552 -0
  10. package/dist/clearing-server.d.ts +30 -49
  11. package/dist/clearing-server.js +87 -67
  12. package/dist/clearing-x402.d.ts +38 -0
  13. package/dist/clearing-x402.js +249 -0
  14. package/dist/client.d.ts +14 -15
  15. package/dist/client.js +53 -22
  16. package/dist/index.d.ts +4 -1
  17. package/dist/index.js +4 -1
  18. package/dist/mandates/account.d.ts +9 -4
  19. package/dist/mandates/account.js +15 -11
  20. package/dist/mandates/binding.d.ts +4 -27
  21. package/dist/mandates/binding.js +5 -9
  22. package/dist/mandates/checkout.js +5 -5
  23. package/dist/mandates/client.js +4 -3
  24. package/dist/mandates/l3a.d.ts +2 -2
  25. package/dist/mandates/l3a.js +5 -4
  26. package/dist/mandates/seller.d.ts +1 -1
  27. package/dist/mandates/seller.js +4 -4
  28. package/dist/mandates/verify.d.ts +4 -6
  29. package/dist/mandates/verify.js +147 -82
  30. package/dist/mandates/vi/credential.js +4 -4
  31. package/dist/networks.d.ts +9 -1
  32. package/dist/networks.js +105 -4
  33. package/dist/node.d.ts +11 -0
  34. package/dist/node.js +97 -0
  35. package/package.json +18 -11
  36. package/src/clearing-abi.ts +2361 -0
  37. package/src/clearing-client.test.ts +850 -0
  38. package/src/clearing-client.ts +612 -0
  39. package/src/clearing-codec.test.ts +57 -0
  40. package/src/clearing-codec.ts +251 -0
  41. package/src/clearing-custody.test.ts +763 -0
  42. package/src/clearing-custody.ts +743 -0
  43. package/src/clearing-fixture.json +37 -0
  44. package/src/clearing-server.test.ts +450 -0
  45. package/src/clearing-server.ts +123 -0
  46. package/src/clearing-x402.test.ts +181 -0
  47. package/src/clearing-x402.ts +328 -0
  48. package/src/client.test.ts +333 -0
  49. package/src/client.ts +195 -0
  50. package/src/index.ts +33 -0
  51. package/src/mandates/account.ts +95 -0
  52. package/src/mandates/binding.ts +280 -0
  53. package/src/mandates/chain.ts +68 -0
  54. package/src/mandates/checkout.ts +368 -0
  55. package/src/mandates/client.ts +159 -0
  56. package/src/mandates/extension.ts +48 -0
  57. package/src/mandates/index.ts +121 -0
  58. package/src/mandates/ingest.ts +42 -0
  59. package/src/mandates/jcs.ts +16 -0
  60. package/src/mandates/l3a.ts +216 -0
  61. package/src/mandates/seller.ts +91 -0
  62. package/src/mandates/verify.ts +625 -0
  63. package/src/mandates/vi/credential.ts +477 -0
  64. package/src/mandates/vi/issue.ts +118 -0
  65. package/src/mandates/vi/sd-jwt.ts +189 -0
  66. package/src/networks.test.ts +177 -0
  67. package/src/networks.ts +242 -0
  68. package/src/node.test.ts +24 -0
  69. package/src/node.ts +96 -0
  70. package/src/server.test.ts +223 -0
  71. package/src/server.ts +77 -0
  72. package/dist/clearing.d.ts +0 -204
  73. package/dist/clearing.js +0 -151
  74. package/dist/file-storage.d.ts +0 -39
  75. package/dist/file-storage.js +0 -195
@@ -0,0 +1,625 @@
1
+ // Verifying a mandate and its payments from the chain, with no Metal service:
2
+ // the mandate's binding on the principal's account; the credential in hand
3
+ // against what was bound, and its signatures under the issuer the chain
4
+ // recorded; and each payment or refusal under the mandate as the agent key's
5
+ // per-payment mandate, rebuilt byte for byte from the transaction that
6
+ // carried it. Any JSON-RPC node serves the reads, with no historical state:
7
+ // the account's code is read at the latest block, and a clone's code never
8
+ // changes, so what it is now it was when it bound and paid. What a verifier
9
+ // cannot rebuild (the budget's running total, the nonce's single use) is read
10
+ // as the contract recorded it in the event and checked against the limits.
11
+
12
+ import {
13
+ type Address,
14
+ type Hash,
15
+ type Hex,
16
+ type ParseEventLogsReturnType,
17
+ type PublicClient,
18
+ type RpcLog,
19
+ decodeFunctionData,
20
+ formatLog,
21
+ getAbiItem,
22
+ getAddress,
23
+ hexToBigInt,
24
+ isAddressEqual,
25
+ numberToHex,
26
+ erc20Abi,
27
+ parseAbi,
28
+ parseEventLogs,
29
+ slice,
30
+ toEventSelector,
31
+ toFunctionSelector,
32
+ } from "viem";
33
+ import {
34
+ type AgentKey,
35
+ type Constraints,
36
+ type L3aFields,
37
+ type Pull,
38
+ REASONS,
39
+ REFUSALS,
40
+ type ReasonName,
41
+ type Terms,
42
+ decodeAgentKey,
43
+ encodeAgentKey,
44
+ mandateBindingAbi,
45
+ mandateReference,
46
+ reasonName,
47
+ sameAgentKey,
48
+ } from "./binding.js";
49
+ import { accountFactoryFor, metalNetwork } from "../networks.js";
50
+ import { assertMetalAccount, metalAccountCode, metalAccountFactoryAbi } from "./account.js";
51
+ import { publicMessage } from "./chain.js";
52
+ import { checkoutHash, readL3b, verifyCheckoutMandate, x402Offer, lineItemOf } from "./checkout.js";
53
+ import { type Json, jcs } from "./jcs.js";
54
+ import { buildL3a, mandateNonce, serializeL3a, transactionIdOf, verifyL3aSignature } from "./l3a.js";
55
+ import { type Credential, type TrustedIssuer, extractTerms, hashCredential, issuerKeyId, verifyCredential } from "./vi/credential.js";
56
+
57
+ interface LogPosition { block: bigint; transactionIndex: number; logIndex: number }
58
+ function before(a: LogPosition, b: LogPosition): boolean {
59
+ return a.block < b.block || (a.block === b.block && (a.transactionIndex < b.transactionIndex || (a.transactionIndex === b.transactionIndex && a.logIndex < b.logIndex)));
60
+ }
61
+ function position(log: { blockNumber: bigint | null; transactionIndex: number | null; logIndex: number | null }): LogPosition {
62
+ if (log.blockNumber === null || log.transactionIndex === null || log.logIndex === null) throw new Error("pending mandate event");
63
+ return { block: log.blockNumber, transactionIndex: log.transactionIndex, logIndex: log.logIndex };
64
+ }
65
+
66
+ export interface VerifyStep {
67
+ step: 1 | 2 | 3 | 4;
68
+ title: string;
69
+ outcome: "pass" | "fail" | "skipped";
70
+ detail: string;
71
+ }
72
+
73
+ export const STEP_TITLES = {
74
+ 1: "The mandate is bound on the principal's account: hash(C), the limits, the payees, the mandate key and the issuer key are on chain",
75
+ 2: "The credential in hand hashes to the bound hash(C) and states the bound limits, payees and mandate key",
76
+ 3: "The credential's signatures: the issuer's under a trusted key that is the one recorded at binding, the owner's under the key the issuer bound",
77
+ 4: "Each payment and refusal is the mandate key's per-payment mandate, rebuilt from its transaction, inside the mandate at its block",
78
+ } as const;
79
+
80
+ /** A mandate as its `MandateBound` event records it. */
81
+ export interface BoundMandate {
82
+ ref: Hex;
83
+ principal: Address;
84
+ block: bigint;
85
+ txHash: Hash;
86
+ hashC: Hex;
87
+ issuerKey: Hex;
88
+ agentKey: AgentKey;
89
+ constraints: Constraints;
90
+ terms: Terms;
91
+ /** The block of its revocation, when it was revoked (a replacement revokes too). */
92
+ revokedBlock?: bigint;
93
+ revokedPosition?: LogPosition;
94
+ /** The mandate that replaced it. */
95
+ successor?: Hex;
96
+ }
97
+
98
+ export interface VerifiedPayment {
99
+ kind: "payment" | "refusal";
100
+ txHash: Hash;
101
+ block: bigint;
102
+ to: Address;
103
+ amount: bigint;
104
+ /** The ERC-3009 nonce the token recorded; a seller's `/settlements` row carries it for a payment the facilitator settled. */
105
+ nonce?: Hex;
106
+ reason?: ReasonName;
107
+ /** The L3a SD-JWT, as the VI reference serialises it, when the transaction carried one. */
108
+ perPaymentMandate?: string;
109
+ problems: string[];
110
+ }
111
+
112
+ export interface MandateVerification {
113
+ ok: boolean;
114
+ steps: VerifyStep[];
115
+ mandate?: BoundMandate;
116
+ payments: VerifiedPayment[];
117
+ }
118
+
119
+ export interface VerifyMandateOptions {
120
+ /** The MetalAccountFactory whose clones are trusted, chosen by the verifier, never by the payer; the one `@metalntwx/metal-pay` names for the chain unless given. */
121
+ accountFactory?: Address;
122
+ /** The credential the mandate was bound from; without it steps 2 and 3 are skipped. */
123
+ credential?: Credential;
124
+ /** The issuers whose signature on the credential is accepted; without them step 3 is skipped. */
125
+ trustedIssuers?: readonly TrustedIssuer[];
126
+ /** The seller's x402 payment requirements, to check that the one payment verified was signed over them. */
127
+ requirements?: Json;
128
+ /** The agent's signed offer for the one payment verified, which must be the one its per-payment mandate names. */
129
+ checkoutMandate?: string;
130
+ /** The mandate to verify in a transaction that settles payments under several, for a verifier with no credential; the credential names it otherwise. */
131
+ ref?: Hex;
132
+ }
133
+
134
+ const short = (hex: Hex) => `${hex.slice(0, 10)}…`;
135
+ /** A reason as the facilitator names it to a payer. */
136
+ const named = (reason: ReasonName) => (reason === "None" ? "none" : REFUSALS[reason].reason);
137
+ const message = publicMessage;
138
+
139
+ /** Terms as JSON for comparison: integers as decimal strings, hex lowercase. */
140
+ function toJson(value: unknown): Json {
141
+ return JSON.parse(JSON.stringify(value, (_k, v: unknown) => (typeof v === "bigint" ? v.toString() : typeof v === "string" && /^0x[0-9a-fA-F]*$/.test(v) ? v.toLowerCase() : v))) as Json;
142
+ }
143
+
144
+ /**
145
+ * Why `reason` (`None` for a payment) contradicts what the transfer, the
146
+ * bound terms and the block decide, or undefined. Mandate Binding's checks a
147
+ * verifier can decide, in its order: revocation, expiry, the window, the
148
+ * payee set, the per-payment cap and the recurrence dates. The budget, the
149
+ * occurrences and the nonce's single use depend on the running state the
150
+ * event records, and are checked from it by the caller.
151
+ */
152
+ function reasonContradiction(reason: ReasonName, pull: Pick<Pull, "to" | "value" | "validAfter" | "validBefore">, m: BoundMandate, at: LogPosition & { timestamp: bigint }): string | undefined {
153
+ const c = m.constraints;
154
+ const t = at.timestamp;
155
+ const decided: [ReasonName, boolean][] = [
156
+ ["Revoked", m.revokedPosition !== undefined && before(m.revokedPosition, at)],
157
+ ["Expired", t > c.expiry],
158
+ ["OutsideWindow", t <= pull.validAfter || t >= pull.validBefore],
159
+ ["PayeeNotAllowed", hexToBigInt(m.terms.openSdHash) === 0n && !m.terms.payees.some((p) => isAddressEqual(p.account, pull.to))],
160
+ ["OverPerPaymentCap", pull.value > c.perPaymentCap],
161
+ ["OutsideRecurrence", t < c.recurrenceStart || t > c.recurrenceEnd],
162
+ ];
163
+ const first = decided.find(([, holds]) => holds)?.[0];
164
+ const own = decided.find(([r]) => r === reason);
165
+ if (own !== undefined) {
166
+ if (!own[1]) return `${named(reason)} does not hold for it`;
167
+ return first === undefined || first === reason ? undefined : `${named(first)} holds, and Mandate Binding checks it before ${named(reason)}`;
168
+ }
169
+ if (first === undefined) return undefined;
170
+ if (reason === "None") return `it breaks the mandate: ${named(first)}`;
171
+ return REASONS.indexOf(first) < REASONS.indexOf(reason) ? `${named(first)} holds, and Mandate Binding checks it before ${named(reason)}` : undefined;
172
+ }
173
+
174
+ interface Carried { pull: Pull; fields: L3aFields }
175
+
176
+ /** The selector a payment's calldata carries: `pull` on the principal, whoever submitted it. */
177
+ const PULL_SELECTOR = toFunctionSelector(getAbiItem({ abi: mandateBindingAbi, name: "pull" }));
178
+
179
+ /**
180
+ * Every pull that `input` carries: at the top level, or inside whatever
181
+ * wrapped the call (a multicall, a sponsor's batch, the quorum's own
182
+ * execute). Each occurrence of the selector in the calldata is tried as a
183
+ * call's start; the one the event's digest names is the payment's, and a
184
+ * forged candidate rebuilds to another digest.
185
+ */
186
+ function carriedL3as(input: Hex): Carried[] {
187
+ const found: Carried[] = [];
188
+ const body = input.slice(2);
189
+ for (let at = body.indexOf(PULL_SELECTOR.slice(2)); at !== -1; at = body.indexOf(PULL_SELECTOR.slice(2), at + 2)) {
190
+ if (at % 2 !== 0) continue;
191
+ try {
192
+ const call = decodeFunctionData({ abi: mandateBindingAbi, data: `0x${body.slice(at)}` });
193
+ if (call.functionName !== "pull") continue;
194
+ const [pull, fields] = call.args;
195
+ found.push({ pull: { ...pull }, fields: { ...fields } });
196
+ } catch {
197
+ // Not a call that starts here.
198
+ }
199
+ }
200
+ return found;
201
+ }
202
+
203
+ /**
204
+ * The candidate that is the outcome the event records: it rebuilds to the
205
+ * recorded digest, moves the mandate's token, and its nonce commits to the
206
+ * mandate and that digest (the L3a hashes neither the token nor the nonce,
207
+ * so a copy with either changed is not it); among those, one signed by the
208
+ * agent key comes first, so unrelated calldata with the same L3a (a copy
209
+ * under another signature) cannot stand in for the payment. One another
210
+ * mandate's terms cannot rebuild is not a candidate.
211
+ */
212
+ function carriedL3a(input: Hex, chainId: number, m: BoundMandate, o: Pick<Outcome, "l3aDigest">): Carried {
213
+ const rank = (c: Carried): number => {
214
+ try {
215
+ const l3a = buildL3a(chainId, m.terms, c.pull, c.fields);
216
+ if (slice(l3a.digest, 0, 20).toLowerCase() !== o.l3aDigest.toLowerCase()) return 0;
217
+ if (!isAddressEqual(c.pull.token, m.constraints.token) || mandateNonce(m.ref, l3a.digest) !== c.pull.nonce.toLowerCase()) return 0;
218
+ return 1 + (verifyL3aSignature(m.agentKey, l3a.digest, c.fields.r, c.fields.s) ? 1 : 0);
219
+ } catch {
220
+ return 0;
221
+ }
222
+ };
223
+ const candidates = carriedL3as(input);
224
+ let carried: Carried | undefined;
225
+ let best = 0;
226
+ for (const c of candidates) {
227
+ const r = rank(c);
228
+ if (r > best) [carried, best] = [c, r];
229
+ }
230
+ if (carried === undefined) throw new Error(candidates.length === 0 ? "the transaction carries no pull on the principal" : "no pull the transaction carries rebuilds to the per-payment mandate the event records");
231
+ return carried;
232
+ }
233
+
234
+ type MandateEvent = ParseEventLogsReturnType<typeof mandateBindingAbi, undefined, true>[number];
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
+
284
+ /**
285
+ * The mandate's events on the principal between two blocks, oldest first: one
286
+ * `eth_getLogs` for every event kind, since each puts the reference in its
287
+ * first topic. A node caps the range one request may span, each by its own
288
+ * rule and in its own words, so a refused range is halved until the node
289
+ * answers; an error that holds for a single block is the node's.
290
+ */
291
+ async function mandateEvents(client: PublicClient, principal: Address, ref: Hex, fromBlock: bigint, toBlock: bigint): Promise<{ events: MandateEvent[]; unknown: string[] }> {
292
+ const out: MandateEvent[] = [];
293
+ const raw: RpcLog[] = [];
294
+ let span = toBlock - fromBlock + 1n;
295
+ for (let start = fromBlock; start <= toBlock;) {
296
+ const end = start + span - 1n > toBlock ? toBlock : start + span - 1n;
297
+ try {
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 }));
301
+ start = end + 1n;
302
+ } catch (error) {
303
+ if (span <= 1n) throw error;
304
+ span /= 2n;
305
+ }
306
+ }
307
+ return { events: out, unknown: undecoded(principal, ref, raw, out) };
308
+ }
309
+
310
+ /**
311
+ * The mandate `ref` as the principal's account bound it: its state names the
312
+ * block, so the scan for its events starts there and never at genesis.
313
+ * `toBlock` bounds the scan when only the events up to one block matter.
314
+ */
315
+ async function boundMandate(client: PublicClient, principal: Address, ref: Hex, factory: Address, toBlock?: bigint): Promise<Found> {
316
+ const state = await client.readContract({ address: principal, abi: mandateBindingAbi, functionName: "mandates", args: [ref] });
317
+ if (!isAddressEqual(state.principal, principal)) return { events: [], unknown: [], problems: [`no mandate ${short(ref)} is bound on ${principal}`] };
318
+ const from = state.boundBlock;
319
+ const to = toBlock ?? (await client.getBlockNumber());
320
+ const { events, unknown } = await mandateEvents(client, principal, ref, from, to);
321
+ const bound = events.filter((e) => e.eventName === "MandateBound");
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)}`] };
323
+ const [log] = bound;
324
+ if (log === undefined || log.eventName !== "MandateBound" || log.blockNumber === null || log.transactionHash === null) return { events, unknown, problems: ["the MandateBound event is pending"] };
325
+ try { await assertMetalAccount(client, principal, factory); }
326
+ catch (error) { return { events, unknown, problems: [message(error)] }; }
327
+ const agentKey = decodeAgentKey(log.args.agentKey);
328
+ const problems: string[] = [];
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)");
330
+ const revoked = events.find((e) => e.eventName === "MandateRevoked");
331
+ const replaced = events.find((e) => e.eventName === "MandateReplaced");
332
+ const revokedBlock = revoked?.blockNumber ?? undefined;
333
+ const successor = replaced?.eventName === "MandateReplaced" ? replaced.args.successor : undefined;
334
+ return {
335
+ mandate: {
336
+ ref: ref.toLowerCase() as Hex,
337
+ principal,
338
+ block: log.blockNumber,
339
+ txHash: log.transactionHash,
340
+ hashC: log.args.hashC,
341
+ issuerKey: log.args.issuerKey,
342
+ agentKey,
343
+ constraints: { ...log.args.constraints },
344
+ terms: { ...log.args.terms, payees: log.args.terms.payees.map((p) => ({ ...p })) },
345
+ ...(revokedBlock === undefined || revoked === undefined ? {} : { revokedBlock, revokedPosition: position(revoked) }),
346
+ ...(successor === undefined ? {} : { successor }),
347
+ },
348
+ events,
349
+ unknown,
350
+ problems,
351
+ };
352
+ }
353
+
354
+ interface Outcome extends LogPosition {
355
+ kind: "payment" | "refusal";
356
+ txHash: Hash;
357
+ block: bigint;
358
+ to: Address;
359
+ amount: bigint;
360
+ l3aDigest: Hex;
361
+ agentKey: Hex;
362
+ spent?: bigint;
363
+ occurrences?: bigint;
364
+ reason?: ReasonName;
365
+ }
366
+
367
+ /** The payments and refusals among `events`, oldest first; with `only`, those one transaction carried. */
368
+ function outcomesOf(events: readonly MandateEvent[], only: Hash | undefined): Outcome[] {
369
+ const outcomes: Outcome[] = [];
370
+ for (const log of events) {
371
+ if (log.blockNumber === null || log.transactionHash === null) continue;
372
+ if (log.eventName === "MandatePayment") {
373
+ const { to, amount, l3aDigest, agentKey, spent, occurrences } = log.args;
374
+ outcomes.push({ ...position(log), kind: "payment", txHash: log.transactionHash, block: log.blockNumber, to, amount, l3aDigest, agentKey, spent, occurrences });
375
+ } else if (log.eventName === "MandateRefused") {
376
+ const { to, amount, l3aDigest, agentKey, reason } = log.args;
377
+ outcomes.push({ ...position(log), kind: "refusal", txHash: log.transactionHash, block: log.blockNumber, to, amount, l3aDigest, agentKey, reason: reasonName(reason) });
378
+ }
379
+ }
380
+ outcomes.sort((a, b) => before(a, b) ? -1 : before(b, a) ? 1 : 0);
381
+ return only === undefined ? outcomes : outcomes.filter((o) => o.txHash.toLowerCase() === only.toLowerCase());
382
+ }
383
+
384
+ async function verifyOutcome(client: PublicClient, chainId: number, m: BoundMandate, o: Outcome, one: { requirements?: Json; checkoutMandate?: string; checkoutSdHash?: Hex }): Promise<VerifiedPayment> {
385
+ const verified: VerifiedPayment = { kind: o.kind, txHash: o.txHash, block: o.block, to: o.to, amount: o.amount, ...(o.reason === undefined ? {} : { reason: o.reason }), problems: [] };
386
+ const found = verified.problems;
387
+ const agentKey = decodeAgentKey(o.agentKey);
388
+ if (!sameAgentKey(agentKey, m.agentKey)) found.push("not the bound mandate key");
389
+ try {
390
+ const [tx, block] = await Promise.all([client.getTransaction({ hash: o.txHash }), client.getBlock({ blockNumber: o.block })]);
391
+ const { pull, fields } = carriedL3a(tx.input, chainId, m, o);
392
+ if (!isAddressEqual(pull.token, m.constraints.token)) found.push("another token than the mandate's");
393
+ const receipt = await client.getTransactionReceipt({ hash: o.txHash });
394
+ if (receipt.status !== "success" || receipt.blockNumber !== o.block) found.push("no successful receipt at the recorded block");
395
+ if (o.kind === "payment") {
396
+ const transfers = parseEventLogs({ abi: erc20Abi, logs: receipt.logs, eventName: "Transfer", strict: true });
397
+ if (!transfers.some(t => isAddressEqual(t.address, m.constraints.token) && isAddressEqual(t.args.from, m.principal) && isAddressEqual(t.args.to, pull.to) && t.args.value === pull.value && t.logIndex < o.logIndex)) found.push("no matching token transfer before the payment event");
398
+ }
399
+ const l3a = buildL3a(chainId, m.terms, pull, fields);
400
+ verified.perPaymentMandate = serializeL3a(l3a, fields.r, fields.s);
401
+ if (slice(l3a.digest, 0, 20).toLowerCase() !== o.l3aDigest.toLowerCase()) found.push("its digest is not the one the event records");
402
+ verified.nonce = pull.nonce;
403
+ if (mandateNonce(m.ref, l3a.digest) !== pull.nonce.toLowerCase()) found.push("its nonce does not commit to the mandate and the per-payment mandate");
404
+ if (!verifyL3aSignature(m.agentKey, l3a.digest, fields.r, fields.s)) found.push("not the bound mandate key's signature");
405
+ if (!isAddressEqual(pull.to, o.to)) found.push("another payee than the event records");
406
+ if (pull.value !== o.amount) found.push("another amount than the event records");
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) {
412
+ // The offer served, when the verifier served it; the signed order's own otherwise.
413
+ const offer = one.requirements !== null && typeof one.requirements === "object" && !Array.isArray(one.requirements) ? x402Offer(one.requirements) : undefined;
414
+ found.push(...verifyCheckoutMandate(one.checkoutMandate, {
415
+ agent: m.agentKey,
416
+ kid: m.terms.kid,
417
+ payee: pull.to,
418
+ ...(offer === undefined ? {} : { offer, lineItemId: lineItemOf(offer) }),
419
+ window: pull,
420
+ transactionId: fields.transactionId,
421
+ amount: pull.value,
422
+ ...(one.checkoutSdHash === undefined ? {} : { checkoutSdHash: one.checkoutSdHash }),
423
+ }).problems);
424
+ } else if (one.requirements !== undefined && transactionIdOf(one.requirements).toLowerCase() !== fields.transactionId.toLowerCase()) {
425
+ found.push("not signed over the requirements given");
426
+ }
427
+ if (one.requirements !== undefined) {
428
+ const r = one.requirements;
429
+ if (r === null || typeof r !== "object" || Array.isArray(r)
430
+ || typeof r.asset !== "string" || r.asset.toLowerCase() !== pull.token.toLowerCase()
431
+ || typeof r.payTo !== "string" || r.payTo.toLowerCase() !== pull.to.toLowerCase()
432
+ || r.amount !== pull.value.toString() || r.network !== `eip155:${chainId}`) found.push("the transfer does not satisfy the requirements given");
433
+ }
434
+ const at = { ...o, timestamp: block.timestamp };
435
+ if (o.kind === "payment") {
436
+ const broken = reasonContradiction("None", pull, m, at);
437
+ if (broken !== undefined) found.push(broken);
438
+ if (o.spent !== undefined && o.spent > m.constraints.budget) found.push("over the budget");
439
+ if (o.occurrences !== undefined && o.occurrences > m.constraints.maxOccurrences) found.push("over the occurrence cap");
440
+ } else if (o.reason !== undefined) {
441
+ const contradiction = reasonContradiction(o.reason, pull, m, at);
442
+ if (contradiction !== undefined) found.push(`refused for ${named(o.reason)}: ${contradiction}`);
443
+ }
444
+ } catch (error) {
445
+ found.push(message(error));
446
+ }
447
+ return verified;
448
+ }
449
+
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[] };
472
+
473
+ async function verify(client: PublicClient, found: Found, opts: VerifyMandateOptions & { accountFactory: Address }, only: { tx: Hash; events: readonly MandateEvent[] } | undefined): Promise<MandateVerification> {
474
+ const steps: VerifyStep[] = [];
475
+ const record = (step: VerifyStep["step"], problems: string[], passed: string) =>
476
+ steps.push({ step, title: STEP_TITLES[step], outcome: problems.length > 0 ? "fail" : "pass", detail: problems.length > 0 ? problems.join("; ") : passed });
477
+ const skip = (step: VerifyStep["step"], why: string) => steps.push({ step, title: STEP_TITLES[step], outcome: "skipped", detail: why });
478
+
479
+ const m = found.mandate;
480
+ if (m === undefined) {
481
+ record(1, [...found.problems, ...found.unknown], "");
482
+ skip(2, "no binding to check against");
483
+ skip(3, "no binding to check against");
484
+ skip(4, "no binding to check against");
485
+ return { ok: false, steps, payments: [] };
486
+ }
487
+ record(
488
+ 1,
489
+ found.problems,
490
+ `${short(m.ref)} bound in block ${m.block} by ${m.principal}: hash(C) ${m.hashC}, issuer key ${m.issuerKey}, mandate key ${short(encodeAgentKey(m.agentKey))}, ${m.terms.payees.length} payee(s)` +
491
+ `${m.revokedBlock === undefined ? "" : `, revoked in block ${m.revokedBlock}`}${m.successor === undefined ? "" : `, replaced by ${short(m.successor)}`}`,
492
+ );
493
+
494
+ const c = opts.credential;
495
+ let credentialIsBound = false;
496
+ if (c === undefined) {
497
+ skip(2, "no credential in hand");
498
+ } else {
499
+ const problems: string[] = [];
500
+ if (hashCredential(c).toLowerCase() !== m.hashC.toLowerCase()) problems.push("hash(C) of the credential in hand is not the bound hash");
501
+ else {
502
+ try {
503
+ const stated = extractTerms(c);
504
+ const differs = [
505
+ ["principal", isAddressEqual(stated.principal, m.principal)],
506
+ ["mandate key", sameAgentKey(stated.agentKey, m.agentKey)],
507
+ ["limits", jcs(toJson(stated.constraints)) === jcs(toJson(m.constraints))],
508
+ ["payees", jcs(toJson(stated.terms)) === jcs(toJson(m.terms))],
509
+ ].filter(([, same]) => !same);
510
+ if (differs.length > 0) problems.push(`the bound ${differs.map(([name]) => name).join(", ")} differ from the credential's`);
511
+ } catch (error) {
512
+ problems.push(message(error));
513
+ }
514
+ }
515
+ credentialIsBound = problems.length === 0;
516
+ record(2, problems, "the credential hashes to the bound hash(C), and binds exactly the limits, payees and mandate key it states");
517
+ }
518
+
519
+ if (c === undefined || !credentialIsBound) skip(3, c === undefined ? "no credential in hand" : "the credential in hand is not the bound one");
520
+ else if (opts.trustedIssuers === undefined || opts.trustedIssuers.length === 0) skip(3, "no trusted issuer given");
521
+ else {
522
+ const problems: string[] = [];
523
+ try {
524
+ const boundAt = await client.getBlock({ blockNumber: m.block });
525
+ const { issuer } = await verifyCredential(c, { trustedIssuers: opts.trustedIssuers, account: m.principal, now: Number(boundAt.timestamp) });
526
+ if (issuerKeyId(issuer.jwk).toLowerCase() !== m.issuerKey.toLowerCase()) problems.push("the trusted issuer's key is not the issuer key recorded at binding");
527
+ } catch (error) {
528
+ problems.push(message(error));
529
+ }
530
+ record(3, problems, "the issuer's signature verifies under the trusted key the chain recorded, the owner's under the key the issuer bound, and the credential was valid when bound");
531
+ }
532
+
533
+ const chainId = await client.getChainId();
534
+ const outcomes = outcomesOf(only === undefined ? found.events : only.events, only?.tx);
535
+ const payments: VerifiedPayment[] = [];
536
+ const checkoutSdHash = c !== undefined && credentialIsBound ? extractTerms(c).checkoutSdHash : undefined;
537
+ const one = outcomes.length === 1 ? { ...opts, ...(checkoutSdHash === undefined ? {} : { checkoutSdHash }) } : {};
538
+ for (const o of outcomes) payments.push(await verifyOutcome(client, chainId, m, o, one));
539
+ const problems = [...found.unknown, ...payments.filter((p) => p.problems.length > 0).map((p) => `${p.kind} in ${short(p.txHash)}: ${p.problems.join(", ")}`)];
540
+ if (only !== undefined && outcomes.length === 0) problems.push(`transaction ${only.tx} carries no payment or refusal under ${short(m.ref)}`);
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");
542
+ const paid = payments.filter((p) => p.kind === "payment").length;
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}`);
546
+
547
+ return { ok: steps.every((s) => s.outcome !== "fail"), steps, mandate: m, payments };
548
+ }
549
+
550
+ /** The factory the verifier trusts on `client`'s chain: the caller's, else the one the network table names. */
551
+ async function trustedFactory(client: PublicClient, override: Address | undefined): Promise<Address> {
552
+ if (override !== undefined) return override;
553
+ const chainId = await client.getChainId();
554
+ const network = metalNetwork(`eip155:${chainId}`);
555
+ if (network === undefined) throw new Error(`chain ${chainId} is not a Metal network; pass accountFactory`);
556
+ return accountFactoryFor(network, undefined, "pass accountFactory");
557
+ }
558
+
559
+ /**
560
+ * The four steps over a credential and the chain: the mandate the credential
561
+ * describes, bound on the account it names, and every payment and refusal
562
+ * under it.
563
+ */
564
+
565
+ export async function verifyMandate(client: PublicClient, opts: VerifyMandateOptions & { credential: Credential }): Promise<MandateVerification> {
566
+ const terms = extractTerms(opts.credential);
567
+ const ref = mandateReference(terms.principal, terms.agentKey, hashCredential(opts.credential));
568
+ const accountFactory = await trustedFactory(client, opts.accountFactory);
569
+ return verify(client, await boundMandate(client, terms.principal, ref, accountFactory), { ...opts, accountFactory }, undefined);
570
+ }
571
+
572
+ /**
573
+ * The four steps over one transaction: the mandate its payment or refusal
574
+ * names, found from the event the principal's account emitted, and that one
575
+ * outcome. A seller holds no credential, so steps 2 and 3 are skipped unless
576
+ * one is given; with the x402 `requirements` it served, the payment is
577
+ * checked to have been signed over them.
578
+ */
579
+ export async function verifyMandatePayment(client: PublicClient, opts: VerifyMandateOptions & { tx: Hash }): Promise<MandateVerification> {
580
+ const receipt = await client.getTransactionReceipt({ hash: opts.tx });
581
+ const accountFactory = await trustedFactory(client, opts.accountFactory);
582
+ const logs = parseEventLogs({ abi: mandateBindingAbi, logs: receipt.logs, eventName: ["MandatePayment", "MandateRefused"], strict: true });
583
+ // Only an account the trusted factory made emits a mandate's outcome; any
584
+ // other contract in the transaction may copy the event's shape. A read
585
+ // that fails is not an answer: it propagates, so a verification never
586
+ // passes on part of a transaction's outcomes.
587
+ const clone = metalAccountCode(await client.readContract({ address: accountFactory, abi: metalAccountFactoryAbi, functionName: "implementation" }));
588
+ const emitters = new Map<string, boolean>();
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);
594
+ const carried = logs.filter((log) => emitters.get(log.address.toLowerCase()) === true);
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
+ }
606
+ // One transaction may settle payments under several mandates (a sponsor's
607
+ // batch). `ref` or the credential in hand names the one to verify; a
608
+ // credential that names none of them is still checked against a single
609
+ // mandate, and fails there; without either, several are ambiguous.
610
+ const named = (log: (typeof carried)[number]) => `${log.address.toLowerCase()}:${log.args.ref.toLowerCase()}`;
611
+ const mandates = [...new Set(carried.map(named))];
612
+ const listed = mandates.map((k) => short(k.split(":")[1] as Hex)).join(", ");
613
+ const wanted = opts.ref !== undefined ? opts.ref : opts.credential !== undefined ? mandateReference(extractTerms(opts.credential).principal, extractTerms(opts.credential).agentKey, hashCredential(opts.credential)) : undefined;
614
+ let chosen = wanted === undefined ? undefined : mandates.find((k) => k.endsWith(`:${wanted.toLowerCase()}`));
615
+ if (chosen === undefined) {
616
+ if (opts.ref !== undefined) throw new Error(`transaction ${opts.tx} carries no payment or refusal under ${short(opts.ref)}; it carries outcomes under ${listed}`);
617
+ if (mandates.length > 1) throw new Error(`transaction ${opts.tx} carries outcomes under ${mandates.length} mandates (${listed})${opts.credential === undefined ? "; pass the credential, or the ref, of the one to verify" : ", none the credential's"}`);
618
+ chosen = mandates[0]!;
619
+ }
620
+ const [principal, ref] = chosen.split(":") as [Address, Hex];
621
+ // The mandate's events up to this payment's block decide whether it was revoked by then; the outcome itself is in the receipt.
622
+ const found = await boundMandate(client, principal, ref, accountFactory, receipt.blockNumber);
623
+ const events = carried.filter((log) => named(log) === chosen) as unknown as MandateEvent[];
624
+ return verify(client, found, { ...opts, accountFactory }, { tx: opts.tx, events });
625
+ }