Spaces:
Sleeping
Sleeping
feat: auto-refund for failed renders (blocked/empty pages) via Stellar USDC transfer
Browse files- .env.example +4 -2
- Dockerfile +1 -1
- refund.js +91 -0
- 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,
|