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