import { Keypair, TransactionBuilder, Operation, Asset, Horizon, Memo, } from "@stellar/stellar-sdk"; import { NETWORK_PASSPHRASE, HORIZON_URL, USDC_ISSUER } from "./network-config.js"; const USDC = new Asset("USDC", USDC_ISSUER); // Per-payer refund rate limit. Throws off attrition attacks (paying $0.001 // repeatedly to drain XLM via tx fees) without blocking legitimate users. // In-memory only — fine for single-instance HF Space; revisit if we scale. const REFUND_WINDOW_MS = 60 * 60 * 1000; // 1 hour const REFUND_MAX_PER_WINDOW = 10; const refundLog = new Map(); // payerAddress -> [timestamp, ...] function isRefundRateLimited(payerAddress) { const now = Date.now(); const cutoff = now - REFUND_WINDOW_MS; const history = (refundLog.get(payerAddress) || []).filter((t) => t > cutoff); if (history.length >= REFUND_MAX_PER_WINDOW) { refundLog.set(payerAddress, history); return true; } history.push(now); refundLog.set(payerAddress, history); return false; } // Periodic prune so the map doesn't grow unbounded. setInterval(() => { const cutoff = Date.now() - REFUND_WINDOW_MS; for (const [addr, history] of refundLog) { const kept = history.filter((t) => t > cutoff); if (kept.length === 0) refundLog.delete(addr); else refundLog.set(addr, kept); } }, REFUND_WINDOW_MS).unref(); const BLOCKED_PATTERNS = [ "you've been blocked", "you have been blocked", "blocked by network security", "access denied", "please log in", "log in to", "sign in to", "enable javascript", "just a moment...", "checking your browser", "performing security verification", "ray id:", "cloudflare", "attention required", "please verify you are a human", "this page doesn't exist", ]; export function isFailedRender(content, title) { if (!content || content.length < 100) return "empty_content"; const lower = content.toLowerCase(); const titleLower = (title || "").toLowerCase(); // Check title for block signals if (titleLower === "just a moment..." || titleLower === "blocked") { return "blocked_page"; } // Check content for block/login patterns for (const pattern of BLOCKED_PATTERNS) { // Only flag if the blocked pattern is a large portion of the content // (avoid false positives on pages that mention "log in" in a nav bar) if (lower.includes(pattern) && content.length < 500) { return "blocked_page"; } } return null; // render is good } export async function sendRefund(payerAddress, amount, memo) { const serverSecret = process.env.SERVER_SECRET; if (!serverSecret) { console.error("SERVER_SECRET not set — cannot send refund"); return null; } if (isRefundRateLimited(payerAddress)) { console.warn( `Refund rate-limited for ${payerAddress} (>${REFUND_MAX_PER_WINDOW}/hr) — skipping`, ); return null; } try { const server = new Horizon.Server(HORIZON_URL); const keypair = Keypair.fromSecret(serverSecret); const account = await server.loadAccount(keypair.publicKey()); const tx = new TransactionBuilder(account, { fee: "100", networkPassphrase: NETWORK_PASSPHRASE, }) .addOperation( Operation.payment({ destination: payerAddress, asset: USDC, amount: amount, }), ) .addMemo(new Memo("text", (memo || "RenderGate refund").slice(0, 28))) .setTimeout(30) .build(); tx.sign(keypair); const result = await server.submitTransaction(tx); console.log(`Refund sent to ${payerAddress}: ${result.hash}`); return result.hash; } catch (err) { console.error(`Refund failed for ${payerAddress}:`, err.message); return null; } }