- /light, /receipt and the offline receipt verifier say "devnet, no value" (bundle and source), the reference-app test page names no devnet number, the faucet API's closed message names no testnet - /oracle drops the earlier-oracle history sentence - tools/site-deploy-from-mirror.sh: the served-page pins no longer expect "Devnet 3 faucet" or the class v5 floor history; they expect the Igneum 2.0 devnet faucet title and igneum-devnet-4 on /build Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
110 lines
6 KiB
JavaScript
110 lines
6 KiB
JavaScript
// 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.' });
|
|
}
|
|
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();
|