tantk commited on
Commit
bc3d938
Β·
1 Parent(s): ad2e551

fix: move refund hook before x402 middleware, use res.on finish for post-settlement payer extraction

Browse files
Files changed (1) hide show
  1. server.js +43 -57
server.js CHANGED
@@ -3,12 +3,12 @@ import express from "express";
3
  import { paymentMiddlewareFromConfig } from "@x402/express";
4
  import { HTTPFacilitatorClient } from "@x402/core/server";
5
  import { ExactStellarScheme } from "@x402/stellar/exact/server";
6
- import { Transaction, Networks } from "@stellar/stellar-sdk";
7
  import { renderUrl, closeBrowser } from "./renderer.js";
8
  import { isFailedRender, sendRefund } from "./refund.js";
9
 
10
  const PORT = process.env.PORT || 3001;
11
  const PRICE = "$0.001";
 
12
  const NETWORK = "stellar:testnet";
13
  const FACILITATOR_URL = "https://www.x402.org/facilitator";
14
  const PAY_TO = process.env.PAY_TO;
@@ -24,7 +24,6 @@ function isAllowedUrl(urlStr) {
24
  if (!["http:", "https:"].includes(parsed.protocol)) return false;
25
  const blocked = ["localhost", "127.0.0.1", "0.0.0.0", "[::1]"];
26
  if (blocked.includes(parsed.hostname)) return false;
27
- // Block private/link-local IP ranges
28
  const parts = parsed.hostname.split(".");
29
  if (parts[0] === "10") return false;
30
  if (parts[0] === "172" && +parts[1] >= 16 && +parts[1] <= 31) return false;
@@ -36,6 +35,18 @@ function isAllowedUrl(urlStr) {
36
  }
37
  }
38
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  const app = express();
40
 
41
  // Info endpoint (free)
