@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.
@@ -8,12 +8,12 @@
8
8
  // changes, so what it is now it was when it bound and paid. What a verifier
9
9
  // cannot rebuild (the budget's running total, the nonce's single use) is read
10
10
  // as the contract recorded it in the event and checked against the limits.
11
- import { decodeFunctionData, formatLog, getAbiItem, hexToBigInt, isAddressEqual, numberToHex, erc20Abi, parseEventLogs, slice, toFunctionSelector, } from "viem";
11
+ import { decodeFunctionData, formatLog, getAbiItem, getAddress, hexToBigInt, isAddressEqual, numberToHex, erc20Abi, parseAbi, parseEventLogs, slice, toEventSelector, toFunctionSelector, } from "viem";
12
12
  import { REASONS, REFUSALS, decodeAgentKey, encodeAgentKey, mandateBindingAbi, mandateReference, reasonName, sameAgentKey, } from "./binding.js";
13
13
  import { accountFactoryFor, metalNetwork } from "../networks.js";
14
14
  import { assertMetalAccount, metalAccountCode, metalAccountFactoryAbi } from "./account.js";
15
15
  import { publicMessage } from "./chain.js";
16
- import { verifyCheckoutMandate, x402Offer, lineItemOf } from "./checkout.js";
16
+ import { checkoutHash, readL3b, verifyCheckoutMandate, x402Offer, lineItemOf } from "./checkout.js";
17
17
  import { jcs } from "./jcs.js";
18
18
  import { buildL3a, mandateNonce, serializeL3a, transactionIdOf, verifyL3aSignature } from "./l3a.js";
19
19
  import { extractTerms, hashCredential, issuerKeyId, verifyCredential } from "./vi/credential.js";
@@ -134,6 +134,50 @@ function carriedL3a(input, chainId, m, o) {
134
134
  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");
135
135
  return carried;
136
136
  }
