@x402jp/safe-buy 0.0.0-stage → 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.
@@ -0,0 +1,357 @@
1
+ // safeBuy / checkSeller: buy one call from an x402 seller, stage by stage, stopping
2
+ // before any money moves when something looks wrong.
3
+ import { createHash } from "node:crypto";
4
+ import { checkBaseTransfer, findBasePayment, getBaseReceipt } from "./chains/base.js";
5
+ import { checkSolanaTransfer, findSolanaPayment, getSolanaTx } from "./chains/solana.js";
6
+ import { DEFAULT_FIND_TIMEOUT_MS, DEFAULT_PROBE_URL, DEFAULT_RPC, REQUEST_TIMEOUT_MS, USER_AGENT, } from "./constants.js";
7
+ import { makePayer, payerFromClient, refuseReason, settleFrom, signFor } from "./pay.js";
8
+ import { atomicToUsd, usdToAtomic } from "./price.js";
9
+ import { BAD_LAST_PAID, hostOf, lookupProbe } from "./probe.js";
10
+ import { RECEIPT_HEADER, checkReceipt, fetchReceiptKeys } from "./receipt.js";
11
+ import { buildReport, sendReport } from "./report.js";
12
+ import { encodeReceipt, refetch as refetchCore } from "@x402jp/delivery-receipt-verify";
13
+ import { inputRequirement, parse402, selectAccept, x402Error } from "./x402.js";
14
+ function buildRequest(url, input) {
15
+ const u = new URL(url);
16
+ for (const [k, v] of Object.entries(input?.query ?? {}))
17
+ u.searchParams.set(k, String(v));
18
+ const headers = { "user-agent": USER_AGENT, accept: "application/json, */*", ...(input?.headers ?? {}) };
19
+ let body;
20
+ if (input?.body !== undefined) {
21
+ if (typeof input.body === "string")
22
+ body = input.body;
23
+ else if (input.body instanceof Uint8Array)
24
+ body = new Uint8Array(input.body);
25
+ else {
26
+ body = JSON.stringify(input.body);
27
+ if (!Object.keys(headers).some((h) => h.toLowerCase() === "content-type"))
28
+ headers["content-type"] = "application/json";
29
+ }
30
+ }
31
+ const method = (input?.method ?? (body !== undefined ? "POST" : "GET")).toUpperCase();
32
+ return { url: u.toString(), init: { method, headers, ...(body !== undefined && method !== "GET" && method !== "HEAD" ? { body } : {}) } };
33
+ }
34
+ const errMsg = (e) => (e?.name === "TimeoutError" ? "timeout" : `${e?.message ?? e}`);
35
+ /** Stages 1–3. `usable` limits chain "any" to chains the payer can sign for. */
36
+ async function prepare(url, opts, usable) {
37
+ const stages = [];
38
+ let host;
39
+ try {
40
+ host = hostOf(url);
41
+ }
42
+ catch {
43
+ throw new TypeError(`invalid url ${JSON.stringify(url)}`);
44
+ }
45
+ const out = {
46
+ ok: false, stopped_at: null, reason: null, stages, host, probe: { status: "unavailable", doc: null },
47
+ offer: null, input_requirement: null, parsed: null, accept: null, request: buildRequest(url, opts.input),
48
+ };
49
+ const stop = (name, reason) => {
50
+ stages.push({ name, ok: false, detail: reason });
51
+ return Object.assign(out, { ok: false, stopped_at: name, reason });
52
+ };
53
+ // 1. probe
54
+ const lk = await lookupProbe(opts.probeUrl ?? DEFAULT_PROBE_URL, host);
55
+ out.probe = { status: lk.status, doc: lk.status === "found" ? lk.doc : null };
56
+ const doc = lk.status === "found" ? lk.doc : null;
57
+ if (doc) {
58
+ const lp = doc.last_paid;
59
+ if (lp?.outcome && BAD_LAST_PAID.includes(lp.outcome)) {
60
+ return stop("probe", `probe: last paid purchase (${lp.date ?? lp.ts?.slice(0, 10) ?? "date unknown"}) was ${lp.outcome}`);
61
+ }
62
+ if (doc.buyable_without_input === false && !opts.input) {
63
+ return stop("probe", `probe: not buyable without input${doc.requires_input_reason?.reason ? ` (${doc.requires_input_reason.reason})` : ""}; pass input`);
64
+ }
65
+ stages.push({
66
+ name: "probe",
67
+ ok: true,
68
+ detail: `score ${doc.score ?? "n/a"}, last paid ${lp?.outcome ?? "none"}${lp?.date ? ` (${lp.date})` : ""}, buyable_without_input ${doc.buyable_without_input ?? "n/a"}`,
69
+ });
70
+ }
71
+ else {
72
+ stages.push({ name: "probe", ok: true, detail: lk.status === "no_data" ? "no data" : `unavailable (${lk.error})` });
73
+ }
74
+ // 2. 402
75
+ let res;
76
+ let text;
77
+ try {
78
+ res = await fetch(out.request.url, { ...out.request.init, signal: AbortSignal.timeout(opts.requestTimeoutMs ?? REQUEST_TIMEOUT_MS), redirect: "follow" });
79
+ text = await res.text();
80
+ }
81
+ catch (e) {
82
+ return stop("402", `unreachable: ${errMsg(e)}`);
83
+ }
84
+ if (res.status !== 402)
85
+ return stop("402", `expected 402, got HTTP ${res.status}`);
86
+ const parsed = parse402((n) => res.headers.get(n), text);
87
+ out.parsed = parsed;
88
+ if (parsed.error && !parsed.accepts.length)
89
+ return stop("402", `unreadable 402: ${parsed.error}`);
90
+ const need = inputRequirement(parsed);
91
+ out.input_requirement = need;
92
+ if (need.requires && !opts.input)
93
+ return stop("402", `requires_input: ${need.reason} (from ${need.source}); pass input`);
94
+ const chain = opts.chain ?? "any";
95
+ const a = selectAccept(parsed, chain, usable);
96
+ const refuse = refuseReason(a);
97
+ if (refuse || !a) {
98
+ const offered = parsed.accepts.map((x) => `${x.scheme}/${x.network}/${x.asset}`).join(", ") || "none";
99
+ return stop("402", `${refuse} (chain ${chain}${usable ? `, signable: ${usable.join("/")}` : ""}; offered: ${offered})`);
100
+ }
101
+ out.accept = a;
102
+ out.offer = {
103
+ spec_version: parsed.spec_version, scheme: a.scheme, network: a.network, chain: a.chain, amount: a.amount,
104
+ amount_usd: atomicToUsd(a.amount), asset: a.asset, pay_to: a.payTo, resource: a.resource ?? null, max_timeout_seconds: a.maxTimeoutSeconds ?? null,
105
+ };
106
+ stages.push({ name: "402", ok: true, detail: `${parsed.spec_version} ${a.scheme} ${a.network} ${out.offer.amount_usd} USDC to ${a.payTo}${need.requires ? ` (input given for: ${need.reason})` : ""}` });
107
+ // 3. price
108
+ const amount = BigInt(a.amount);
109
+ const notes = [];
110
+ if (opts.maxPrice !== undefined) {
111
+ const max = usdToAtomic(opts.maxPrice);
112
+ if (amount > max)
113
+ return stop("price", `price ${atomicToUsd(amount)} > maxPrice ${atomicToUsd(max)}`);
114
+ notes.push(`${atomicToUsd(amount)} <= maxPrice ${atomicToUsd(max)}`);
115
+ }
116
+ else
117
+ notes.push(`${atomicToUsd(amount)} (no maxPrice given)`);
118
+ const cat = doc?.catalog;
119
+ if (cat?.url && sameUrl(cat.url, url) && typeof cat.price_usd === "number") {
120
+ const catAtomic = usdToAtomic(cat.price_usd);
121
+ if (catAtomic !== amount) {
122
+ const reason = `price_mismatch (catalog ${atomicToUsd(catAtomic)}, 402 ${atomicToUsd(amount)})`;
123
+ if (!opts.allowCatalogMismatch)
124
+ return stop("price", reason);
125
+ notes.push(`${reason}, allowed by allowCatalogMismatch`);
126
+ }
127
+ else
128
+ notes.push("matches catalog");
129
+ }
130
+ stages.push({ name: "price", ok: true, detail: notes.join("; ") });
131
+ out.ok = true;
132
+ return out;
133
+ }
134
+ const sameUrl = (a, b) => {
135
+ try {
136
+ const x = new URL(a);
137
+ const y = new URL(b);
138
+ return x.origin === y.origin && x.pathname.replace(/\/+$/, "") === y.pathname.replace(/\/+$/, "") && x.search === y.search;
139
+ }
140
+ catch {
141
+ return a === b;
142
+ }
143
+ };
144
+ const publicCheck = ({ parsed: _p, accept: _a, request: _r, ...c }) => c;
145
+ /** Stages 1–3 only: Probe record, the 402 offer and the price checks. Nothing is signed or paid. */
146
+ export async function checkSeller(url, opts = {}) {
147
+ return publicCheck(await prepare(url, opts));
148
+ }
149
+ const sha256 = (b) => createHash("sha256").update(b).digest("hex");
150
+ /** Buy one call from an x402 seller. See README for the stages. */
151
+ export async function safeBuy(url, opts) {
152
+ if (opts?.maxPrice === undefined || opts.maxPrice === null)
153
+ throw new TypeError("safeBuy: opts.maxPrice is required (USD, e.g. 0.01 or \"$0.01\")");
154
+ const maxAtomic = usdToAtomic(opts.maxPrice);
155
+ const rpcUrls = {
156
+ base: opts.rpc?.base || process.env.BASE_RPC_URL || DEFAULT_RPC.base,
157
+ solana: opts.rpc?.solana || process.env.SOLANA_RPC_URL || DEFAULT_RPC.solana,
158
+ };
159
+ const base = (p) => ({
160
+ ok: false, outcome: "refused", stopped_at: null, reason: null, status: null, body: null, body_sha256: null, content_type: null,
161
+ tx: null, tx_source: null, network: null, amount: null, transferred: null, payer_key: null, receipt: null, refetched: false,
162
+ latency_ms: null, offer: null, ...p,
163
+ });
164
+ // The payer is built first so chain "any" only picks a chain we can sign for.
165
+ let payer;
166
+ try {
167
+ payer = opts.client ? payerFromClient(opts.client) : await makePayer(opts.signer ?? {}, maxAtomic, rpcUrls);
168
+ }
169
+ catch (e) {
170
+ const reason = `signer: ${errMsg(e)}`;
171
+ return base({ stopped_at: "pay", reason, stages: [{ name: "pay", ok: false, detail: reason }], probe: { status: "unavailable", doc: null } });
172
+ }
173
+ const prep = await prepare(url, opts, payer.chains ?? undefined);
174
+ const { stages } = prep;
175
+ if (!prep.ok || !prep.accept || !prep.offer || !prep.parsed) {
176
+ return base({ stopped_at: prep.stopped_at, reason: prep.reason, stages, probe: prep.probe, offer: prep.offer });
177
+ }
178
+ const a = prep.accept;
179
+ const offer = prep.offer;
180
+ const amount = BigInt(offer.amount);
181
+ const stopAtPay = (reason) => {
182
+ stages.push({ name: "pay", ok: false, detail: reason });
183
+ return base({ stopped_at: "pay", reason, stages, probe: prep.probe, offer, network: offer.network, amount: offer.amount });
184
+ };
185
+ // 4. pay: sign, hand the payer-side identifier to the caller, then send.
186
+ let signed;
187
+ try {
188
+ signed = await signFor(payer, prep.parsed, a);
189
+ }
190
+ catch (e) {
191
+ return stopAtPay(`could not sign: ${errMsg(e)}`);
192
+ }
193
+ const payer_key = signed.payer_key;
194
+ const record = {
195
+ ts: new Date().toISOString(), url: prep.request.url, host: prep.host, network: offer.network, chain: offer.chain,
196
+ amount: offer.amount, pay_to: offer.pay_to, payer_key,
197
+ };
198
+ try {
199
+ await opts.onAttempt?.(record);
200
+ }
201
+ catch (e) {
202
+ return { ...stopAtPay(`onAttempt failed, payment not sent: ${errMsg(e)}`), payer_key };
203
+ }
204
+ stages.push({
205
+ name: "pay",
206
+ ok: true,
207
+ detail: `signed ${offer.amount_usd} on ${offer.network} as ${payer_key.payer}${signed.eip712_filled ? " (EIP-712 domain filled: USD Coin / 2)" : ""}`,
208
+ });
209
+ // 5. reconcile: paid request, settlement header, then the chain.
210
+ const headers = { ...prep.request.init.headers, ...signed.headers };
211
+ let status = 0;
212
+ let body = new Uint8Array(0);
213
+ let contentType = null;
214
+ let getHeader = () => null;
215
+ let requestError = null;
216
+ const tReq = Date.now();
217
+ try {
218
+ const r = await fetch(prep.request.url, { ...prep.request.init, headers, signal: AbortSignal.timeout(opts.requestTimeoutMs ?? REQUEST_TIMEOUT_MS), redirect: "follow" });
219
+ status = r.status;
220
+ body = new Uint8Array(await r.arrayBuffer());
221
+ contentType = r.headers.get("content-type");
222
+ getHeader = (n) => r.headers.get(n);
223
+ }
224
+ catch (e) {
225
+ requestError = errMsg(e);
226
+ }
227
+ const latency_ms = Date.now() - tReq;
228
+ const settle = settleFrom(payer.http, getHeader);
229
+ const search = { rpcUrl: rpcUrls[offer.chain], timeoutMs: opts.findTimeoutMs ?? DEFAULT_FIND_TIMEOUT_MS, pollMs: opts.pollIntervalMs ?? 5000 };
230
+ const want = { payer: payer_key.payer, payTo: offer.pay_to, amount };
231
+ let tx = null;
232
+ let tx_source = null;
233
+ let check = null;
234
+ if (settle?.transaction) {
235
+ tx = settle.transaction;
236
+ tx_source = "settle_header";
237
+ check = offer.chain === "solana"
238
+ ? checkSolanaTransfer(await getSolanaTx(tx, search), want)
239
+ : checkBaseTransfer(await getBaseReceipt(tx, search), want);
240
+ }
241
+ else {
242
+ // No settlement tx in a header (even on a 200): look for our own payment on chain.
243
+ const found = payer_key.chain === "solana"
244
+ ? await findSolanaPayment(payer_key.payer, payer_key.payer_signature, search).then((f) => f && { tx: f.signature, check: checkSolanaTransfer(f.tx, want) })
245
+ : await findBasePayment(payer_key.payer, payer_key.authorization_nonce, search).then((f) => f && { tx: f.hash, check: checkBaseTransfer(f.receipt, want) });
246
+ if (found) {
247
+ tx = found.tx;
248
+ tx_source = "onchain_search";
249
+ check = found.check;
250
+ }
251
+ }
252
+ const onchain_found = !!check && check.transferred !== null;
253
+ const transferred = check?.transferred ?? null;
254
+ stages.push({
255
+ name: "reconcile",
256
+ ok: !!check?.ok,
257
+ detail: [
258
+ `HTTP ${status || `error (${requestError})`}`,
259
+ settle ? `settlement header ${settle.success ? "success" : `failure${settle.error ? ` (${settle.error})` : ""}`}${settle.transaction ? ` tx ${settle.transaction}` : ""}` : "no settlement header",
260
+ tx ? `tx ${tx} (${tx_source}): ${check?.reason}` : `no payment found on chain within ${search.timeoutMs} ms`,
261
+ transferred !== null ? `transferred ${atomicToUsd(transferred)}` : null,
262
+ ].filter(Boolean).join("; "),
263
+ });
264
+ // Id the seller's receipt carries: Solana = tx signature, Base = authorization nonce.
265
+ const paymentId = payer_key.chain === "base" ? payer_key.authorization_nonce : tx;
266
+ let delivered = status === 200 && body.length > 0;
267
+ let refetched = false;
268
+ let receipt = null;
269
+ // 6. refetch: paid but nothing came back, and the seller keeps receipts.
270
+ if (tx && !delivered && paymentId) {
271
+ const wk = await fetchReceiptKeys(prep.request.url);
272
+ if (!wk.advertised || !wk.wellKnown) {
273
+ stages.push({ name: "refetch", ok: false, detail: `seller publishes no receipt keys (${wk.error ?? "no keys"}); not refetched` });
274
+ }
275
+ else {
276
+ const init = prep.request.init;
277
+ const r = await refetchCore({
278
+ url: prep.request.url,
279
+ paymentHeaders: signed.headers,
280
+ paymentId,
281
+ wellKnown: wk.wellKnown,
282
+ headers: init.headers,
283
+ // Re-send with the original method and body (the vendored helper always uses GET).
284
+ fetch: (u, i) => fetch(u, { ...i, method: init.method, ...(init.body !== undefined ? { body: init.body } : {}), signal: AbortSignal.timeout(opts.requestTimeoutMs ?? REQUEST_TIMEOUT_MS) }),
285
+ });
286
+ if (r.ok) {
287
+ refetched = true;
288
+ delivered = r.body.length > 0;
289
+ status = r.status;
290
+ body = r.body;
291
+ // The helper returns the verified receipt; it is re-encoded (canonical JSON) as the header value.
292
+ receipt = { header: encodeReceipt(r.receipt), ok: true, reason: null };
293
+ stages.push({ name: "refetch", ok: true, detail: `refetched with ${paymentId}: HTTP 200, ${r.body.length} bytes, receipt verified (key ${r.receipt.key_id})` });
294
+ }
295
+ else {
296
+ stages.push({ name: "refetch", ok: false, detail: `refetch failed: ${r.reason}${r.status ? ` (HTTP ${r.status})` : ""}${r.detail ? `: ${r.detail}` : ""}` });
297
+ }
298
+ }
299
+ }
300
+ // 7. receipt on a first-delivery 200
301
+ if (!refetched && status === 200) {
302
+ const h = getHeader(RECEIPT_HEADER);
303
+ if (h) {
304
+ const wk = await fetchReceiptKeys(prep.request.url);
305
+ receipt = checkReceipt(h, body, wk.wellKnown, paymentId);
306
+ stages.push({ name: "receipt", ok: receipt.ok, detail: receipt.ok ? "X-Delivery-Receipt verified" : `X-Delivery-Receipt invalid: ${receipt.reason}` });
307
+ }
308
+ }
309
+ let outcome;
310
+ if (tx && transferred !== null && BigInt(transferred) > amount)
311
+ outcome = "overcharged";
312
+ else if (tx && transferred !== null && check && !check.ok && BigInt(transferred) !== amount)
313
+ outcome = "amount_mismatch";
314
+ else if (tx)
315
+ outcome = delivered ? "delivered" : "not_delivered";
316
+ else if (delivered)
317
+ outcome = "delivered_unsettled";
318
+ else
319
+ outcome = status === 0 ? "failed" : "rejected";
320
+ const result = base({
321
+ ok: outcome === "delivered" || outcome === "delivered_unsettled",
322
+ outcome,
323
+ reason: outcome === "rejected" ? `HTTP ${status}${x402Reason(status, getHeader, body)}` : outcome === "failed" ? requestError : outcome === "overcharged" || outcome === "amount_mismatch" ? check?.reason ?? null : null,
324
+ status: status || null,
325
+ body,
326
+ body_sha256: sha256(body),
327
+ content_type: contentType,
328
+ tx,
329
+ tx_source,
330
+ network: offer.network,
331
+ amount: offer.amount,
332
+ transferred,
333
+ payer_key,
334
+ receipt,
335
+ refetched,
336
+ latency_ms,
337
+ offer,
338
+ probe: prep.probe,
339
+ stages,
340
+ });
341
+ // 8. report (opt-in, anonymized; never fails the purchase)
342
+ if (opts.report === true) {
343
+ const rep = buildReport({
344
+ host: prep.host, url: prep.request.url, network: offer.network, amount: offer.amount, outcome,
345
+ http_status: status || null, latency_ms, settled_header: !!settle, onchain_found, receipt_ok: receipt ? receipt.ok : null, refetched,
346
+ });
347
+ const s = await sendReport(opts.probeUrl ?? DEFAULT_PROBE_URL, rep);
348
+ stages.push({ name: "report", ok: s.ok, detail: s.ok ? `reported (${s.detail})` : `report failed (${s.detail}); purchase unaffected` });
349
+ }
350
+ return result;
351
+ }
352
+ function x402Reason(status, getHeader, body) {
353
+ if (status !== 402)
354
+ return "";
355
+ const e = x402Error(parse402(getHeader, Buffer.from(body).toString("utf8")));
356
+ return e ? `: ${e}` : "";
357
+ }
package/dist/x402.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { type Chain, type ChainChoice } from "./constants.js";
2
+ export type Accept = {
3
+ scheme?: string;
4
+ network?: string;
5
+ chain: Chain | null;
6
+ /** Atomic units: v2 `amount`, v1 `maxAmountRequired`. */
7
+ amount?: string;
8
+ asset?: string;
9
+ payTo?: string;
10
+ /** v2: top-level resource.url; v1: per-accept resource. */
11
+ resource?: string;
12
+ maxTimeoutSeconds?: number;
13
+ extra?: Record<string, unknown>;
14
+ raw: Record<string, unknown>;
15
+ };
16
+ export type Parsed402 = {
17
+ spec_version: "v1" | "v2" | null;
18
+ /** Where the requirements were read from. */
19
+ source: "header" | "body" | null;
20
+ accepts: Accept[];
21
+ raw: unknown;
22
+ error?: string;
23
+ };
24
+ export declare function parse402(getHeader: (n: string) => string | null, bodyText: string): Parsed402;
25
+ export declare const isOfficialUsdc: (a: Accept) => boolean;
26
+ /** Probe's rule: the first Solana/Base `exact` accept in official USDC, else the first Solana/Base accept. */
27
+ export declare function primaryAccept(p: Parsed402): Accept | undefined;
28
+ /**
29
+ * The accept safe-buy pays: `exact`, official USDC, on the requested chain. For "any",
30
+ * the first payable accept in CHAIN_PREFERENCE order (Base, then Solana), limited to
31
+ * `usable` chains when the caller knows which chains it can sign for.
32
+ */
33
+ export declare function selectAccept(p: Parsed402, chain: ChainChoice, usable?: Chain[]): Accept | undefined;
34
+ /** The 402's own `error` text, if any. */
35
+ export declare function x402Error(p: Parsed402 | null): string | null;
36
+ export type InputRequirement = {
37
+ requires: boolean;
38
+ reason: string | null;
39
+ source: "bazaar" | "outputSchema" | null;
40
+ };
41
+ /**
42
+ * Does buying this endpoint with a bare GET need input? Read only from the 402's own
43
+ * schemas (bazaar extension, v1 outputSchema); no schema means "no known input".
44
+ */
45
+ export declare function inputRequirement(p: Parsed402 | null): InputRequirement;
package/dist/x402.js ADDED
@@ -0,0 +1,125 @@
1
+ // Parsing a 402 Payment Required response (x402 v1 or v2) and choosing what to pay.
2
+ // Pure functions. Ported from kato9292929/seller-probe src/x402.ts.
3
+ import { CHAIN_PREFERENCE, USDC, chainOf } from "./constants.js";
4
+ function b64json(s) {
5
+ return JSON.parse(Buffer.from(s.trim(), "base64").toString("utf8"));
6
+ }
7
+ const str = (v) => (typeof v === "string" && v.length ? v : typeof v === "number" ? String(v) : undefined);
8
+ export function parse402(getHeader, bodyText) {
9
+ let doc = null;
10
+ let source = null;
11
+ const h = getHeader("payment-required");
12
+ if (h) {
13
+ try {
14
+ doc = b64json(h);
15
+ source = "header";
16
+ }
17
+ catch {
18
+ return { spec_version: null, source: "header", accepts: [], raw: h.slice(0, 500), error: "PAYMENT-REQUIRED header is not base64 JSON" };
19
+ }
20
+ }
21
+ else {
22
+ try {
23
+ doc = JSON.parse(bodyText);
24
+ source = "body";
25
+ }
26
+ catch {
27
+ return { spec_version: null, source: null, accepts: [], raw: bodyText.slice(0, 500), error: "no PAYMENT-REQUIRED header and body is not JSON" };
28
+ }
29
+ }
30
+ const v = doc?.x402Version;
31
+ const spec_version = v === 2 ? "v2" : v === 1 ? "v1" : null;
32
+ const topResource = typeof doc?.resource === "object" ? doc?.resource?.url : undefined;
33
+ const accepts = (Array.isArray(doc?.accepts) ? doc.accepts : []).map((a) => ({
34
+ scheme: str(a?.scheme),
35
+ network: str(a?.network),
36
+ chain: chainOf(str(a?.network)),
37
+ amount: str(a?.amount ?? a?.maxAmountRequired),
38
+ asset: str(a?.asset),
39
+ payTo: str(a?.payTo),
40
+ resource: str(spec_version === "v2" ? (topResource ?? a?.resource ?? a?.extra?.resource) : (a?.resource ?? topResource)),
41
+ maxTimeoutSeconds: typeof a?.maxTimeoutSeconds === "number" ? a.maxTimeoutSeconds : undefined,
42
+ extra: a?.extra && typeof a.extra === "object" ? a.extra : undefined,
43
+ raw: a ?? {},
44
+ }));
45
+ return { spec_version, source, accepts, raw: doc, ...(spec_version ? {} : { error: `unknown x402Version ${JSON.stringify(v)}` }) };
46
+ }
47
+ export const isOfficialUsdc = (a) => !!a.chain && !!a.asset && (a.chain === "base" ? a.asset.toLowerCase() === USDC.base.toLowerCase() : a.asset === USDC.solana);
48
+ /** Probe's rule: the first Solana/Base `exact` accept in official USDC, else the first Solana/Base accept. */
49
+ export function primaryAccept(p) {
50
+ const onChain = p.accepts.filter((a) => a.chain);
51
+ return onChain.find((a) => a.scheme === "exact" && isOfficialUsdc(a)) ?? onChain[0];
52
+ }
53
+ /**
54
+ * The accept safe-buy pays: `exact`, official USDC, on the requested chain. For "any",
55
+ * the first payable accept in CHAIN_PREFERENCE order (Base, then Solana), limited to
56
+ * `usable` chains when the caller knows which chains it can sign for.
57
+ */
58
+ export function selectAccept(p, chain, usable) {
59
+ const order = chain === "any" ? CHAIN_PREFERENCE.filter((c) => !usable || usable.includes(c)) : [chain];
60
+ for (const c of order) {
61
+ const hit = p.accepts.find((a) => a.chain === c && a.scheme === "exact" && isOfficialUsdc(a));
62
+ if (hit)
63
+ return hit;
64
+ }
65
+ return undefined;
66
+ }
67
+ /** The 402's own `error` text, if any. */
68
+ export function x402Error(p) {
69
+ const e = p?.raw?.error;
70
+ return typeof e === "string" && e ? e.slice(0, 300) : null;
71
+ }
72
+ const READ_METHODS = ["GET", "HEAD"];
73
+ const nonEmpty = (v) => (Array.isArray(v) ? v.length > 0 : !!v && typeof v === "object" && Object.keys(v).length > 0);
74
+ /** JSON-Schema form: { properties: { method, body, queryParams }, required } (bazaar `schema.properties.input`). */
75
+ function fromInputSchema(s) {
76
+ const props = s?.properties ?? {};
77
+ const m = props.method;
78
+ const methods = Array.isArray(m?.enum) ? m.enum : typeof m?.const === "string" ? [m.const] : null;
79
+ if (methods && !methods.some((x) => typeof x === "string" && READ_METHODS.includes(x.toUpperCase())))
80
+ return `method ${methods.join("/")}`;
81
+ if (Array.isArray(props.body?.required) && props.body.required.length)
82
+ return `body: ${props.body.required.join(", ")}`;
83
+ if (Array.isArray(s?.required) && s.required.includes("body"))
84
+ return "body required";
85
+ if (Array.isArray(props.queryParams?.required) && props.queryParams.required.length)
86
+ return `query: ${props.queryParams.required.join(", ")}`;
87
+ return null;
88
+ }
89
+ /** Example/description form: { method, queryParams, body, bodyFields } (bazaar `info.input`, x402 v1 `outputSchema.input`). */
90
+ function fromInputInfo(i) {
91
+ if (!i || typeof i !== "object")
92
+ return null;
93
+ if (typeof i.method === "string" && !READ_METHODS.includes(i.method.toUpperCase()))
94
+ return `method ${i.method}`;
95
+ if (nonEmpty(i.body))
96
+ return "body";
97
+ const requiredNames = (fields) => fields && typeof fields === "object" && !Array.isArray(fields)
98
+ ? Object.entries(fields).filter(([, v]) => v?.required === true).map(([k]) => k)
99
+ : [];
100
+ const q = requiredNames(i.queryParams);
101
+ if (q.length)
102
+ return `query: ${q.join(", ")}`;
103
+ const b = requiredNames(i.bodyFields);
104
+ if (b.length)
105
+ return `body: ${b.join(", ")}`;
106
+ return null;
107
+ }
108
+ /**
109
+ * Does buying this endpoint with a bare GET need input? Read only from the 402's own
110
+ * schemas (bazaar extension, v1 outputSchema); no schema means "no known input".
111
+ */
112
+ export function inputRequirement(p) {
113
+ const doc = p?.raw;
114
+ const bazaar = doc?.extensions?.bazaar;
115
+ const r1 = fromInputSchema(bazaar?.schema?.properties?.input) ?? fromInputInfo(bazaar?.info?.input);
116
+ if (r1)
117
+ return { requires: true, reason: r1, source: "bazaar" };
118
+ for (const a of p?.accepts ?? []) {
119
+ const os = a.raw?.outputSchema;
120
+ const r2 = fromInputSchema(os?.input?.type === "object" ? os.input : null) ?? fromInputInfo(os?.input);
121
+ if (r2)
122
+ return { requires: true, reason: r2, source: "outputSchema" };
123
+ }
124
+ return { requires: false, reason: null, source: bazaar ? "bazaar" : null };
125
+ }
package/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
2
  "name": "@x402jp/safe-buy",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Buy from an x402 (HTTP 402, USDC) seller safely: Probe check, price cap, payment reconciliation, refetch and Delivery Receipt verification. Also an MCP server.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "main": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./mcp": {
