rendergate-mainnet / refund.js
tantk's picture
initial deploy: hardened mainnet build
dcf8b6b verified
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;
}
}