tantk commited on
Commit
8c7f41d
·
1 Parent(s): 65b4395

feat: auto-refund for failed renders (blocked/empty pages) via Stellar USDC transfer

Browse files
Files changed (4) hide show
  1. .env.example +4 -2
  2. Dockerfile +1 -1
  3. refund.js +91 -0
  4. server.js +33 -0
.env.example CHANGED
@@ -1,4 +1,6 @@
1
- # Stellar testnet secret key (starts with S...)
2
- STELLAR_PRIVATE_KEY=
3
  # Stellar testnet public key to receive payments (starts with G...)
4
  PAY_TO=
 
 
 
 
 
 
 
1
  # Stellar testnet public key to receive payments (starts with G...)
2
  PAY_TO=
3
+ # Stellar testnet secret key for the server — used to send refunds (starts with S...)
4
+ SERVER_SECRET=
5
+ # Client's Stellar testnet secret key — for demo-client.js only (starts with S...)
6
+ STELLAR_PRIVATE_KEY=
Dockerfile CHANGED
@@ -16,7 +16,7 @@ RUN npm ci --omit=dev
16
  # Install Playwright Chromium
17
  RUN npx playwright install chromium
18
 
19
- COPY server.js renderer.js ./
20
 
21
  USER node
22
  ENV PORT=7860
 
16
  # Install Playwright Chromium
17
  RUN npx playwright install chromium
18
 
19
+ COPY server.js renderer.js refund.js ./
20
 
21
  USER node
22
  ENV PORT=7860
refund.js ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Keypair,
3
+ Networks,
4
+ TransactionBuilder,
5
+ Operation,
6
+ Asset,
7
+ Horizon,
8
+ } from "@stellar/stellar-sdk";
9
+
10
+ const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
11
+ const USDC = new Asset("USDC", USDC_ISSUER);
12
+ const HORIZON_URL = "https://horizon-testnet.stellar.org";
13
+
14
+ const BLOCKED_PATTERNS = [
15
+ "you've been blocked",
16
+ "you have been blocked",
17
+ "blocked by network security",
18
+ "access denied",
19
+ "please log in",
20
+ "log in to",
21
+ "sign in to",
22
+ "enable javascript",
23
+ "just a moment...",
24
+ "checking your browser",
25
+ "performing security verification",
26
+ "ray id:",
27
+ "cloudflare",
28
+ "attention required",
29
+ "please verify you are a human",
30
+ "this page doesn't exist",
31
+ ];
32
+
33
+ export function isFailedRender(content, title) {
34
+ if (!content || content.length < 100) return "empty_content";
35
+
36
+ const lower = content.toLowerCase();
37
+ const titleLower = (title || "").toLowerCase();
38
+
39
+ // Check title for block signals
40
+ if (titleLower === "just a moment..." || titleLower === "blocked") {
41
+ return "blocked_page";
42
+ }
43
+
44
+ // Check content for block/login patterns
45
+ for (const pattern of BLOCKED_PATTERNS) {
46
+ // Only flag if the blocked pattern is a large portion of the content
47
+ // (avoid false positives on pages that mention "log in" in a nav bar)
48
+ if (lower.includes(pattern) && content.length < 500) {
49
+ return "blocked_page";
50
+ }
51
+ }
52
+
53
+ return null; // render is good
54
+ }
55
+
56
+ export async function sendRefund(payerAddress, amount, memo) {
57
+ const serverSecret = process.env.SERVER_SECRET;
58
+ if (!serverSecret) {
59
+ console.error("SERVER_SECRET not set — cannot send refund");
60
+ return null;
61
+ }
62
+
63
+ try {
64
+ const server = new Horizon.Server(HORIZON_URL);
65
+ const keypair = Keypair.fromSecret(serverSecret);
66
+ const account = await server.loadAccount(keypair.publicKey());
67
+
68
+ const tx = new TransactionBuilder(account, {
69
+ fee: "100",
70
+ networkPassphrase: Networks.TESTNET,
71
+ })
72
+ .addOperation(
73
+ Operation.payment({
74
+ destination: payerAddress,
75
+ asset: USDC,
76
+ amount: amount,
77
+ }),
78
+ )
79
+ .addMemo(TransactionBuilder.memo("text", memo || "RenderGate refund"))
80
+ .setTimeout(30)
81
+ .build();
82
+
83
+ tx.sign(keypair);
84
+ const result = await server.submitTransaction(tx);
85
+ console.log(`Refund sent to ${payerAddress}: ${result.hash}`);
86
+ return result.hash;
87
+ } catch (err) {
88
+ console.error(`Refund failed for ${payerAddress}:`, err.message);
89
+ return null;
90
+ }
91
+ }
server.js CHANGED
@@ -4,6 +4,7 @@ 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
 
8
  const PORT = process.env.PORT || 3001;
9
  const PRICE = "$0.001";
@@ -89,6 +90,18 @@ app.use(
89
  ),
90
  );
91
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  // Protected render endpoint
93
  app.get("/render", async (req, res) => {
94
  const decoded = req.decodedUrl;
@@ -99,6 +112,26 @@ app.get("/render", async (req, res) => {
99
  const result = await renderUrl(decoded);
100
  const elapsed = Date.now() - start;
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  res.json({
103
  ...result,
104
  renderTimeMs: elapsed,
 
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";
 
90
  ),
91
  );
92
 
93
+ // Extract payer address from the PAYMENT-RESPONSE header set by x402 middleware
94
+ function getPayerFromResponse(res) {
95
+ try {
96
+ const header = res.getHeader("PAYMENT-RESPONSE");
97
+ if (!header) return null;
98
+ const decoded = JSON.parse(Buffer.from(header, "base64").toString());
99
+ return decoded?.payer || null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
  // Protected render endpoint
106
  app.get("/render", async (req, res) => {
107
  const decoded = req.decodedUrl;
 
112
  const result = await renderUrl(decoded);
113
  const elapsed = Date.now() - start;
114
 
115
+ // Check if the render actually succeeded
116
+ const failReason = isFailedRender(result.content, result.title);
117
+ const payerAddress = getPayerFromResponse(res);
118
+ if (failReason && payerAddress) {
119
+ console.log(`Bad render (${failReason}) for ${decoded} — refunding ${payerAddress}`);
120
+ const refundHash = await sendRefund(payerAddress, "0.001", `refund:${failReason}`);
121
+
122
+ return res.json({
123
+ ...result,
124
+ renderTimeMs: elapsed,
125
+ payment: { price: PRICE, network: NETWORK },
126
+ refund: {
127
+ reason: failReason,
128
+ transaction: refundHash,
129
+ amount: "0.001 USDC",
130
+ message: "Page was blocked or empty — payment refunded",
131
+ },
132
+ });
133
+ }
134
+
135
  res.json({
136
  ...result,
137
  renderTimeMs: elapsed,