import "dotenv/config"; import express from "express"; import { paymentMiddlewareFromConfig } from "@x402/express"; import { HTTPFacilitatorClient } from "@x402/core/server"; import { ExactStellarScheme } from "@x402/stellar/exact/server"; import { Transaction, Address } from "@stellar/stellar-sdk"; import { renderUrl, closeBrowser } from "./renderer.js"; import { isFailedRender, sendRefund } from "./refund.js"; import { NETWORK, NETWORK_PASSPHRASE, FACILITATOR_URL, FACILITATOR_API_KEY, } from "./network-config.js"; import { isAllowedUrl } from "./url-policy.js"; const PORT = process.env.PORT || 3001; const PRICE = "$0.001"; const REFUND_AMOUNT = "0.001"; const PAY_TO = process.env.PAY_TO; if (!PAY_TO) { console.error("ERROR: PAY_TO not set in .env"); process.exit(1); } // Extract payer from the payment-signature request header. // x402 payments are exactly one InvokeHostFunction op calling USDC.transfer // with one auth entry whose address is the payer. Reject anything that doesn't // match this exact shape — we don't want to refund ambiguous transactions. function getPayerAddress(req) { try { const header = req.get("payment-signature") || req.get("x-payment"); if (!header) return null; const decoded = JSON.parse(Buffer.from(header, "base64").toString()); const txXdr = decoded?.payload?.transaction; if (!txXdr) return null; const tx = new Transaction(txXdr, NETWORK_PASSPHRASE); if (tx.operations.length !== 1) return null; const op = tx.operations[0]; if (op.type !== "invokeHostFunction") return null; if (!op.auth || op.auth.length !== 1) return null; const creds = op.auth[0].credentials(); if (creds.switch().name !== "sorobanCredentialsAddress") return null; return Address.fromScAddress(creds.address().address()).toString(); } catch { return null; } } const app = express(); // Info endpoint (free) app.get("/", (_, res) => res.json({ service: "RenderGate", description: "Pay-per-render headless browser API on Stellar x402", price: PRICE, network: NETWORK, usage: "GET /render?url=", }), ); // Health check (free) app.get("/health", (_, res) => res.json({ status: "ok" })); // URL validation — runs before payment to reject SSRF attempts early app.use("/render", (req, res, next) => { const url = req.query.url; if (!url) return res.status(400).json({ error: "Missing ?url= parameter" }); let decoded; try { decoded = decodeURIComponent(url); } catch { return res.status(400).json({ error: "Malformed URL encoding" }); } if (!isAllowedUrl(decoded)) { return res .status(400) .json({ error: "URL not allowed — only public http/https URLs" }); } req.decodedUrl = decoded; next(); }); // x402 payment middleware — protects /render app.use( paymentMiddlewareFromConfig( { "GET /render": { accepts: { scheme: "exact", price: PRICE, network: NETWORK, payTo: PAY_TO, maxTimeoutSeconds: 60, }, description: "Render a JS-heavy webpage and return extracted content", }, }, new HTTPFacilitatorClient({ url: FACILITATOR_URL, ...(FACILITATOR_API_KEY && { // The lib calls this for each operation ("verify"/"settle"/"supported") // and indexes into the returned object by op name. createAuthHeaders: async () => { const auth = { Authorization: `Bearer ${FACILITATOR_API_KEY}` }; return { verify: auth, settle: auth, supported: auth }; }, }), }), [{ network: NETWORK, server: new ExactStellarScheme() }], ), ); // Protected render endpoint app.get("/render", async (req, res) => { const decoded = req.decodedUrl; try { console.log(`Rendering: ${decoded}`); const start = Date.now(); const result = await renderUrl(decoded); const elapsed = Date.now() - start; const failReason = isFailedRender(result.content, result.title); if (failReason) { const payerAddress = getPayerAddress(req); let refund = null; if (payerAddress) { console.log(`Bad render (${failReason}) for ${decoded} — refunding ${payerAddress}`); const refundHash = await sendRefund(payerAddress, REFUND_AMOUNT, `refund:${failReason}`); refund = refundHash ? { transaction: refundHash, amount: `${REFUND_AMOUNT} USDC`, reason: failReason } : { error: "Refund failed — contact support", reason: failReason }; } return res.json({ ...result, renderTimeMs: elapsed, payment: { price: PRICE, network: NETWORK }, refund, }); } res.json({ ...result, renderTimeMs: elapsed, payment: { price: PRICE, network: NETWORK }, }); } catch (err) { console.error(`Render failed for ${decoded}:`, err.message); if (err.message.includes("Too many concurrent")) { return res.status(503).json({ error: err.message }); } // Attempt refund on crash const payerAddress = getPayerAddress(req); let refund = null; if (payerAddress) { const refundHash = await sendRefund(payerAddress, REFUND_AMOUNT, "refund:render_crash"); refund = refundHash ? { transaction: refundHash, amount: `${REFUND_AMOUNT} USDC`, reason: "render_crash" } : { error: "Refund failed — contact support" }; } res.status(500).json({ error: "Render failed", message: err.message, refund }); } }); const server = app.listen(Number(PORT), () => { console.log(`RenderGate listening on http://localhost:${PORT}`); console.log(` Pay ${PRICE} USDC on ${NETWORK} per render`); console.log(` Payments go to ${PAY_TO}`); }); for (const sig of ["SIGTERM", "SIGINT"]) { process.on(sig, async () => { console.log(`${sig} received, shutting down...`); server.close(); await closeBrowser(); process.exit(0); }); }