From f338e3b54fdcc0bd4a19c51a54cf96bd6f086092 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 5 Sep 2026 19:36:24 +0000 Subject: [PATCH] Pay x402 gateways when an audit meets one (@profullstack/x402-client) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smartFetch's clearnet path — the fetch every audit reads the customer's site with — now goes through a paidFetch: with X402_PRIVATE_KEY set, a 402 carrying an x402 offer is signed with the shared crawler wallet, the pass is filed by origin and presented on every later request, so auditing a gated site reads the site rather than its sales page. Without the key it is the global fetch, unchanged. Capped at five dollars a payment; the key is read through a non-literal accessor because Next inlines process.env. Deliberately not wired: link-status probes and uptime pings. Both touch many third-party domains per run, and paying a dollar to learn a link's status would be waste. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NSGS7Sy3QmEgTNZDg4eq7g --- lib/onion.ts | 5 ++++- lib/paid-fetch.ts | 26 ++++++++++++++++++++++++++ package-lock.json | 44 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 lib/paid-fetch.ts diff --git a/lib/onion.ts b/lib/onion.ts index 168be914..1eaeff73 100644 --- a/lib/onion.ts +++ b/lib/onion.ts @@ -2,6 +2,7 @@ import tls from "node:tls"; import { Agent, type buildConnector, type Dispatcher } from "undici"; import { SocksClient } from "socks"; import { env } from "./env"; +import { paidFetch } from "./paid-fetch"; // Tor routing for .onion targets. Onion addresses don't resolve via DNS, so any // fetch to one must go through a Tor SOCKS5 proxy (socks5h — the proxy resolves @@ -58,7 +59,9 @@ function torDispatcher(): Dispatcher { // Fetch that transparently routes .onion targets through Tor and everything // else through the normal stack. Callers pass their usual RequestInit. export async function smartFetch(url: string, init?: RequestInit): Promise { - if (!isOnion(url)) return fetch(url, init); + // The clearnet path pays an x402 gateway when a key is configured; Tor + // keeps the plain fetch, since the pass and the proof are clearnet things. + if (!isOnion(url)) return paidFetch(url, init); if (!torConfigured()) { throw new Error( "This is a .onion address; set TOR_SOCKS_URL and run a Tor proxy to reach it.", diff --git a/lib/paid-fetch.ts b/lib/paid-fetch.ts new file mode 100644 index 00000000..e5900748 --- /dev/null +++ b/lib/paid-fetch.ts @@ -0,0 +1,26 @@ +/** + * The fetch the audit reaches a customer's site with — paying when asked. + * + * A site behind an x402 gateway answers a crawler with 402 and an offer. With + * `X402_PRIVATE_KEY` set, the client signs the offer with the shared crawler + * wallet, buys the pass, files it by origin and presents it on every later + * request to that site, so an audit of a gated site reads the site rather + * than the sales page. Without the key this is the global fetch, unchanged. + * + * Only the page fetch goes through here. Link-status probes and uptime pings + * touch many third-party domains per run, and paying a dollar to learn a + * link's status would be waste; those stay on the plain fetch. + * + * Capped at five dollars a payment. The key is read through a non-literal + * accessor because Next inlines `process.env.NAME` at build time. + */ + +import { createClient } from "@profullstack/x402-client"; + +const key = process.env[["X402", "PRIVATE_KEY"].join("_")]; + +export const x402 = key ? createClient({ key, maxUsd: 5 }) : null; + +export const paidFetch: typeof fetch = x402 + ? (input, init) => x402.fetch(input, init) + : (input, init) => fetch(input, init); diff --git a/package-lock.json b/package-lock.json index d148bb4e..8491f035 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", "@profullstack/referrals": "^0.1.0", "@profullstack/stack": "^0.1.3", + "@profullstack/x402-client": "^0.2.0", "@supabase/ssr": "^0.10.3", "@supabase/supabase-js": "^2.105.4", "@types/nodemailer": "^8.0.0", @@ -1552,6 +1553,33 @@ "node": ">= 10" } }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodable/entities": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", @@ -1686,6 +1714,22 @@ } } }, + "node_modules/@profullstack/x402-client": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@profullstack/x402-client/-/x402-client-0.2.0.tgz", + "integrity": "sha512-kJGAomE9Tf/ruhsCStaKVXpjklCbOh2UFcMGTIfTqcWaSsW2NFWQRaTxJBLvc6r7Iy2+fpAWzfJnerolI6IrRQ==", + "license": "MIT", + "dependencies": { + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0" + }, + "bin": { + "x402": "bin/x402.js" + }, + "engines": { + "node": ">=20.19" + } + }, "node_modules/@react-email/render": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz", diff --git a/package.json b/package.json index 6ca918be..d20c9418 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@profullstack/autoblog": "github:profullstack/autoblog#75e54af", "@profullstack/referrals": "^0.1.0", "@profullstack/stack": "^0.1.3", + "@profullstack/x402-client": "^0.2.0", "@supabase/ssr": "^0.10.3", "@supabase/supabase-js": "^2.105.4", "@types/nodemailer": "^8.0.0",