import express, { Request, Response } from "express"; import dotenv from "dotenv"; import { JsonRpcProvider, Wallet, parseUnits } from "ethers"; dotenv.config(); // ---------- Configuration ---------- const PORT = Number(process.env.PORT ?? 4001); // RPC Talero (node local derrière rpc.talero.net) const TALERO_RPC_URL = process.env.TALERO_RPC_URL ?? "http://127.0.0.1:8547/rpc"; // Hot wallet du faucet (adresse dédiée, refill depuis la fee de pool) const FAUCET_PRIVATE_KEY = process.env.FAUCET_PRIVATE_KEY; const FAUCET_ADDRESS_RAW = process.env.FAUCET_ADDRESS ?? ""; // Décimales TLRO : 1 TLRO = 10^12 "atoms" (d’après la doc) const FAUCET_DECIMALS = parseInt(process.env.FAUCET_DECIMALS ?? "12", 10); // Montant envoyé par requête (en TLRO, lisible humain) const FAUCET_AMOUNT_TLRO = process.env.FAUCET_AMOUNT_TLRO ?? "5"; // Solde minimum laissé sur le faucet avant de refuser (en TLRO) const FAUCET_MIN_BALANCE_TLRO = process.env.FAUCET_MIN_BALANCE_TLRO ?? "50"; // Fenêtre de rate limit (par défaut 24h) const WINDOW_MS = Number( process.env.FAUCET_WINDOW_MS ?? 24 * 60 * 60 * 1000 ); // Limites const MAX_PER_ADDRESS = Number(process.env.FAUCET_MAX_PER_ADDRESS_DAY ?? 1); const MAX_PER_IP = Number(process.env.FAUCET_MAX_PER_IP_DAY ?? 2); // Captcha const CAPTCHA_PROVIDER = process.env.FAUCET_CAPTCHA_PROVIDER ?? "disabled"; // "hcaptcha" | "recaptcha" | "disabled" const CAPTCHA_SECRET = process.env.FAUCET_CAPTCHA_SECRET ?? ""; // Pré-calcul des montants en unités de base (atoms) const AMOUNT_UNITS = parseUnits(FAUCET_AMOUNT_TLRO, FAUCET_DECIMALS); const MIN_BALANCE_UNITS = parseUnits(FAUCET_MIN_BALANCE_TLRO, FAUCET_DECIMALS); const REQUIRED_BALANCE_UNITS = AMOUNT_UNITS + MIN_BALANCE_UNITS; // Vérification config de base if (!FAUCET_PRIVATE_KEY || !FAUCET_ADDRESS_RAW) { // On préfère fail fast si la config n’est pas présente console.error( "[FAUCET] Missing FAUCET_PRIVATE_KEY or FAUCET_ADDRESS env variables." ); process.exit(1); } // Normalisation de l’adresse faucet (0x… sans tlro:) const FAUCET_ADDRESS = normalizeAddress(FAUCET_ADDRESS_RAW); if (!FAUCET_ADDRESS) { console.error( "[FAUCET] FAUCET_ADDRESS is invalid. Expected tlro:0x… or 0x… format." ); process.exit(1); } // Provider & wallet ethers (Node RPC) const provider = new JsonRpcProvider(TALERO_RPC_URL); const faucetWallet = new Wallet(FAUCET_PRIVATE_KEY, provider); console.log("[FAUCET] Using faucet address:", FAUCET_ADDRESS); console.log("[FAUCET] RPC URL:", TALERO_RPC_URL); // ---------- Types ---------- interface FaucetRequestBody { address?: string; captchaToken?: string; } interface FaucetSuccessResponse { status: "ok"; txHash: string; amount: string; unit: "TLRO"; nextEligibleAt: string; } type FaucetErrorCode = | "invalid_request" | "invalid_captcha" | "rate_limited" | "faucet_empty" | "internal_error"; interface FaucetErrorResponse { status: "error"; error: FaucetErrorCode; message: string; nextEligibleAt?: string; } // ---------- Utilitaires ---------- function normalizeAddress(input: string): string | null { if (!input) return null; let addr = input.trim(); // Support du format tlro:0x… (ce que voient les users) const prefix = "tlro:"; if (addr.toLowerCase().startsWith(prefix)) { addr = addr.slice(prefix.length); } // Doit être un 0x + 40 hex chars if (!/^0x[0-9a-fA-F]{40}$/.test(addr)) { return null; } return "0x" + addr.slice(2).toLowerCase(); } function getClientIp(req: Request): string { const xfwd = req.headers["x-forwarded-for"]; if (typeof xfwd === "string" && xfwd.length > 0) { return xfwd.split(",")[0].trim(); } if (Array.isArray(xfwd) && xfwd.length > 0) { return xfwd[0].trim(); } return req.socket.remoteAddress ?? "unknown"; } // Rate limiting simple en mémoire (clé -> compteur + expiration) interface RateEntry { count: number; resetAt: number; // timestamp ms } const rateStore = new Map(); function checkAndConsumeRateLimit( key: string, limit: number, now: number, windowMs: number ): { allowed: boolean; nextAllowedAt: number } { const current = rateStore.get(key); if (!current || now > current.resetAt) { // Première requête dans une nouvelle fenêtre const resetAt = now + windowMs; rateStore.set(key, { count: 1, resetAt }); return { allowed: true, nextAllowedAt: resetAt }; } if (current.count >= limit) { // Limite atteinte return { allowed: false, nextAllowedAt: current.resetAt }; } current.count += 1; rateStore.set(key, current); return { allowed: true, nextAllowedAt: current.resetAt }; } // Vérification captcha (hCaptcha / reCAPTCHA) – peut être désactivée async function verifyCaptcha( token: string | undefined, remoteIp: string | undefined ): Promise { if (CAPTCHA_PROVIDER === "disabled" || !CAPTCHA_SECRET) { // Captcha désactivé (dev / test) return true; } if (!token || token.trim() === "") { return false; } try { if (CAPTCHA_PROVIDER === "hcaptcha") { const params = new URLSearchParams(); params.set("secret", CAPTCHA_SECRET); params.set("response", token); if (remoteIp) params.set("remoteip", remoteIp); const resp = await fetch("https://hcaptcha.com/siteverify", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString(), }); if (!resp.ok) return false; const data = (await resp.json()) as { success?: boolean }; return !!data.success; } if (CAPTCHA_PROVIDER === "recaptcha") { const params = new URLSearchParams(); params.set("secret", CAPTCHA_SECRET); params.set("response", token); if (remoteIp) params.set("remoteip", remoteIp); const resp = await fetch( "https://www.google.com/recaptcha/api/siteverify", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString(), } ); if (!resp.ok) return false; const data = (await resp.json()) as { success?: boolean }; return !!data.success; } // Provider inconnu → on échoue par défaut return false; } catch (err) { console.error("[FAUCET] Captcha verification error:", err); return false; } } // ---------- App Express ---------- const app = express(); app.use(express.json()); // Endpoint simple de santé (optionnel mais sympa pour check nginx) app.get("/api/health", async (_req: Request, res: Response) => { try { const balance = await faucetWallet.getBalance(); const balanceTlro = Number( balance / BigInt(10 ** FAUCET_DECIMALS) ).toString(); res.json({ status: "ok", service: "talero-faucet", faucetAddress: FAUCET_ADDRESS, balanceTlro, }); } catch (err) { console.error("[FAUCET] /api/health error:", err); res.status(500).json({ status: "error", service: "talero-faucet", }); } }); // ---------- Endpoint principal : POST /api/faucet ---------- app.post( "/api/faucet", async ( req: Request< unknown, FaucetSuccessResponse | FaucetErrorResponse, FaucetRequestBody >, res: Response ) => { const now = Date.now(); try { const { address, captchaToken } = req.body ?? {}; // 1) Validation adresse if (!address || typeof address !== "string") { return res.status(400).json({ status: "error", error: "invalid_request", message: "Missing or invalid address.", }); } const normalizedAddress = normalizeAddress(address); if (!normalizedAddress) { return res.status(400).json({ status: "error", error: "invalid_request", message: "Address must be a valid TLRO / EVM address (tlro:0x… or 0x…).", }); } const clientIp = getClientIp(req); // 2) Captcha const captchaOk = await verifyCaptcha(captchaToken, clientIp); if (!captchaOk) { return res.status(400).json({ status: "error", error: "invalid_captcha", message: "Captcha verification failed.", }); } // 3) Rate limiting (IP + adresse) const addrKey = `addr:${normalizedAddress}`; const ipKey = `ip:${clientIp}`; const addrLimit = checkAndConsumeRateLimit( addrKey, MAX_PER_ADDRESS, now, WINDOW_MS ); const ipLimit = checkAndConsumeRateLimit( ipKey, MAX_PER_IP, now, WINDOW_MS ); if (!addrLimit.allowed || !ipLimit.allowed) { const nextTs = Math.max( addrLimit.nextAllowedAt, ipLimit.nextAllowedAt ); return res.status(429).json({ status: "error", error: "rate_limited", message: "Faucet limit reached. Try again later.", nextEligibleAt: new Date(nextTs).toISOString(), }); } // 4) Vérifier solde du faucet const balance = await faucetWallet.getBalance(); if (balance < REQUIRED_BALANCE_UNITS) { return res.status(403).json({ status: "error", error: "faucet_empty", message: "Faucet temporarily depleted. Please try again later.", }); } // 5) Envoyer la transaction const tx = await faucetWallet.sendTransaction({ to: normalizedAddress, value: AMOUNT_UNITS, }); console.log( `[FAUCET] Sent ${FAUCET_AMOUNT_TLRO} TLRO to ${normalizedAddress} | txHash=${tx.hash} | ip=${clientIp}` ); const nextEligibleAt = new Date(now + WINDOW_MS).toISOString(); return res.status(200).json({ status: "ok", txHash: tx.hash, amount: FAUCET_AMOUNT_TLRO, unit: "TLRO", nextEligibleAt, }); } catch (err) { console.error("[FAUCET] /api/faucet error:", err); return res.status(500).json({ status: "error", error: "internal_error", message: "Unexpected error while processing faucet request.", }); } } ); // ---------- Lancement du serveur ---------- app.listen(PORT, () => { console.log(`Talero faucet backend listening on port ${PORT}`); });