137
+ const MANDATE_EVENTS = new Set(mandateBindingAbi.filter((item) => item.type === "event").map((item) => toEventSelector(item)));
138
+ /** The events a MetalAccount emits besides Mandate Binding's: its quorum, scheduling, sessions and OpenZeppelin bases. */
139
+ const ACCOUNT_EVENTS = new Set([
140
+ ...MANDATE_EVENTS,
141
+ ...parseAbi([
142
+ "event EIP712DomainChanged()",
143
+ "event ERC7579TryExecuteFail(uint256 batchExecutionIndex, bytes returndata)",
144
+ "event ERC7913SignerAdded(bytes indexed signers)",
145
+ "event ERC7913SignerRemoved(bytes indexed signers)",
146
+ "event ERC7913SignerWeightChanged(bytes indexed signer, uint64 weight)",
147
+ "event ERC7913ThresholdSet(uint64 threshold)",
148
+ "event Executed(uint256 indexed nonce)",
149
+ "event Initialized(uint64 version)",
150
+ "event ScheduleCanceled(bytes32 indexed operationId)",
151
+ "event ScheduleConfigured(uint64 threshold, uint32 delay)",
152
+ "event Scheduled(bytes32 indexed operationId, uint256 indexed nonce, bytes32 mode, bytes executionData, uint48 readyAt)",
153
+ "event ScheduledExecuted(bytes32 indexed operationId)",
154
+ "event SessionGranted(address indexed agent, address indexed token, uint64 expiresAt, uint256 perTransferCap, uint256 periodBudget, uint32 period)",
155
+ "event SessionRevoked(address indexed agent, address indexed token)",
156
+ "event SessionTransfer(address indexed agent, address indexed token, address indexed to, uint256 amount)",
157
+ ]).map((item) => toEventSelector(item)),
158
+ ]);
159
+ /** What a verifier that cannot decode an account's events says: a deployed account of another version than the SDK's can emit them. */
160
+ const OTHER_VERSIONS = "the SDK and the deployed account may be different versions";
161
+ /** A log's place in the chain: its transaction and its index there. */
162
+ const placeOf = (log) => `${log.transactionHash}:${log.logIndex}`;
163
+ /**
164
+ * The account's events under `ref` that Mandate Binding's ABI does not
165
+ * decode, by topic. Each puts the reference in its first topic, so an
166
+ * account of another version emits its outcomes there under another topic,
167
+ * or under the same topic in another layout, and a scan that dropped them
168
+ * would see a mandate with no payments. Each log is matched to its decoding
169
+ * by its place.
170
+ */
171
+ function undecoded(principal, ref, logs, decoded) {
172
+ const read = new Set(decoded.map(placeOf));
173
+ const counts = new Map();
174
+ for (const log of logs.map((raw) => formatLog(raw))) {
175
+ const topic = log.topics[0];
176
+ if (topic !== undefined && !read.has(placeOf(log)))
177
+ counts.set(topic, (counts.get(topic) ?? 0) + 1);
178
+ }
179
+ 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}`);
180
+ }
137
181
  /**
138
182
  * The mandate's events on the principal between two blocks, oldest first: one
139
183
  * `eth_getLogs` for every event kind, since each puts the reference in its
@@ -143,11 +187,13 @@ function carriedL3a(input, chainId, m, o) {
143
187
  */
144
188
  async function mandateEvents(client, principal, ref, fromBlock, toBlock) {
145
189
  const out = [];
190
+ const raw = [];
146
191
  let span = toBlock - fromBlock + 1n;
147
192
  for (let start = fromBlock; start <= toBlock;) {
148
193
  const end = start + span - 1n > toBlock ? toBlock : start + span - 1n;
149
194
  try {
150
- const logs = await client.request({ method: "eth_getLogs", params: [{ address: principal, fromBlock: numberToHex(start), toBlock: numberToHex(end), topics: [null, ref] }] });
195
+ const logs = (await client.request({ method: "eth_getLogs", params: [{ address: principal, fromBlock: numberToHex(start), toBlock: numberToHex(end), topics: [null, ref] }] }));
196
+ raw.push(...logs);
151
197
  out.push(...parseEventLogs({ abi: mandateBindingAbi, logs: logs.map((log) => formatLog(log)), strict: true }));
152
198
  start = end + 1n;
153
199
  }
@@ -157,7 +203,7 @@ async function mandateEvents(client, principal, ref, fromBlock, toBlock) {
157
203
  span /= 2n;
158
204
  }
159
205
  }
160
- return out;
206
+ return { events: out, unknown: undecoded(principal, ref, raw, out) };
161
207
  }
162
208
  /**
163
209
  * The mandate `ref` as the principal's account bound it: its state names the
@@ -167,21 +213,21 @@ async function mandateEvents(client, principal, ref, fromBlock, toBlock) {
167
213
  async function boundMandate(client, principal, ref, factory, toBlock) {
168
214
  const state = await client.readContract({ address: principal, abi: mandateBindingAbi, functionName: "mandates", args: [ref] });
169
215
  if (!isAddressEqual(state.principal, principal))
170
- return { events: [], problems: [`no mandate ${short(ref)} is bound on ${principal}`] };
216
+ return { events: [], unknown: [], problems: [`no mandate ${short(ref)} is bound on ${principal}`] };
171
217
  const from = state.boundBlock;
172
218
  const to = toBlock ?? (await client.getBlockNumber());
173
- const events = await mandateEvents(client, principal, ref, from, to);
219
+ const { events, unknown } = await mandateEvents(client, principal, ref, from, to);
174
220
  const bound = events.filter((e) => e.eventName === "MandateBound");
175
221
  if (bound.length !== 1)
176
- 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)}`] };
222
+ 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)}`] };
177
223
  const [log] = bound;
178
224
  if (log === undefined || log.eventName !== "MandateBound" || log.blockNumber === null || log.transactionHash === null)
179
- return { events, problems: ["the MandateBound event is pending"] };
225
+ return { events, unknown, problems: ["the MandateBound event is pending"] };
180
226
  try {
181
227
  await assertMetalAccount(client, principal, factory);
182
228
  }
183
229
  catch (error) {
184
- return { events, problems: [message(error)] };
230
+ return { events, unknown, problems: [message(error)] };
185
231
  }
186
232
  const agentKey = decodeAgentKey(log.args.agentKey);
187
233
  const problems = [];
@@ -206,6 +252,7 @@ async function boundMandate(client, principal, ref, factory, toBlock) {
206
252
  ...(successor === undefined ? {} : { successor }),
207
253
  },
208
254
  events,
255
+ unknown,
209
256
  problems,
210
257
  };
211
258
  }
@@ -259,7 +306,12 @@ async function verifyOutcome(client, chainId, m, o, one) {
259
306
  found.push("another payee than the event records");
260
307
  if (pull.value !== o.amount)
261
308
  found.push("another amount than the event records");
262
- if (one.checkoutMandate !== undefined) {
309
+ const other = one.checkoutMandate === undefined ? undefined : othersOrder(one.checkoutMandate, fields.transactionId, pull);
310
+ if (other !== undefined) {
311
+ // Its other checks would only list how that other payment differs.
312
+ found.push(`the checkout mandate belongs to another payment: ${other}`);
313
+ }
314
+ else if (one.checkoutMandate !== undefined) {
263
315
  // The offer served, when the verifier served it; the signed order's own otherwise.
264
316
  const offer = one.requirements !== null && typeof one.requirements === "object" && !Array.isArray(one.requirements) ? x402Offer(one.requirements) : undefined;
265
317
  found.push(...verifyCheckoutMandate(one.checkoutMandate, {
@@ -305,13 +357,34 @@ async function verifyOutcome(client, chainId, m, o, one) {
305
357
  }
306
358
  return verified;
307
359
  }
360
+ /**
361
+ * How a checkout mandate that parses is another payment's: a checkout hash
362
+ * other than the per-payment mandate's `transaction_id`, or another window,
363
+ * as both are signed together for one payment. Undefined when it is this
364
+ * payment's, or does not parse, which its own check names.
365
+ */
366
+ function othersOrder(checkoutMandate, transactionId, pull) {
367
+ try {
368
+ const l3b = readL3b(checkoutMandate);
369
+ if (!Number.isSafeInteger(l3b.iat) || !Number.isSafeInteger(l3b.exp))
370
+ return undefined;
371
+ const differs = [
372
+ ...(checkoutHash(l3b.checkoutJwt) === transactionId.toLowerCase() ? [] : ["its checkout hash is not the transaction_id this payment's per-payment mandate signed"]),
373
+ ...(BigInt(l3b.iat) === pull.validAfter && BigInt(l3b.exp) === pull.validBefore ? [] : ["its window is not this payment's"]),
374
+ ];
375
+ return differs.length === 0 ? undefined : differs.join(", and ");
376
+ }
377
+ catch {
378
+ return undefined;
379
+ }
380
+ }
308
381
  async function verify(client, found, opts, only) {
309
382
  const steps = [];
310
383
  const record = (step, problems, passed) => steps.push({ step, title: STEP_TITLES[step], outcome: problems.length > 0 ? "fail" : "pass", detail: problems.length > 0 ? problems.join("; ") : passed });
311
384
  const skip = (step, why) => steps.push({ step, title: STEP_TITLES[step], outcome: "skipped", detail: why });
312
385
  const m = found.mandate;
313
386
  if (m === undefined) {
314
- record(1, found.problems, "");
387
+ record(1, [...found.problems, ...found.unknown], "");
315
388
  skip(2, "no binding to check against");
316
389
  skip(3, "no binding to check against");
317
390
  skip(4, "no binding to check against");
@@ -371,13 +444,15 @@ async function verify(client, found, opts, only) {
371
444
  const one = outcomes.length === 1 ? { ...opts, ...(checkoutSdHash === undefined ? {} : { checkoutSdHash }) } : {};
372
445
  for (const o of outcomes)
373
446
  payments.push(await verifyOutcome(client, chainId, m, o, one));
374
- const problems = payments.filter((p) => p.problems.length > 0).map((p) => `${p.kind} in ${short(p.txHash)}: ${p.problems.join(", ")}`);
447
+ const problems = [...found.unknown, ...payments.filter((p) => p.problems.length > 0).map((p) => `${p.kind} in ${short(p.txHash)}: ${p.problems.join(", ")}`)];
375
448
  if (only !== undefined && outcomes.length === 0)
376
449
  problems.push(`transaction ${only.tx} carries no payment or refusal under ${short(m.ref)}`);
377
450
  if ((opts.requirements !== undefined || opts.checkoutMandate !== undefined) && outcomes.length > 1)
378
451
  problems.push("requirements or a signed offer given, but more than one payment to check them against");
379
452
  const paid = payments.filter((p) => p.kind === "payment").length;
380
- 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`);
453
+ // One payment's check says whether it covered the signed order.
454
+ 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";
455
+ 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}`);
381
456
  return { ok: steps.every((s) => s.outcome !== "fail"), steps, mandate: m, payments };
382
457
  }
383
458
  /** The factory the verifier trusts on `client`'s chain: the caller's, else the one the network table names. */
@@ -418,12 +493,26 @@ export async function verifyMandatePayment(client, opts) {
418
493
  // passes on part of a transaction's outcomes.
419
494
  const clone = metalAccountCode(await client.readContract({ address: accountFactory, abi: metalAccountFactoryAbi, functionName: "implementation" }));
420
495
  const emitters = new Map();
421
- for (const address of new Set(logs.map((log) => log.address.toLowerCase()))) {
422
- emitters.set(address, (await client.getCode({ address }))?.toLowerCase() === clone);
423
- }
496
+ const trusted = async (address) => {
497
+ if (!emitters.has(address))
498
+ emitters.set(address, (await client.getCode({ address: address }))?.toLowerCase() === clone);
499
+ return emitters.get(address) === true;
500
+ };
501
+ for (const address of new Set(logs.map((log) => log.address.toLowerCase())))
502
+ await trusted(address);
424
503
  const carried = logs.filter((log) => emitters.get(log.address.toLowerCase()) === true);
425
- if (carried.length === 0)
426
- 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}`);
504
+ if (carried.length === 0) {
505
+ // An account of another version than the SDK's emits its outcomes under
506
+ // topics the ABI does not decode, or under its topics in another layout.
507
+ const read = new Set(parseEventLogs({ abi: mandateBindingAbi, logs: receipt.logs, strict: true }).map(placeOf));
508
+ for (const log of receipt.logs) {
509
+ const topic = log.topics[0]?.toLowerCase();
510
+ if (topic === undefined || (MANDATE_EVENTS.has(topic) ? read.has(placeOf(log)) : ACCOUNT_EVENTS.has(topic)) || !(await trusted(log.address.toLowerCase())))
511
+ continue;
512
+ 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}`);
513
+ }
514
+ 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}`);
515
+ }
427
516
  // One transaction may settle payments under several mandates (a sponsor's
