// Igneum testnet faucet. POST /api/faucet {address} sends 10 IGN of testnet coin to that address: once per address per // day, once per IP per day. The key is only in the Vercel env (FAUCET_KEY), the node in FAUCET_RPC; without either the // faucet answers "not open yet" and sends nothing. Rate limits live in the Neon table faucet_grants (the same HTTP SQL // pattern as api/log.mjs). Zero dependencies: the transaction is signed by site/lib/eth.mjs. // Prepared on the devnet (chain id 4463) on 5 October 2026; the public testnet (4462) gets its own key and RPC. import { signTransaction, addressOf, isAddress, toWei } from '../lib/eth.mjs'; export const AMOUNT_IGN = 10; export const WINDOW_HOURS = 24; export function neon(url) { if (!url) throw new Error('DATABASE_URL is not set'); const host = new URL(url).hostname.replace('-pooler', ''); return async (query, params = []) => { const r = await fetch(`https://${host}/sql`, { method: 'POST', headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, params }), }); const j = await r.json(); if (!r.ok) throw new Error(j.message || JSON.stringify(j)); return j; }; } export function rpcClient(url, fetchImpl = fetch) { let id = 0; return async (method, params = []) => { const r = await fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }) }); const j = await r.json(); if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`); return j.result; }; } async function readBody(req) { if (req.body !== undefined && req.body !== null) { if (typeof req.body === 'string') return JSON.parse(req.body); if (Buffer.isBuffer(req.body)) return JSON.parse(req.body.toString('utf8')); return req.body; } const chunks = []; for await (const c of req) chunks.push(c); return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } export function clientIp(req) { const fwd = req.headers['x-forwarded-for']; const first = (Array.isArray(fwd) ? fwd[0] : fwd || '').split(',')[0].trim(); return first || String(req.headers['x-real-ip'] || req.socket?.remoteAddress || '').trim() || 'unknown'; } // The handler, with its dependencies injectable for the tests: {env, sql, rpc}. `sql` is (query, params) -> {rows}, // `rpc` is (method, params) -> result. The default export wires the real ones from the environment. export function createHandler({ env = process.env, sql, rpc } = {}) { return async function handler(req, res) { res.setHeader('Cache-Control', 'no-store'); if (req.method !== 'POST') { res.setHeader('Allow', 'POST'); return res.status(405).json({ ok: false, error: 'method not allowed' }); } const key = env.FAUCET_KEY, rpcUrl = env.FAUCET_RPC; const chainId = Number(env.FAUCET_CHAIN_ID || 4463); if (!key || !/^0x[0-9a-fA-F]{64}$/.test(key) || !rpcUrl) { return res.status(503).json({ ok: false, error: 'The faucet is not open yet. It opens with the public testnet.' }); } let body; try { body = await readBody(req); } catch { return res.status(400).json({ ok: false, error: 'bad json' }); } const address = String(body.address || '').trim(); if (!isAddress(address)) return res.status(400).json({ ok: false, error: 'That is not an address. It is 0x followed by 40 hex characters.' }); const to = address.toLowerCase(); const ip = clientIp(req); try { sql = sql || neon(env.DATABASE_URL); const recent = await sql( `SELECT address, ip FROM faucet_grants WHERE created_at > now() - interval '${WINDOW_HOURS} hours' AND (address = $1 OR ip = $2)`, [to, ip], ); const rows = recent.rows || []; if (rows.some(r => r.address === to)) return res.status(429).json({ ok: false, error: `This address had its ${AMOUNT_IGN} IGN in the last ${WINDOW_HOURS} hours. Come back tomorrow.` }); if (rows.some(r => r.ip === ip)) return res.status(429).json({ ok: false, error: `This connection had its ${AMOUNT_IGN} IGN in the last ${WINDOW_HOURS} hours. Come back tomorrow.` }); // reserve the grant first, so two requests in the same second cannot both pass the count const ins = await sql('INSERT INTO faucet_grants (address, ip, amount_wei, chain_id) VALUES ($1, $2, $3, $4) RETURNING id', [to, ip, toWei(AMOUNT_IGN).toString(), chainId]); const grantId = Number(ins.rows[0].id); try { rpc = rpc || rpcClient(rpcUrl); const from = addressOf(key); const [nonceHex, block, tipHex] = await Promise.all([ rpc('eth_getTransactionCount', [from, 'pending']), rpc('eth_getBlockByNumber', ['latest', false]), rpc('eth_maxPriorityFeePerGas').catch(() => '0x3b9aca00'), // 1 gwei when the node has no tip oracle ]); const baseFee = BigInt(block?.baseFeePerGas || '0x3b9aca00'); const tip = BigInt(tipHex || '0x3b9aca00'); const value = toWei(AMOUNT_IGN); let gas = 21000n; try { gas = BigInt(await rpc('eth_estimateGas', [{ from, to, value: '0x' + value.toString(16) }])); } catch { /* the plain-transfer default */ } const tx = { chainId, nonce: BigInt(nonceHex), maxPriorityFeePerGas: tip, maxFeePerGas: baseFee * 2n + tip, gas, to, value, data: '0x' }; const signed = signTransaction(tx, key); const hash = await rpc('eth_sendRawTransaction', [signed.raw]); await sql('UPDATE faucet_grants SET tx_hash = $1 WHERE id = $2', [hash, grantId]); return res.status(200).json({ ok: true, tx: hash, amount: String(AMOUNT_IGN), chainId, to }); } catch (e) { await sql('DELETE FROM faucet_grants WHERE id = $1', [grantId]).catch(() => {}); return res.status(502).json({ ok: false, error: `The node did not take the transaction: ${String(e.message || e).slice(0, 200)}` }); } } catch (e) { return res.status(500).json({ ok: false, error: String(e.message || e).slice(0, 200) }); } }; } export default createHandler();