@@ -71,6 +82,27 @@ app.use("/render", (req, res, next) => {
71
  next();
72
  });
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  // x402 payment middleware β€” protects /render
75
  app.use(
76
  paymentMiddlewareFromConfig(
@@ -91,40 +123,6 @@ app.use(
91
  ),
92
  );
93
 
94
- // Extract payer address from the incoming payment-signature request header
95
- function getPayerAddress(req) {
96
- try {
97
- const header =
98
- req.get("payment-signature") || req.get("x-payment") || req.get("PAYMENT-SIGNATURE");
99
- if (!header) {
100
- console.log("No payment header found");
101
- return null;
102
- }
103
- const decoded = JSON.parse(Buffer.from(header, "base64").toString());
104
- const txXdr = decoded?.payload?.transaction;
105
- if (!txXdr) {
106
- console.log("No transaction in payload");
107
- return null;
108
- }
109
- // The transaction XDR source account is the payer
110
- const tx = new Transaction(txXdr, Networks.TESTNET);
111
- console.log(`Payer extracted: ${tx.source}`);
112
- return tx.source;
113
- } catch (err) {
114
- console.error("Payer extraction failed:", err.message);
115
- // Fallback: try extracting from the x402 settlement response on res
116
- try {
117
- const respHeader = req.res?.getHeader?.("PAYMENT-RESPONSE");
118
- if (respHeader) {
119
- const resp = JSON.parse(Buffer.from(respHeader, "base64").toString());
120
- console.log(`Payer from response: ${resp.payer}`);
121
- return resp.payer;
122
- }
123
- } catch { /* ignore */ }
124
- return null;
125
- }
126
- }
127
-
128
  // Protected render endpoint
129
  app.get("/render", async (req, res) => {
130
  const decoded = req.decodedUrl;
@@ -135,22 +133,19 @@ app.get("/render", async (req, res) => {
135
  const result = await renderUrl(decoded);
136
  const elapsed = Date.now() - start;
137
 
138
- // Check if the render actually succeeded
139
  const failReason = isFailedRender(result.content, result.title);
140
- const payerAddress = getPayerAddress(req);
141
- if (failReason && payerAddress) {
142
- console.log(`Bad render (${failReason}) for ${decoded} β€” refunding ${payerAddress}`);
143
- const refundHash = await sendRefund(payerAddress, "0.001", `refund:${failReason}`);
144
 
145
  return res.json({
146
  ...result,
147
  renderTimeMs: elapsed,
148
  payment: { price: PRICE, network: NETWORK },
149
- refund: {
150
  reason: failReason,
151
- transaction: refundHash,
152
- amount: "0.001 USDC",
153
- message: "Page was blocked or empty β€” payment refunded",
154
  },
155
  });
156
  }
@@ -165,18 +160,9 @@ app.get("/render", async (req, res) => {
165
  if (err.message.includes("Too many concurrent")) {
166
  return res.status(503).json({ error: err.message });
167
  }
168
- // Attempt refund on crash β€” agent paid but got nothing
169
- const payerAddress = getPayerAddress(req);
170
- if (payerAddress) {
171
- const refundHash = await sendRefund(payerAddress, "0.001", "refund:render_crash");
172
- return res.status(500).json({
173
- error: "Render failed",
174
- message: err.message,
175
- refund: refundHash
176
- ? { transaction: refundHash, amount: "0.001 USDC", reason: "render_crash" }
177
- : { error: "Refund failed β€” contact support" },
178
- });
179
- }
180
  res.status(500).json({ error: "Render failed", message: err.message });
181
  }
182
  });
 
3
  import { paymentMiddlewareFromConfig } from "@x402/express";
4
  import { HTTPFacilitatorClient } from "@x402/core/server";
5
  import { ExactStellarScheme } from "@x402/stellar/exact/server";
 
6
  import { renderUrl, closeBrowser } from "./renderer.js";
7
  import { isFailedRender, sendRefund } from "./refund.js";
8
 
9
  const PORT = process.env.PORT || 3001;
10
  const PRICE = "$0.001";
11
+ const REFUND_AMOUNT = "0.001";
12
  const NETWORK = "stellar:testnet";
13
  const FACILITATOR_URL = "https://www.x402.org/facilitator";
14
  const PAY_TO = process.env.PAY_TO;
 
24
  if (!["http:", "https:"].includes(parsed.protocol)) return false;
25
  const blocked = ["localhost", "127.0.0.1", "0.0.0.0", "[::1]"];
26
  if (blocked.includes(parsed.hostname)) return false;
 
27
  const parts = parsed.hostname.split(".");
28
  if (parts[0] === "10") return false;
29
  if (parts[0] === "172" && +parts[1] >= 16 && +parts[1] <= 31) return false;
 
35
  }
36
  }
37
 
38
+ // Extract payer from the PAYMENT-RESPONSE header (set by x402 after settlement)
39
+ function getPayerFromSettlement(res) {
40
+ try {
41
+ const header = res.getHeader("PAYMENT-RESPONSE");
42
+ if (!header) return null;
43
+ const decoded = JSON.parse(Buffer.from(header, "base64").toString());
44
+ return decoded?.payer || null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
  const app = express();
51
 
52
  // Info endpoint (free)
 
82
  next();
83
  });
84
 
85
+ // Post-settlement refund hook β€” attach finish listener before x402 middleware
86
+ app.use("/render", (req, res, next) => {
87
+ res.on("finish", async () => {
88
+ if (!res.locals.needsRefund) return;
89
+
90
+ const payer = getPayerFromSettlement(res);
91
+ if (!payer) {
92
+ console.log("Refund needed but could not extract payer address");
93
+ return;
94
+ }
95
+
96
+ const reason = res.locals.failReason || "unknown";
97
+ console.log(`Issuing refund to ${payer} β€” reason: ${reason}`);
98
+ const hash = await sendRefund(payer, REFUND_AMOUNT, `refund:${reason}`);
99
+ if (hash) {
100
+ console.log(`Refund sent: ${hash}`);
101
+ }
102
+ });
103
+ next();
104
+ });
105
+
106
  // x402 payment middleware β€” protects /render
107
  app.use(
108
  paymentMiddlewareFromConfig(
 
123
  ),
124
  );
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  // Protected render endpoint
127
  app.get("/render", async (req, res) => {
128
  const decoded = req.decodedUrl;
 
133
  const result = await renderUrl(decoded);
134
  const elapsed = Date.now() - start;
135
 
 
136
  const failReason = isFailedRender(result.content, result.title);
137
+ if (failReason) {
138
+ // Tag the response so post-settlement hook knows to refund
139
+ res.locals.needsRefund = true;
140
+ res.locals.failReason = failReason;
141
 
142
  return res.json({
143
  ...result,
144
  renderTimeMs: elapsed,
145
  payment: { price: PRICE, network: NETWORK },
146
+ renderFailed: {
147
  reason: failReason,
148
+ message: "Page was blocked or empty β€” refund will be issued",
 
 
149
  },
150
  });
151
  }
 
160
  if (err.message.includes("Too many concurrent")) {
161
  return res.status(503).json({ error: err.message });
162
  }
163
+ // Tag for refund on crash too
164
+ res.locals.needsRefund = true;
165
+ res.locals.failReason = "render_crash";
 
 
 
 
 
 
 
 
 
166
  res.status(500).json({ error: "Render failed", message: err.message });
167
  }
168
  });