@url2md-io/mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +392 -0
- package/dist/client.js +611 -0
- package/dist/index.js +288 -0
- package/package.json +58 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { decodePaymentRequiredHeader, decodePaymentResponseHeader, encodePaymentSignatureHeader } from "@x402/core/http";
|
|
2
|
+
import { ExactEvmScheme, toClientEvmSigner } from "@x402/evm";
|
|
3
|
+
import { x402Client } from "@x402/core/client";
|
|
4
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
/**
|
|
7
|
+
* The paying half of the MCP server: everything that talks to url2md, with no MCP in it.
|
|
8
|
+
*
|
|
9
|
+
* The wallet is the caller's. This process signs USDC transfer authorizations with a key the user
|
|
10
|
+
* put in the environment; it holds no account with url2md and url2md holds no balance for anyone.
|
|
11
|
+
* That is also why the server is local stdio rather than a hosted remote MCP: a remote server would
|
|
12
|
+
* have to pay from *its* wallet, which means it would have to bill the caller some other way, and
|
|
13
|
+
* the whole point of x402 is that there is no other way to bill.
|
|
14
|
+
*
|
|
15
|
+
* Two payment rounds, never more. The first 402 is url2md's own price. When the requested page is
|
|
16
|
+
* itself behind an x402 toll, url2md answers the paid request with a *second* 402 — a quote for
|
|
17
|
+
* base + toll + margin, carrying a signed quote token it expects back — and that retry is the last
|
|
18
|
+
* one. A third 402 is a refusal, not something to keep paying into.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_BASE_URL = "https://url2md.io";
|
|
21
|
+
/**
|
|
22
|
+
* Per-call ceiling in USD, before anything is signed. url2md's own price is $0.005 and the most a
|
|
23
|
+
* tolled call can cost is base + the $0.05 toll cap + 25 % ≈ $0.068, so $0.10 clears every honest
|
|
24
|
+
* call and still refuses a service that answers with a surprising number. Raise it with
|
|
25
|
+
* URL2MD_MAX_PRICE_USD; there is no way to switch it off.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_MAX_PRICE_USD = "0.10";
|
|
28
|
+
/**
|
|
29
|
+
* The longest a signed authorization may stay redeemable, in seconds. The server names it
|
|
30
|
+
* (`maxTimeoutSeconds`) and it becomes the EIP-3009 `validBefore`; url2md asks for 120. An hour is
|
|
31
|
+
* generous for a request that times out in 90 seconds, and it bounds what a failed call leaves behind.
|
|
32
|
+
*/
|
|
33
|
+
export const MAX_AUTHORIZATION_SECONDS = 3600;
|
|
34
|
+
/**
|
|
35
|
+
* How much one process will commit in total, in USD, before it refuses to sign anything more. The
|
|
36
|
+
* per-call ceiling does not bound a loop: an agent retrying at the service's 60 requests a minute
|
|
37
|
+
* commits about $18 an hour with no limit at all. Raise it with URL2MD_MAX_SPEND_USD.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_MAX_SPEND_USD = "1.00";
|
|
40
|
+
export const RENDER_MODES = ["auto", "static", "browser"];
|
|
41
|
+
/** Every failure this module raises, in the shape the tool hands back to the model. */
|
|
42
|
+
export class Url2mdToolError extends Error {
|
|
43
|
+
code;
|
|
44
|
+
httpStatus;
|
|
45
|
+
retryable;
|
|
46
|
+
details;
|
|
47
|
+
constructor(code, message, opts = {}) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "Url2mdToolError";
|
|
50
|
+
this.code = code;
|
|
51
|
+
this.httpStatus = opts.httpStatus ?? null;
|
|
52
|
+
this.retryable = opts.retryable ?? false;
|
|
53
|
+
this.details = opts.details ?? {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The configuration, read once at startup so a missing key is a startup failure rather than a tool
|
|
58
|
+
* call that looks like a network problem. The key is validated by deriving its address, which is
|
|
59
|
+
* also the only thing about it that is ever shown.
|
|
60
|
+
*/
|
|
61
|
+
export function readConfig(env, readFile = defaultReadFile) {
|
|
62
|
+
const keyPath = (env["URL2MD_PRIVATE_KEY_FILE"] ?? "").trim();
|
|
63
|
+
let key = (env["URL2MD_PRIVATE_KEY"] ?? "").trim();
|
|
64
|
+
if (keyPath) {
|
|
65
|
+
// Preferred over the variable: an MCP client stores its `env` block in a config file in the
|
|
66
|
+
// clear, and every `--env KEY=0x…` snippet puts the key in shell history and in `ps aux`.
|
|
67
|
+
// A path is not a secret; the file it names can be chmod 600 and can stay out of both.
|
|
68
|
+
try {
|
|
69
|
+
key = readFile(keyPath).trim();
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
throw new Url2mdToolError("CONFIG_BAD_KEY_FILE", `URL2MD_PRIVATE_KEY_FILE points at ${keyPath}, which could not be read: ${e instanceof Error ? e.message : String(e)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!key && keyPath) {
|
|
76
|
+
throw new Url2mdToolError("CONFIG_BAD_KEY_FILE", `URL2MD_PRIVATE_KEY_FILE points at ${keyPath}, which is empty. Put the 0x-prefixed private key of a wallet holding USDC on Base in that file.`);
|
|
77
|
+
}
|
|
78
|
+
if (!key) {
|
|
79
|
+
throw new Url2mdToolError("CONFIG_MISSING_KEY", "Neither URL2MD_PRIVATE_KEY_FILE nor URL2MD_PRIVATE_KEY is set. url2md is paid per call over x402: the server signs USDC payments with your own key and never holds a balance for you. Put a 0x-prefixed private key of a wallet holding USDC on Base in a file and name that file in URL2MD_PRIVATE_KEY_FILE (preferred), or put the key itself in URL2MD_PRIVATE_KEY.");
|
|
80
|
+
}
|
|
81
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(key)) {
|
|
82
|
+
// The value is never echoed, here or anywhere: only its shape is described.
|
|
83
|
+
throw new Url2mdToolError("CONFIG_BAD_KEY", "The value given is not a private key: it must be 0x followed by 64 hex characters.");
|
|
84
|
+
}
|
|
85
|
+
// The shape is not enough: a 64-hex string can still be zero or above the curve order, and viem's
|
|
86
|
+
// rejection message quotes the number it was given — which is the key, in decimal. That message
|
|
87
|
+
// must never reach stderr, so the check happens here and only our own fixed wording escapes.
|
|
88
|
+
try {
|
|
89
|
+
privateKeyToAccount(key);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new Url2mdToolError("CONFIG_BAD_KEY", "The value given is the right shape but is not a valid secp256k1 private key (it is zero, or at or above the curve order). Generate a new wallet.");
|
|
93
|
+
}
|
|
94
|
+
const maxPriceUsd = (env["URL2MD_MAX_PRICE_USD"] ?? "").trim() || DEFAULT_MAX_PRICE_USD;
|
|
95
|
+
if (!/^\d+(\.\d{1,6})?$/.test(maxPriceUsd)) {
|
|
96
|
+
throw new Url2mdToolError("CONFIG_BAD_LIMIT", `URL2MD_MAX_PRICE_USD must be a plain USD amount such as "0.10"; got ${JSON.stringify(maxPriceUsd)}.`);
|
|
97
|
+
}
|
|
98
|
+
const maxSpendUsd = (env["URL2MD_MAX_SPEND_USD"] ?? "").trim() || DEFAULT_MAX_SPEND_USD;
|
|
99
|
+
if (!/^\d+(\.\d{1,6})?$/.test(maxSpendUsd)) {
|
|
100
|
+
throw new Url2mdToolError("CONFIG_BAD_LIMIT", `URL2MD_MAX_SPEND_USD must be a plain USD amount such as "1.00"; got ${JSON.stringify(maxSpendUsd)}.`);
|
|
101
|
+
}
|
|
102
|
+
if (usdToAtomic(maxSpendUsd) < usdToAtomic(maxPriceUsd)) {
|
|
103
|
+
throw new Url2mdToolError("CONFIG_BAD_LIMIT", `URL2MD_MAX_SPEND_USD ($${maxSpendUsd}) is below URL2MD_MAX_PRICE_USD ($${maxPriceUsd}), so no call could ever be made.`);
|
|
104
|
+
}
|
|
105
|
+
const baseUrl = ((env["URL2MD_BASE_URL"] ?? "").trim() || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
106
|
+
let parsedBase;
|
|
107
|
+
try {
|
|
108
|
+
parsedBase = new URL(baseUrl);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new Url2mdToolError("CONFIG_BAD_BASE_URL", `URL2MD_BASE_URL must be an http(s) URL; got ${JSON.stringify(baseUrl)}.`);
|
|
112
|
+
}
|
|
113
|
+
if (parsedBase.protocol !== "https:" && parsedBase.protocol !== "http:") {
|
|
114
|
+
throw new Url2mdToolError("CONFIG_BAD_BASE_URL", `URL2MD_BASE_URL must be an http(s) URL; got ${JSON.stringify(baseUrl)}.`);
|
|
115
|
+
}
|
|
116
|
+
// This URL is the whole trust anchor: every 402 is checked against the pricing *it* publishes, so
|
|
117
|
+
// a service reached over plain http can be rewritten in flight by anyone on the path — including
|
|
118
|
+
// the pricing the checks are made against. Loopback is exempt because there is no path to sit on.
|
|
119
|
+
if (parsedBase.protocol === "http:" && !LOOPBACK_HOSTS.has(parsedBase.hostname)) {
|
|
120
|
+
throw new Url2mdToolError("CONFIG_BAD_BASE_URL", `URL2MD_BASE_URL must be https:// (got ${JSON.stringify(baseUrl)}). This URL is what every 402 is checked against, so anyone who can rewrite the traffic can rewrite the terms this client verifies. Only a loopback address may be plain http.`);
|
|
121
|
+
}
|
|
122
|
+
const timeoutRaw = (env["URL2MD_TIMEOUT_MS"] ?? "").trim();
|
|
123
|
+
const timeoutMs = timeoutRaw ? Number(timeoutRaw) : 90_000;
|
|
124
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
125
|
+
throw new Url2mdToolError("CONFIG_BAD_TIMEOUT", `URL2MD_TIMEOUT_MS must be a positive number of milliseconds; got ${JSON.stringify(timeoutRaw)}.`);
|
|
126
|
+
}
|
|
127
|
+
return { baseUrl, privateKey: key, maxPriceUsd, maxSpendUsd, timeoutMs };
|
|
128
|
+
}
|
|
129
|
+
/** Hosts where plain http is not a downgrade: nothing can sit between a process and itself. */
|
|
130
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
131
|
+
/** Reads the key file. Its own function so `readConfig` stays testable without touching a disk. */
|
|
132
|
+
function defaultReadFile(path) {
|
|
133
|
+
// A dynamic require would be erased by Node's type stripping; a static import of node:fs is fine.
|
|
134
|
+
return readFileSync(path, "utf8");
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* A ceiling far above anything this service charges. Not enforced — it is the user's wallet and
|
|
138
|
+
* their number — but a ceiling of $50 is almost always a typo or a copied example, and saying so
|
|
139
|
+
* once at startup costs nothing. The README used to claim the limit could not be switched off;
|
|
140
|
+
* it can be raised arbitrarily, and pretending otherwise is worse than warning.
|
|
141
|
+
*/
|
|
142
|
+
export const CEILING_WORTH_MENTIONING_USD = "1";
|
|
143
|
+
export function ceilingLooksWrong(maxPriceUsd) {
|
|
144
|
+
try {
|
|
145
|
+
return usdToAtomic(maxPriceUsd) > usdToAtomic(CEILING_WORTH_MENTIONING_USD);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** The public address of the configured key. The only thing derived from it that anything is allowed to show. */
|
|
152
|
+
export function payerAddress(config) {
|
|
153
|
+
return privateKeyToAccount(config.privateKey).address;
|
|
154
|
+
}
|
|
155
|
+
/** What `GET /` says about payment. Free (unpaid), so it costs nothing to ask and it settles which network to register. */
|
|
156
|
+
export async function discoverPricing(config, fetchImpl) {
|
|
157
|
+
let res;
|
|
158
|
+
try {
|
|
159
|
+
res = await fetchImpl(`${config.baseUrl}/`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(config.timeoutMs) });
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
throw new Url2mdToolError("SERVICE_UNREACHABLE", `${config.baseUrl} could not be reached: ${e instanceof Error ? e.message : String(e)}`, { retryable: true });
|
|
163
|
+
}
|
|
164
|
+
if (!res.ok)
|
|
165
|
+
throw new Url2mdToolError("SERVICE_UNREACHABLE", `${config.baseUrl}/ answered ${res.status}`, { httpStatus: res.status, retryable: true });
|
|
166
|
+
const body = (await res.json().catch(() => null));
|
|
167
|
+
const p = body?.pricing;
|
|
168
|
+
if (!p || typeof p.model !== "string")
|
|
169
|
+
throw new Url2mdToolError("SERVICE_UNREACHABLE", `${config.baseUrl}/ did not describe its pricing`, { retryable: true });
|
|
170
|
+
return { model: p.model, network: p.network ?? null, priceAtomic: p.priceAtomic ?? null, payTo: p.payTo ?? null, asset: p.asset ?? null, assetAddress: p.assetAddress ?? null };
|
|
171
|
+
}
|
|
172
|
+
/** USD with up to six decimals as atomic USDC. */
|
|
173
|
+
export function usdToAtomic(usd) {
|
|
174
|
+
const m = /^(\d+)(?:\.(\d{1,6}))?$/.exec(usd.trim());
|
|
175
|
+
if (!m)
|
|
176
|
+
throw new Url2mdToolError("BAD_RESPONSE", `not a USD amount: ${usd}`);
|
|
177
|
+
return BigInt(m[1]) * 1000000n + BigInt((m[2] ?? "").padEnd(6, "0"));
|
|
178
|
+
}
|
|
179
|
+
export function atomicToUsd(atomic) {
|
|
180
|
+
const frac = (atomic % 1000000n).toString().padStart(6, "0").replace(/0+$/, "");
|
|
181
|
+
return `${atomic / 1000000n}${frac ? `.${frac}` : ""}`;
|
|
182
|
+
}
|
|
183
|
+
/** The x402 client for one network, with the caller's own per-payment ceiling on top of the SDK's default. */
|
|
184
|
+
export function clientFor(config, network) {
|
|
185
|
+
const account = privateKeyToAccount(config.privateKey);
|
|
186
|
+
const signer = toClientEvmSigner({ address: account.address, signTypedData: (a) => account.signTypedData(a) });
|
|
187
|
+
return new x402Client()
|
|
188
|
+
.register(network, new ExactEvmScheme(signer))
|
|
189
|
+
.setSpendControls({ maxAmountPerPayment: `$${config.maxPriceUsd}` });
|
|
190
|
+
}
|
|
191
|
+
const EXPLORER = {
|
|
192
|
+
"eip155:8453": "https://basescan.org/tx/",
|
|
193
|
+
"eip155:84532": "https://sepolia.basescan.org/tx/",
|
|
194
|
+
};
|
|
195
|
+
const sameAddress = (a, b) => (a ?? "").toLowerCase() === (b ?? "").toLowerCase();
|
|
196
|
+
/**
|
|
197
|
+
* The terms, checked before anything is signed — against what the service **published**, not only
|
|
198
|
+
* against what this 402 says about itself.
|
|
199
|
+
*
|
|
200
|
+
* A 402 is just a response header. Whoever answers the request writes it, and a compromised or
|
|
201
|
+
* impersonated deployment can put any address and any amount in it. Cross-checking `payTo`, the
|
|
202
|
+
* asset and the network against the `GET /` a separate, unpaid request fetched is what makes the
|
|
203
|
+
* ceiling a second line of defence rather than the only one: without it the worst case is not
|
|
204
|
+
* "you overpaid", it is "you paid a stranger" (rule 13 of the service repository's CLAUDE.md). The amount is checked against
|
|
205
|
+
* the ceiling and not against the published price, because a toll quote is legitimately higher.
|
|
206
|
+
*/
|
|
207
|
+
function checkTerms(required, config, pricing, committedAtomic) {
|
|
208
|
+
// Fail closed. A check written as "if the service published a payTo, it must match" defends
|
|
209
|
+
// against nothing in its own threat model: the attacker who writes the 402 also writes `GET /`,
|
|
210
|
+
// and can simply publish nothing. Terms that cannot be checked are terms that are not signed.
|
|
211
|
+
for (const [field, value] of [
|
|
212
|
+
["payTo", pricing.payTo],
|
|
213
|
+
["assetAddress", pricing.assetAddress],
|
|
214
|
+
["network", pricing.network],
|
|
215
|
+
]) {
|
|
216
|
+
if (!value) {
|
|
217
|
+
throw new Url2mdToolError("UNPUBLISHED_TERMS", `${config.baseUrl} asks for payment but publishes no ${field} at GET /, so there is nothing to check its 402 against. Nothing was signed.`, {
|
|
218
|
+
httpStatus: 402,
|
|
219
|
+
details: { missing: field },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const accepts = required.accepts?.[0];
|
|
224
|
+
if (!accepts)
|
|
225
|
+
throw new Url2mdToolError("UNSUPPORTED_PAYMENT_TERMS", "the 402 offered no payment options", { httpStatus: 402, details: { x402Version: required.x402Version } });
|
|
226
|
+
if (accepts.scheme !== "exact") {
|
|
227
|
+
throw new Url2mdToolError("UNSUPPORTED_PAYMENT_TERMS", `this client only pays the "exact" scheme; the service asked for "${accepts.scheme}"`, { httpStatus: 402, details: { scheme: accepts.scheme } });
|
|
228
|
+
}
|
|
229
|
+
if (accepts.network !== pricing.network) {
|
|
230
|
+
// A 402 on a network the service did not publish is not a price change, it is a different service.
|
|
231
|
+
throw new Url2mdToolError("UNSUPPORTED_PAYMENT_TERMS", `the 402 asks for payment on ${accepts.network}, but ${config.baseUrl} publishes ${pricing.network}`, { httpStatus: 402, details: { asked: accepts.network, published: pricing.network } });
|
|
232
|
+
}
|
|
233
|
+
if (!sameAddress(accepts.payTo, pricing.payTo)) {
|
|
234
|
+
throw new Url2mdToolError("PAYTO_MISMATCH", `the 402 asks you to pay ${accepts.payTo}, but ${config.baseUrl} publishes ${pricing.payTo} as its receiving address. Nothing was signed.`, {
|
|
235
|
+
httpStatus: 402,
|
|
236
|
+
details: { asked: accepts.payTo, published: pricing.payTo, amountUsd: safeUsd(accepts.amount) },
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (!sameAddress(accepts.asset, pricing.assetAddress)) {
|
|
240
|
+
// The ceiling is arithmetic in USDC's six decimals; another token makes that arithmetic meaningless.
|
|
241
|
+
throw new Url2mdToolError("UNSUPPORTED_PAYMENT_TERMS", `the 402 asks for a payment in ${accepts.asset}, but ${config.baseUrl} publishes ${pricing.asset ?? "its asset"} at ${pricing.assetAddress}. Nothing was signed.`, {
|
|
242
|
+
httpStatus: 402,
|
|
243
|
+
details: { asked: accepts.asset, published: pricing.assetAddress },
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
// An authorization stays redeemable until `validBefore`, which the server picks. The honest
|
|
247
|
+
// deployment asks for two minutes; a hostile one asked for ten years in review, and a failed call
|
|
248
|
+
// leaves the signature behind. Anything beyond an hour is refused.
|
|
249
|
+
// Coerced, not type-checked: the same ten-year lifetime sent as a *string* skipped a
|
|
250
|
+
// `typeof === "number"` guard in review and only viem's own arithmetic stopped it, with an
|
|
251
|
+
// INTERNAL error no agent could branch on. A value that is not a number at all is refused too.
|
|
252
|
+
// A lifetime that is absent, zero or unreadable is refused too: the field decides how long the
|
|
253
|
+
// signature stays redeemable, and signing one whose expiry cannot be stated is signing blind.
|
|
254
|
+
const lifetime = Number(accepts.maxTimeoutSeconds);
|
|
255
|
+
if (!Number.isFinite(lifetime) || lifetime <= 0 || lifetime > MAX_AUTHORIZATION_SECONDS) {
|
|
256
|
+
throw new Url2mdToolError("UNSUPPORTED_PAYMENT_TERMS", `the 402 wants an authorization valid for ${accepts.maxTimeoutSeconds} seconds; this client signs nothing redeemable for longer than ${MAX_AUTHORIZATION_SECONDS}. Nothing was signed.`, {
|
|
257
|
+
httpStatus: 402,
|
|
258
|
+
details: { maxTimeoutSeconds: accepts.maxTimeoutSeconds, limit: MAX_AUTHORIZATION_SECONDS },
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
let amount;
|
|
262
|
+
try {
|
|
263
|
+
amount = BigInt(accepts.amount);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
throw new Url2mdToolError("UNSUPPORTED_PAYMENT_TERMS", `the 402 asks for ${JSON.stringify(accepts.amount)}, which is not an amount. Nothing was signed.`, { httpStatus: 402, details: { amount: accepts.amount } });
|
|
267
|
+
}
|
|
268
|
+
// A negative amount passes every "is it above the ceiling" test ever written, and would have
|
|
269
|
+
// *credited* the session budget on its way through. Only viem refused it, one layer down.
|
|
270
|
+
if (amount <= 0n) {
|
|
271
|
+
throw new Url2mdToolError("UNSUPPORTED_PAYMENT_TERMS", `the 402 asks for an amount of ${accepts.amount}, which is not a payment. Nothing was signed.`, { httpStatus: 402, details: { amount: accepts.amount } });
|
|
272
|
+
}
|
|
273
|
+
const max = usdToAtomic(config.maxPriceUsd);
|
|
274
|
+
// The ceiling bounds the **call**, not each payment in it. A tolled page is two rounds, and two
|
|
275
|
+
// rounds under a per-payment ceiling commit twice the number the user was shown.
|
|
276
|
+
const wouldTotal = committedAtomic + amount;
|
|
277
|
+
if (wouldTotal > max) {
|
|
278
|
+
const already = committedAtomic > 0n ? ` (this call has already committed $${atomicToUsd(committedAtomic)})` : "";
|
|
279
|
+
throw new Url2mdToolError("PRICE_ABOVE_LIMIT", `the call would cost $${atomicToUsd(wouldTotal)} in total${already}, above the $${config.maxPriceUsd} per-call limit. Raise URL2MD_MAX_PRICE_USD to allow it.`, {
|
|
280
|
+
httpStatus: 402,
|
|
281
|
+
details: { priceUsd: atomicToUsd(amount), totalUsd: atomicToUsd(wouldTotal), committedUsd: atomicToUsd(committedAtomic), limitUsd: config.maxPriceUsd, network: accepts.network, payTo: accepts.payTo },
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return { amountAtomic: amount, network: accepts.network, payTo: accepts.payTo, asset: accepts.asset };
|
|
285
|
+
}
|
|
286
|
+
const safeUsd = (atomic) => {
|
|
287
|
+
try {
|
|
288
|
+
return atomicToUsd(BigInt(atomic));
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return atomic;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
/** The error the service answered with, carried through unchanged so the model sees url2md's own code and details. */
|
|
295
|
+
async function serviceError(res, body) {
|
|
296
|
+
let parsed = null;
|
|
297
|
+
try {
|
|
298
|
+
parsed = JSON.parse(body);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
parsed = null;
|
|
302
|
+
}
|
|
303
|
+
const err = parsed?.error;
|
|
304
|
+
const details = { ...(err?.details ?? {}) };
|
|
305
|
+
// A charge that produced nothing is the one thing a caller must not have to dig for.
|
|
306
|
+
const ledgerId = res.headers.get("x-url2md-ledger-id");
|
|
307
|
+
if (res.headers.get("x-url2md-caller-charged") === "true")
|
|
308
|
+
details["callerCharged"] = true;
|
|
309
|
+
if (ledgerId)
|
|
310
|
+
details["ledgerId"] = ledgerId;
|
|
311
|
+
const receipt = res.headers.get("payment-response");
|
|
312
|
+
if (receipt) {
|
|
313
|
+
try {
|
|
314
|
+
details["receipt"] = decodePaymentResponseHeader(receipt);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
/* a receipt we cannot decode is not worth failing over; the ledger id is the handle */
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return new Url2mdToolError(err?.code ?? `HTTP_${res.status}`, err?.message ?? `${res.status} from ${new URL(res.url || "https://url2md.invalid").pathname}: ${body.slice(0, 400)}`, {
|
|
321
|
+
httpStatus: res.status,
|
|
322
|
+
retryable: err?.retryable ?? res.status >= 500,
|
|
323
|
+
details,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
/** How many redirects the paid request will follow, all of them on the service's own origin. */
|
|
327
|
+
const MAX_REDIRECTS = 3;
|
|
328
|
+
/** Two hosts that differ only by a leading `www.` — url2md itself redirects www to the apex. */
|
|
329
|
+
function sameService(a, b) {
|
|
330
|
+
const bare = (h) => h.replace(/^www\./, "");
|
|
331
|
+
if (bare(a.hostname) !== bare(b.hostname) || a.port !== b.port)
|
|
332
|
+
return false;
|
|
333
|
+
// https must not become http: a downgrade puts the signed authorization on the open wire.
|
|
334
|
+
return !(a.protocol === "https:" && b.protocol !== "https:");
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* A request pinned to the service's own origin. `fetch` follows redirects by default and re-sends
|
|
338
|
+
* every header, so a service that answers the *paid* request with a 302 hands the signed
|
|
339
|
+
* authorization — a redeemable USDC transfer — to whatever host it names. Found in review: a third
|
|
340
|
+
* party received the signature, wrote the markdown and wrote the settlement receipt, and the client
|
|
341
|
+
* reported success with no warning. Redirects are followed by hand, on this service only.
|
|
342
|
+
*/
|
|
343
|
+
async function fetchPinned(url, init, config, fetchImpl) {
|
|
344
|
+
const base = new URL(config.baseUrl);
|
|
345
|
+
let current = url;
|
|
346
|
+
for (let hop = 0;; hop++) {
|
|
347
|
+
const res = await fetchImpl(current, { ...init, redirect: "manual" });
|
|
348
|
+
const location = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
|
|
349
|
+
if (!location)
|
|
350
|
+
return res;
|
|
351
|
+
if (hop >= MAX_REDIRECTS) {
|
|
352
|
+
throw new Url2mdToolError("TOO_MANY_REDIRECTS", `${config.baseUrl} redirected more than ${MAX_REDIRECTS} times.`, { httpStatus: res.status, details: { lastUrl: current } });
|
|
353
|
+
}
|
|
354
|
+
let next;
|
|
355
|
+
try {
|
|
356
|
+
next = new URL(location, current);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
throw new Url2mdToolError("BAD_RESPONSE", `${config.baseUrl} answered ${res.status} with a Location this client could not read.`, { httpStatus: res.status, details: { location } });
|
|
360
|
+
}
|
|
361
|
+
if (!sameService(base, next)) {
|
|
362
|
+
throw new Url2mdToolError("REDIRECT_REFUSED", `${config.baseUrl} tried to redirect this request to ${next.origin}, which is not the service this client was configured for. The request was not followed there${init.headers && "payment-signature" in init.headers ? ", so the signed payment authorization was not handed to it" : ""}.`, {
|
|
363
|
+
httpStatus: res.status,
|
|
364
|
+
details: { redirectedTo: next.origin, expected: base.origin },
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
current = next.href;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* One conversion, paid for. The unpaid request comes first on purpose: it is one round trip, it is
|
|
372
|
+
* how the price is learned rather than assumed, and it is what makes a tolled page's quote reachable.
|
|
373
|
+
*/
|
|
374
|
+
export async function convert(input, config, pricing, fetchImpl, budget) {
|
|
375
|
+
const target = `${config.baseUrl}/v1/md?url=${encodeURIComponent(input.url)}&render=${input.render}&format=json`;
|
|
376
|
+
const client = pricing.model === "x402" && pricing.network ? clientFor(config, pricing.network) : null;
|
|
377
|
+
let signature = null;
|
|
378
|
+
let rounds = 0;
|
|
379
|
+
// Every authorization this call put on the wire, in the order it signed them. Not a log: it is
|
|
380
|
+
// what the caller is told on **every** exit, success or failure. A signature that left this
|
|
381
|
+
// process is money the caller has committed, whatever the service does next (rule 13 of the service repository's CLAUDE.md).
|
|
382
|
+
const signed = [];
|
|
383
|
+
// Notes this client raises itself, kept apart from the service's own warnings until they are merged.
|
|
384
|
+
const ours = [];
|
|
385
|
+
const committed = () => signed.reduce((t, a) => t + BigInt(a.amountAtomic), 0n);
|
|
386
|
+
// The terms actually signed. A settlement receipt for the "exact" scheme need not restate the
|
|
387
|
+
// amount, and on a tolled call the base price is not what was paid — the accepted offer is.
|
|
388
|
+
let paid = null;
|
|
389
|
+
/** Anything thrown from here on carries what was already committed; nothing exits silently. */
|
|
390
|
+
const withSigned = (e) => signed.length === 0 ? e : new Url2mdToolError(e.code, e.message, { httpStatus: e.httpStatus, retryable: e.retryable, details: { ...e.details, ...signedDetails(signed) } });
|
|
391
|
+
let res;
|
|
392
|
+
try {
|
|
393
|
+
for (;;) {
|
|
394
|
+
try {
|
|
395
|
+
res = await fetchPinned(target, { headers: { accept: "application/json", ...(signature ? { "payment-signature": signature } : {}) }, signal: AbortSignal.timeout(config.timeoutMs) }, config, fetchImpl);
|
|
396
|
+
}
|
|
397
|
+
catch (e) {
|
|
398
|
+
if (e instanceof Url2mdToolError)
|
|
399
|
+
throw e;
|
|
400
|
+
const aborted = e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError");
|
|
401
|
+
throw new Url2mdToolError(aborted ? "TIMEOUT" : "SERVICE_UNREACHABLE", aborted ? `no answer from ${config.baseUrl} within ${config.timeoutMs} ms` : `${config.baseUrl} could not be reached: ${e instanceof Error ? e.message : String(e)}`, { retryable: true });
|
|
402
|
+
}
|
|
403
|
+
if (res.status !== 402)
|
|
404
|
+
break;
|
|
405
|
+
// Two rounds and no more: the second 402 is the toll quote, and there is no third thing to pay.
|
|
406
|
+
if (rounds >= 2) {
|
|
407
|
+
const again = res.headers.get("payment-required");
|
|
408
|
+
throw new Url2mdToolError("PAYMENT_NOT_ACCEPTED", `${config.baseUrl} answered 402 a third time. It already holds ${signed.length} signed authorization${signed.length === 1 ? "" : "s"} from this call, totalling $${atomicToUsd(committed())}, which it can still redeem. No further payment was signed.`, {
|
|
409
|
+
httpStatus: 402,
|
|
410
|
+
details: { rounds, reason: again ? safeDecode(again)?.error : (await res.text()).slice(0, 300) },
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (pricing.model === "x402" && !pricing.network) {
|
|
414
|
+
// Without a published network there is nothing to check the 402's network against, and the
|
|
415
|
+
// old message ("cannot pay it") named the pricing model rather than the missing field.
|
|
416
|
+
throw new Url2mdToolError("UNPUBLISHED_TERMS", `${config.baseUrl} asks for payment but publishes no network at GET /, so there is nothing to check its 402 against. Nothing was signed.`, { httpStatus: 402, details: { missing: "network" } });
|
|
417
|
+
}
|
|
418
|
+
if (!client) {
|
|
419
|
+
throw new Url2mdToolError("PAYMENT_REQUIRED", `${config.baseUrl} asks for payment but publishes pricing.model=${pricing.model}, so this client cannot pay it.`, { httpStatus: 402, details: { pricingModel: pricing.model } });
|
|
420
|
+
}
|
|
421
|
+
const header = res.headers.get("payment-required");
|
|
422
|
+
if (!header)
|
|
423
|
+
throw new Url2mdToolError("BAD_RESPONSE", "the 402 carried no PAYMENT-REQUIRED header", { httpStatus: 402 });
|
|
424
|
+
const required = safeDecode(header);
|
|
425
|
+
if (!required)
|
|
426
|
+
throw new Url2mdToolError("BAD_RESPONSE", "the PAYMENT-REQUIRED header could not be decoded", { httpStatus: 402 });
|
|
427
|
+
paid = checkTerms(required, config, pricing, committed());
|
|
428
|
+
// The first 402 is the service's own price, and it is published. A toll quote is legitimately
|
|
429
|
+
// higher — the second round — but a first round above the published price is the service
|
|
430
|
+
// charging something other than what it advertises, and staying under the ceiling makes it
|
|
431
|
+
// silent. It is the caller's wallet and their ceiling, so this is said, not refused.
|
|
432
|
+
if (rounds === 0 && pricing.priceAtomic && /^\d+$/.test(pricing.priceAtomic) && paid.amountAtomic > BigInt(pricing.priceAtomic)) {
|
|
433
|
+
ours.push(`${config.baseUrl} publishes a price of $${atomicToUsd(BigInt(pricing.priceAtomic))} at GET / but its 402 asked for $${atomicToUsd(paid.amountAtomic)} — ${(Number(paid.amountAtomic) / Number(BigInt(pricing.priceAtomic))).toFixed(1)}× the published price. It was within your $${config.maxPriceUsd} per-call limit, so it was paid.`);
|
|
434
|
+
}
|
|
435
|
+
budget?.reserve(paid.amountAtomic, config);
|
|
436
|
+
let payload;
|
|
437
|
+
try {
|
|
438
|
+
// createPaymentPayload echoes accepts[0] verbatim, which is what carries the signed quote token
|
|
439
|
+
// back on the tolled retry; nothing here needs to know the token exists.
|
|
440
|
+
payload = await client.createPaymentPayload(required);
|
|
441
|
+
}
|
|
442
|
+
catch (e) {
|
|
443
|
+
throw new Url2mdToolError("PAYMENT_NOT_SIGNED", `the payment could not be signed: ${e instanceof Error ? e.message : String(e)}`, { httpStatus: 402, details: { network: required.accepts?.[0]?.network ?? null } });
|
|
444
|
+
}
|
|
445
|
+
signature = encodePaymentSignatureHeader(payload);
|
|
446
|
+
signed.push(describeAuthorization(payload, paid));
|
|
447
|
+
budget?.commit(paid.amountAtomic);
|
|
448
|
+
rounds++;
|
|
449
|
+
}
|
|
450
|
+
const body = await res.text();
|
|
451
|
+
if (!res.ok)
|
|
452
|
+
throw await serviceError(res, body);
|
|
453
|
+
let result;
|
|
454
|
+
try {
|
|
455
|
+
result = JSON.parse(body);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
throw new Url2mdToolError("BAD_RESPONSE", `the answer was ${res.status} but not JSON: ${body.slice(0, 200)}`, { httpStatus: res.status });
|
|
459
|
+
}
|
|
460
|
+
const warnings = [...(result.warnings ?? []), ...ours];
|
|
461
|
+
const receipt = receiptFrom(res, paid, config, warnings);
|
|
462
|
+
const committedAtomic = committed();
|
|
463
|
+
// On a tolled call two authorizations were signed and only the second is settled: reporting the
|
|
464
|
+
// settled one alone understates what the caller committed, and reporting their sum overstates
|
|
465
|
+
// what was charged. Both numbers are given, and the difference is said in words.
|
|
466
|
+
if (signed.length > 1) {
|
|
467
|
+
warnings.push(`This call signed ${signed.length} payment authorizations totalling $${atomicToUsd(committedAtomic)}: the page charged a toll of its own, so the first authorization was replaced by a quote. \`payment\` is the one the service settled ($${atomicToUsd(paid?.amountAtomic ?? 0n)}); the rest are listed in \`signed\` with their nonces, and the service could still redeem them.`);
|
|
468
|
+
}
|
|
469
|
+
return { result, payment: receipt, toll: tollFrom(res, result, paid?.network ?? null, warnings), paymentRounds: rounds, warnings, signed, committedUsd: atomicToUsd(committedAtomic) };
|
|
470
|
+
}
|
|
471
|
+
catch (e) {
|
|
472
|
+
throw withSigned(e instanceof Url2mdToolError ? e : new Url2mdToolError("INTERNAL", e instanceof Error ? e.message : String(e)));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function describeAuthorization(payload, paid) {
|
|
476
|
+
const auth = (payload.payload?.authorization ?? {});
|
|
477
|
+
const validBefore = auth.validBefore && /^\d+$/.test(String(auth.validBefore)) ? new Date(Number(auth.validBefore) * 1000).toISOString() : null;
|
|
478
|
+
return {
|
|
479
|
+
amountUsd: atomicToUsd(paid.amountAtomic),
|
|
480
|
+
amountAtomic: paid.amountAtomic.toString(),
|
|
481
|
+
network: paid.network,
|
|
482
|
+
payTo: paid.payTo,
|
|
483
|
+
payer: auth.from ?? "",
|
|
484
|
+
nonce: auth.nonce ?? null,
|
|
485
|
+
validBefore,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* What every failure after a signature says. A caller that cannot see this has to read the chain to
|
|
490
|
+
* find out whether the call cost anything — and would not know which nonce to look for.
|
|
491
|
+
*/
|
|
492
|
+
function signedDetails(signed) {
|
|
493
|
+
const total = signed.reduce((t, a) => t + BigInt(a.amountAtomic), 0n);
|
|
494
|
+
return {
|
|
495
|
+
signedButUnconfirmed: true,
|
|
496
|
+
committedUsd: atomicToUsd(total),
|
|
497
|
+
signed,
|
|
498
|
+
note: `This call signed ${signed.length} payment authorization${signed.length === 1 ? "" : "s"} totalling $${atomicToUsd(total)} before it failed. The service may or may not have redeemed ${signed.length === 1 ? "it" : "them"}; the nonces above are what USDC emits as AuthorizationUsed when one is spent.`,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
function safeDecode(header) {
|
|
502
|
+
try {
|
|
503
|
+
return decodePaymentRequiredHeader(header);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* The receipt for a payment this client signed. Built from the terms it accepted, then filled in
|
|
511
|
+
* from the service's settlement if one came back — never the other way round, so a missing or
|
|
512
|
+
* unreadable `PAYMENT-RESPONSE` downgrades the receipt instead of erasing it.
|
|
513
|
+
*/
|
|
514
|
+
function receiptFrom(res, paid, config, warnings) {
|
|
515
|
+
if (!paid)
|
|
516
|
+
return null;
|
|
517
|
+
const header = res.headers.get("payment-response");
|
|
518
|
+
let decoded = null;
|
|
519
|
+
let corrupt = false;
|
|
520
|
+
if (header) {
|
|
521
|
+
try {
|
|
522
|
+
decoded = decodePaymentResponseHeader(header);
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
corrupt = true;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// The amount, the network and the recipient are what THIS CLIENT SIGNED. They are not read from
|
|
529
|
+
// the service's receipt: a receipt is the counterparty's word, and one that claimed 1 atomic unit
|
|
530
|
+
// on mainnet for a payment of 5000 on testnet was reported verbatim in review, with a mainnet
|
|
531
|
+
// explorer link and no warning. Where the receipt disagrees, ours stands and the difference is
|
|
532
|
+
// said out loud (rule 13 of the service repository's CLAUDE.md).
|
|
533
|
+
const network = paid.network;
|
|
534
|
+
const amountAtomic = paid.amountAtomic.toString();
|
|
535
|
+
const payer = privateKeyToAccount(config.privateKey).address;
|
|
536
|
+
if (decoded) {
|
|
537
|
+
if (decoded.network && decoded.network !== network)
|
|
538
|
+
warnings.push(`The settlement receipt claims network ${decoded.network}, but this client signed on ${network}. The payment reported here is what was signed.`);
|
|
539
|
+
if (decoded.amount && decoded.amount !== amountAtomic)
|
|
540
|
+
warnings.push(`The settlement receipt claims ${decoded.amount} atomic units, but this client signed ${amountAtomic}. The payment reported here is what was signed.`);
|
|
541
|
+
if (decoded.payer && !sameAddress(decoded.payer, payer))
|
|
542
|
+
warnings.push(`The settlement receipt names ${decoded.payer} as the payer, which is not this wallet. The payment reported here is what was signed.`);
|
|
543
|
+
}
|
|
544
|
+
const settled = decoded !== null && Boolean(decoded.transaction);
|
|
545
|
+
if (!settled) {
|
|
546
|
+
warnings.push(corrupt
|
|
547
|
+
? `You were charged $${atomicToUsd(paid.amountAtomic)} and ${config.baseUrl} returned a settlement receipt this client could not decode. The amount and recipient reported here are what was signed. Check the payer wallet on-chain before calling again.`
|
|
548
|
+
: `You were charged $${atomicToUsd(paid.amountAtomic)} but ${config.baseUrl} returned no settlement receipt at all. The amount and recipient reported here are what this client signed, not what the service confirmed. Check the payer wallet on-chain before calling again.`);
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
amountUsd: atomicToUsd(paid.amountAtomic),
|
|
552
|
+
amountAtomic,
|
|
553
|
+
network,
|
|
554
|
+
payTo: paid.payTo,
|
|
555
|
+
settled,
|
|
556
|
+
payer,
|
|
557
|
+
transaction: decoded?.transaction || null,
|
|
558
|
+
// Built from the network this client signed on, never the one the receipt names.
|
|
559
|
+
explorerUrl: decoded?.transaction && EXPLORER[network] ? `${EXPLORER[network]}${decoded.transaction}` : null,
|
|
560
|
+
ledgerId: res.headers.get("x-url2md-ledger-id"),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* How much this process has committed, across every call. The per-call ceiling bounds one call; an
|
|
565
|
+
* agent in a retry loop makes many. Kept as an object rather than a module variable so a test, and a
|
|
566
|
+
* second server in one process, each get their own.
|
|
567
|
+
*/
|
|
568
|
+
export class Budget {
|
|
569
|
+
spentAtomic = 0n;
|
|
570
|
+
maxAtomic;
|
|
571
|
+
constructor(maxUsd) {
|
|
572
|
+
this.maxAtomic = usdToAtomic(maxUsd);
|
|
573
|
+
}
|
|
574
|
+
/** Refuses before anything is signed. */
|
|
575
|
+
reserve(amountAtomic, config) {
|
|
576
|
+
if (this.spentAtomic + amountAtomic > this.maxAtomic) {
|
|
577
|
+
throw new Url2mdToolError("SPEND_LIMIT_REACHED", `This server has already committed $${atomicToUsd(this.spentAtomic)} of its $${atomicToUsd(this.maxAtomic)} session limit, and this call would take it to $${atomicToUsd(this.spentAtomic + amountAtomic)}. Nothing was signed. Raise URL2MD_MAX_SPEND_USD and restart to allow more.`, { httpStatus: 402, details: { committedUsd: atomicToUsd(this.spentAtomic), wouldCommitUsd: atomicToUsd(amountAtomic), limitUsd: atomicToUsd(this.maxAtomic), baseUrl: config.baseUrl } });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
commit(amountAtomic) {
|
|
581
|
+
this.spentAtomic += amountAtomic;
|
|
582
|
+
}
|
|
583
|
+
get spentUsd() {
|
|
584
|
+
return atomicToUsd(this.spentAtomic);
|
|
585
|
+
}
|
|
586
|
+
get limitUsd() {
|
|
587
|
+
return atomicToUsd(this.maxAtomic);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* What the service says it paid the origin. Every field here is the service's word — this client
|
|
592
|
+
* signed nothing to the origin and settled nothing with it — so the explorer link is built from the
|
|
593
|
+
* network *this client signed on*, never from the network the service names beside the hash. A
|
|
594
|
+
* testnet call whose body claimed `eip155:8453` produced a mainnet explorer link in review.
|
|
595
|
+
*/
|
|
596
|
+
function tollFrom(res, result, signedNetwork, warnings) {
|
|
597
|
+
const amountUsd = res.headers.get("x-url2md-upstream-toll");
|
|
598
|
+
if (!amountUsd)
|
|
599
|
+
return null;
|
|
600
|
+
const up = (result["upstreamPayment"] ?? null);
|
|
601
|
+
const network = signedNetwork ?? "";
|
|
602
|
+
if (up?.network && signedNetwork && up.network !== signedNetwork) {
|
|
603
|
+
warnings.push(`The service says it paid the page's toll on ${up.network}, but this call was made on ${signedNetwork}. The toll link below points at ${signedNetwork}.`);
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
amountUsd,
|
|
607
|
+
payTo: up?.payTo ?? null,
|
|
608
|
+
transaction: up?.transaction ?? null,
|
|
609
|
+
explorerUrl: up?.transaction && EXPLORER[network] ? `${EXPLORER[network]}${up.transaction}` : null,
|
|
610
|
+
};
|
|
611
|
+
}
|