1import { Actor, log } from 'apify';
2import {
3 EIP_NAMES,
4 SOLANA,
5 TRON,
6 NET_ALIAS,
7 ASSETS,
8 NAME_FALLBACK,
9 SOLANA_MINTS,
10 GATEWAY_WALLETS,
11} from './constants.js';
12
13const CONCURRENCY = 15;
14const TIMEOUT_MS = 15_000;
15const PUSH_BATCH = 100;
16
17function resolveNetwork(raw) {
18 if (!raw) return null;
19 if (EIP_NAMES[raw]) return EIP_NAMES[raw];
20 if (raw.startsWith('solana:')) return SOLANA[raw.slice(7)] ?? 'Solana';
21 if (raw.startsWith('tron:')) return TRON[raw.slice(5)] ?? 'Tron';
22 return NET_ALIAS[String(raw).toLowerCase()] ?? raw;
23}
24
25function normalizeAccept(e) {
26 const network = resolveNetwork(e.network ?? null);
27 const assetLc = e.asset ? String(e.asset).toLowerCase() : null;
28 let hit = assetLc ? ASSETS.get(`${network}:${assetLc}`) : null;
29 if (!hit && assetLc) hit = SOLANA_MINTS[assetLc] ?? null;
30 if (!hit && e.extra?.verifyingContract
31 && GATEWAY_WALLETS.has(String(e.extra.verifyingContract).toLowerCase())) {
32 hit = ['USDC', 6];
33 }
34 const name = e.extra?.name ?? null;
35 const nameHit = !hit && name ? NAME_FALLBACK[String(name).toUpperCase().trim()] : null;
36 const symbol = hit?.[0] ?? nameHit?.[0] ?? name ?? e.asset ?? e.currency ?? null;
37 const rawAmt = e.maxAmountRequired ?? e.amount ?? e.price ?? null;
38 let amount = rawAmt == null ? null : String(rawAmt);
39 const decimals = hit?.[1] ?? e.extra?.decimals ?? nameHit?.[1] ?? null;
40 if (rawAmt != null && decimals != null) {
41 const s = String(rawAmt).trim();
42 if (/^\d+$/.test(s)) {
43 const padded = s.padStart(decimals + 1, '0');
44 const frac = padded.slice(-decimals).replace(/0+$/, '');
45 amount = frac ? `${padded.slice(0, -decimals)}.${frac}` : padded.slice(0, -decimals);
46 }
47 }
48 const out = {
49 scheme: e.scheme ?? null,
50 network,
51 amount,
52 symbol,
53 payTo: e.payTo ?? e.endpoint ?? null,
54 };
55 if (e.maxTimeoutSeconds != null) out.maxTimeoutSeconds = e.maxTimeoutSeconds;
56 if (e.extra && typeof e.extra === 'object') {
57 const { name: _n, ...rest } = e.extra;
58 if (Object.keys(rest).length) out.extra = rest;
59 }
60 return out;
61}
62
63const HEADER_KEYS = ['payment-required','x-payment-required','www-authenticate',
64 'x402-price','x402-currency','x402-network','x402-endpoint'];
65
66function parsePaymentDetails(hdrs, body) {
67 const rawHeaders = {};
68 for (const k of HEADER_KEYS) if (hdrs[k]) rawHeaders[k] = hdrs[k];
69
70 let payload = null;
71 if (hdrs['payment-required']) {
72 try { payload = JSON.parse(Buffer.from(hdrs['payment-required'], 'base64').toString('utf8')); } catch {}
73 }
74 if (!payload && body && (body.accepts || body.paymentRequirements || body.x402Version)) payload = body;
75
76 if (payload) {
77 const raw = payload.accepts ?? payload.paymentRequirements ?? [];
78 return {
79 x402Version: payload.x402Version ?? null,
80 description: payload.resource?.description ?? null,
81 accepts: Array.isArray(raw) ? raw.map(normalizeAccept) : [],
82 rawHeaders,
83 };
84 }
85
86
87 let amount = null, symbol = null, network = null, payTo = null;
88 if (hdrs['x-payment-required']) {
89 try {
90 const p = JSON.parse(hdrs['x-payment-required']);
91 amount = p.price ?? p.amount ?? null;
92 symbol = p.currency ?? p.asset ?? null;
93 network = resolveNetwork(p.network ?? p.chain ?? null);
94 payTo = p.endpoint ?? p.paymentEndpoint ?? p.payTo ?? null;
95 } catch { amount = hdrs['x-payment-required']; }
96 }
97 amount ??= hdrs['x402-price'] ?? null;
98 symbol ??= hdrs['x402-currency'] ?? null;
99 network ??= resolveNetwork(hdrs['x402-network'] ?? null);
100 payTo ??= hdrs['x402-endpoint'] ?? null;
101
102 if (!amount && hdrs['www-authenticate']) {
103 const m = (re) => (hdrs['www-authenticate'].match(re) || [])[1] ?? null;
104 amount ??= m(/price="?([^",\s]+)"?/i);
105 symbol ??= m(/currency="?([^",\s]+)"?/i);
106 network ??= resolveNetwork(m(/network="?([^",\s]+)"?/i));
107 payTo ??= m(/endpoint="?([^",\s]+)"?/i);
108 }
109
110 const accepts = (amount || network || payTo)
111 ? [{ scheme: null, network, amount: amount ? String(amount) : null, symbol, payTo }]
112 : [];
113 return { x402Version: null, description: null, accepts, rawHeaders };
114}
115
116async function checkUrl(url) {
117 const checkedAt = new Date().toISOString();
118 const ctrl = new AbortController();
119 const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
120 try {
121 const res = await fetch(url, {
122 method: 'GET', redirect: 'follow',
123 signal: ctrl.signal,
124 headers: { 'User-Agent': 'X402Checker/1.0' },
125 });
126 clearTimeout(timer);
127 const statusCode = res.status;
128 if (statusCode !== 402) return { url, supportsX402: false, statusCode, checkedAt };
129
130 const hdrs = Object.fromEntries(res.headers.entries());
131 const text = await res.text();
132 let body = null;
133 if (text && text.trimStart()[0] === '{') try { body = JSON.parse(text); } catch {}
134
135 const p = parsePaymentDetails(hdrs, body);
136 const item = {
137 url, supportsX402: true, statusCode,
138 x402Version: p.x402Version,
139 description: p.description,
140 acceptsCount: p.accepts.length,
141 acceptNetworks: p.accepts.map(a => a.network),
142 acceptAmounts: p.accepts.map(a => a.amount),
143 acceptSymbols: p.accepts.map(a => a.symbol),
144 acceptPayTos: p.accepts.map(a => a.payTo),
145 accepts: p.accepts,
146 checkedAt,
147 };
148 if (Object.keys(p.rawHeaders).length) item.rawHeaders = p.rawHeaders;
149 return item;
150 } catch (err) {
151 clearTimeout(timer);
152 return { url, supportsX402: false, statusCode: null,
153 error: err.name === 'AbortError' ? 'Request timed out' : err.message, checkedAt };
154 }
155}
156
157Actor.main(async () => {
158 const input = await Actor.getInput();
159 let { urls = [] } = input ?? {};
160
161 if (typeof urls === 'string') urls = urls.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
162 else urls = urls.map(u => (typeof u === 'object' && u.url ? u.url : u));
163 urls = [...new Set(urls.filter(Boolean))];
164 if (!urls.length) { log.warning('No URLs provided — exiting.'); return; }
165
166
167 let urlList = [...urls];
168 const maxBudget = parseFloat(process.env.ACTOR_MAX_TOTAL_CHARGE_USD);
169 const spent = parseFloat(process.env.ACTOR_TOTAL_CHARGE_USD || '0');
170 if (!isNaN(maxBudget) && maxBudget > 0) {
171 try {
172 const pricePerItem = Actor.getChargingManager().getPricingInfo()?.perEventPrices?.['apify-default-dataset-item'] ?? null;
173 if (pricePerItem > 0) {
174 const max = Math.floor((maxBudget - spent) / pricePerItem);
175 log.info(`Budget $${maxBudget.toFixed(4)} | Remaining $${(maxBudget - spent).toFixed(4)} | Max URLs: ${max}`);
176 if (max <= 0) { log.warning('No remaining budget — exiting.'); return; }
177 if (max < urlList.length) { log.warning(`Capping to ${max} URLs (${urlList.length - max} dropped).`); urlList = urlList.slice(0, max); }
178 }
179 } catch (e) { log.warning(`Could not read pricing info: ${e.message}`); }
180 }
181
182 log.info(`Checking ${urlList.length} URL(s) for X402 support.`);
183
184 let idx = 0;
185 const buffer = [];
186
187 const flush = async (force = false) => {
188 if (buffer.length >= PUSH_BATCH || (force && buffer.length)) {
189 const chunk = buffer.splice(0, buffer.length);
190 await Actor.pushData(chunk);
191 for (const _ of chunk) {
192 try { await Actor.charge({ eventName: 'apify-default-dataset-item' }); } catch {}
193 }
194 }
195 };
196
197 const worker = async () => {
198 while (idx < urlList.length) {
199 const url = urlList[idx++];
200 buffer.push(await checkUrl(url));
201 await flush();
202 }
203 };
204
205 await Promise.all(Array.from({ length: Math.min(CONCURRENCY, urlList.length) }, worker));
206 await flush(true);
207 log.info(`Done. Processed ${urlList.length} URL(s).`);
208});