igneum/site/api/faucet.mjs
igneum-josh ba66ca28fc Public testnet launch, the public side: dl/public downloads path, site download buttons, HiveOS package, faucet
Downloads: packaging/ota/publish-public.sh publishes the current installers, the HiveOS package and the two signed
manifests into dl/public/ with no token in any URL, writes the four /public/ aliases as vercel.json rewrites and an
unsigned index for the site; publish-manifest.sh --public and ship-app.mjs --public run it on every release (dry run
and self-test cover it). Nothing removed from the token folders.

Site: the miner and wallet buttons link the public aliases and show the version and size from the index, read at
build time (site/downloads.json is the offline snapshot); TESTNET_OPEN in build.mjs drops the "Public testnet: not yet
open" line on the go; the HiveOS Flight Sheet install line on the miner page; /faucet page.

HiveOS: igneum-hive-0.3.8.tar.gz from the 0.3.8 node (2b6d23ef, PC build job) and the zig-built Linux workers.

Faucet: site/api/faucet.mjs (10 IGN per address and per IP per day, Neon table faucet_grants, EIP-1559 transfer signed
by site/lib/eth.mjs with no dependencies: keccak, RLP, secp256k1 with RFC 6979), FAUCET_KEY and FAUCET_RPC from the
Vercel env only; 15 unit tests with a fake database and node, run in CI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 16:59:31 +01:00

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. 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();