428
517
  // batch). `ref` or the credential in hand names the one to verify; a
429
518
  // credential that names none of them is still checked against a single
@@ -1,5 +1,5 @@
1
1
  import type { Network } from "@x402/core/types";
2
- import type { Address } from "viem";
2
+ import { type Address, type HttpTransport } from "viem";
3
3
  export type MetalNetworkName = "devnet" | "testnet";
4
4
  export interface MetalNetwork {
5
5
  name: MetalNetworkName;
@@ -55,6 +55,14 @@ export declare function resolveNetwork(entry: MetalNetworkName | MetalNetwork):
55
55
  * METAL_RPC_URL"), how the caller passes it.
56
56
  */
57
57
  export declare function rpcUrlFor(network: MetalNetwork, override: string | undefined, how: string): string;
58
+ /**
59
+ * viem's `http` transport to `rpcUrl`, whose errors do not carry the URL: it
60
+ * can hold the endpoint's credential, so each error it throws, and each of
61
+ * that error's causes, names it as `[RPC URL hidden]`. An endpoint that
62
+ * gives no answer, or a server error, is named as one that could not be
63
+ * reached.
64
+ */
65
+ export declare function rpcTransport(rpcUrl: string): HttpTransport;
58
66
  /** A dollar token payers authorize ERC-3009 transfers of. */
59
67
  export interface DollarToken {
60
68
  address: `0x${string}`;
package/dist/networks.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { HttpRequestError, TimeoutError, http } from "viem";
1
2
  /**
2
3
  * The MetalAccountFactory to trust on `network`: `override` when given, else
3
4
  * the network's own. On a network with neither it throws, saying how the
@@ -32,7 +33,7 @@ export const METAL_NETWORKS = {
32
33
  facilitatorUrl: "https://x402-testnet.metalntwx.com/x402/facilitator",
33
34
  // What `just estate-deploy-account-factory testnet` recorded in
34
35
  // deploy/estate/environments/testnet/account-factory.json for this chain.
35
- accountFactory: "0x4D188AC7815a9EAEa219a49D687B71898A487578",
36
+ accountFactory: "0x3dbED5DebA965A639bB0Fd40BE96b97922070FA8",
36
37
  },
37
38
  };
38
39
  /** Resolve a name or a CAIP-2 identifier to a Metal network; undefined for any other chain. */
@@ -61,6 +62,105 @@ export function rpcUrlFor(network, override, how) {
61
62
  return network.rpc.url;
62
63
  throw new Error(`${network.name} needs an RPC endpoint of your own: sign up at ${network.rpc.consoleUrl}, create an endpoint, then ${how}`);
63
64
  }
65
+ /** What an RPC URL reads as in an error. */
66
+ const HIDDEN_RPC_URL = "[RPC URL hidden]";
67
+ /**
68
+ * viem's `http` transport to `rpcUrl`, whose errors do not carry the URL: it
69
+ * can hold the endpoint's credential, so each error it throws, and each of
70
+ * that error's causes, names it as `[RPC URL hidden]`. An endpoint that
71
+ * gives no answer, or a server error, is named as one that could not be
72
+ * reached.
73
+ */
74
+ export function rpcTransport(rpcUrl) {
75
+ const transport = http(rpcUrl);
76
+ const parsed = URL.canParse(rpcUrl) ? new URL(rpcUrl) : undefined;
77
+ const host = parsed?.host;
78
+ // What an endpoint can echo of the URL without its host: the path and
79
+ // query, which can hold a key, and the user and password.
80
+ const decoded = (text) => {
81
+ try {
82
+ return decodeURIComponent(text);
83
+ }
84
+ catch {
85
+ return text;
86
+ }
87
+ };
88
+ const path = parsed ? parsed.pathname + parsed.search : "";
89
+ const secrets = [rpcUrl];
90
+ if (path.length > 1)
91
+ secrets.push(path);
92
+ // A key is often echoed alone: each path segment and query value long enough to be one.
93
+ if (parsed) {
94
+ const parts = [...decoded(parsed.pathname).split("/"), ...[...parsed.searchParams.values()]];
95
+ for (const part of parts)
96
+ if (part.length >= 8)
97
+ secrets.push(part);
98
+ }
99
+ if (parsed?.username || parsed?.password) {
100
+ const [user, password] = [decoded(parsed.username), decoded(parsed.password)];
101
+ secrets.push(`${user}:${password}`);
102
+ if (password.length >= 4)
103
+ secrets.push(password);
104
+ }
105
+ // viem prints the URL as given, or without its user and password.
106
+ const hide = (text) => secrets
107
+ .reduce((hidden, secret) => hidden.replaceAll(secret, HIDDEN_RPC_URL), text)
108
+ .replace(/https?:\/\/[^\s"'<>]+/g, (url) => (URL.canParse(url) && new URL(url).host === host ? HIDDEN_RPC_URL : url));
109
+ // Every string the error holds, in its fields, its causes, its arrays and
110
+ // its response headers, as viem or the endpoint put it there: the fields
111
+ // that name the URL are not a list to keep.
112
+ const scrub = (value, seen) => {
113
+ if (value === null || typeof value !== "object" || seen.has(value))
114
+ return;
115
+ seen.add(value);
116
+ for (const key of Object.getOwnPropertyNames(value)) {
117
+ const fields = value;
118
+ let field;
119
+ try {
120
+ field = fields[key];
121
+ }
122
+ catch {
123
+ continue;
124
+ }
125
+ let hidden = field;
126
+ if (typeof field === "string")
127
+ hidden = hide(field);
128
+ else if (field instanceof Headers) {
129
+ const entries = [...field].map(([name, text]) => [name, hide(text)]);
130
+ if (entries.some(([name, text]) => field.get(name) !== text))
131
+ hidden = new Headers(entries);
132
+ }
133
+ else
134
+ scrub(field, seen);
135
+ if (hidden !== field)
136
+ try {
137
+ fields[key] = hidden;
138
+ }
139
+ catch {
140
+ // A property that cannot be written holds what it held.
141
+ }
142
+ }
143
+ };
144
+ return ((options) => {
145
+ const made = transport(options);
146
+ return {
147
+ ...made,
148
+ request: (async (args, requestOptions) => {
149
+ try {
150
+ return await made.request(args, requestOptions);
151
+ }
152
+ catch (error) {
153
+ if (error instanceof TimeoutError || (error instanceof HttpRequestError && (error.status === undefined || error.status >= 500))) {
154
+ error.shortMessage = `the RPC endpoint could not be reached: ${error.shortMessage}`;
155
+ error.message = `the RPC endpoint could not be reached: ${error.message}`;
156
+ }
157
+ scrub(error, new Set());
158
+ throw error;
159
+ }
160
+ }),
161
+ };
162
+ });
163
+ }
64
164
  /** The default gas token and the asset every offer here is priced in. */
65
165
  export const USDZ = {
66
166
  address: "0x000000000000000000000000000000000000B5d5",
@@ -95,8 +195,9 @@ export function usdzBaseUnits(amount) {
95
195
  /** Format base units back to a decimal `*USDZ` string for humans, trailing zeros trimmed. */
96
196
  export function formatUsdz(amount) {
97
197
  const units = BigInt(amount);
198
+ const magnitude = units < 0n ? -units : units;
98
199
  const scale = 10n ** BigInt(USDZ.decimals);
99
- const whole = units / scale;
100
- const fraction = (units % scale).toString().padStart(USDZ.decimals, "0").replace(/0+$/, "");
101
- return fraction ? `${whole}.${fraction}` : whole.toString();
200
+ const whole = magnitude / scale;
201
+ const fraction = (magnitude % scale).toString().padStart(USDZ.decimals, "0").replace(/0+$/, "");
202
+ return `${units < 0n ? "-" : ""}${fraction ? `${whole}.${fraction}` : whole}`;
102
203
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metalntwx/metal-pay",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Agent payments on Metal for the official x402 packages: native Bajillion clearing, on-chain exact payments, durable payer wallets, seller integration, and mandates, which let an agent pay from its owner's account within limits Metal enforces.",
5
5
  "license": "MIT",
6
6
  "repository": {