18
+ "types": "./dist/mcp.d.ts",
19
+ "import": "./dist/mcp.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "bin": {
24
+ "x402-safe-buy-mcp": "dist/mcp-bin.js"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "keywords": [
32
+ "x402",
33
+ "usdc",
34
+ "mcp",
35
+ "base",
36
+ "solana",
37
+ "payments",
38
+ "agent"
39
+ ],
40
+ "scripts": {
41
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/mcp-bin.js",
42
+ "typecheck": "tsc --noEmit -p tsconfig.json",
43
+ "test": "node --test --import tsx test/*.test.ts",
44
+ "prepublishOnly": "npm run typecheck && npm test && npm run build",
45
+ "try": "tsx scripts/try.ts"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.20.5",
49
+ "tsx": "^4.23.15",
50
+ "typescript": "^5.9.3"
51
+ },
52
+ "dependencies": {
53
+ "@modelcontextprotocol/sdk": "^1.32.2",
54
+ "@solana/kit": "^8.6.0",
55
+ "@x402/core": "^2.29.0",
56
+ "@x402/evm": "^2.29.0",
57
+ "@x402/extensions": "^2.29.0",
58
+ "@x402/svm": "^2.29.0",
59
+ "@x402jp/delivery-receipt-verify": "^0.1.0",
60
+ "bs58": "^6.0.0",
61
+ "viem": "^2.57.14",
62
+ "zod": "^4.6.7"
63
+ },
64
+ "publishConfig": {
65
+ "access": "public"
66
+ },
67
+ "repository": {
68
+ "type": "git",
69
+ "url": "git+https://github.com/kato9292929/x402-safe-buy.git"
70
+ },
71
+ "homepage": "https://github.com/kato9292929/x402-safe-buy#readme"
72
+ }