igneum/site/api/faucet.test.mjs
igneum-labs 3551069c51 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 15:59:31 +00:00

154 lines
8.6 KiB
JavaScript

// node --test site/api/faucet.test.mjs
// The faucet handler against a fake database and a fake node: validation, the per-address and per-IP limits, the
// reservation row, the signed transaction it sends; and the signing primitives against known vectors.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHandler, AMOUNT_IGN } from './faucet.mjs';
import { keccak256, rlp, hex, unhex, addressOf, signTransaction, sign, toWei, isAddress, utf8 } from '../lib/eth.mjs';
// a well-known test key (the first Hardhat account; never funded on Igneum)
const KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
const KEY_ADDRESS = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266';
function fakeDb(rows = []) {
const grants = rows.map((r, i) => ({ id: i + 1, address: r.address, ip: r.ip, tx_hash: r.tx_hash || null }));
const calls = [];
const sql = async (query, params) => {
calls.push({ query, params });
if (query.startsWith('SELECT')) return { rows: grants.filter(g => g.address === params[0] || g.ip === params[1]) };
if (query.startsWith('INSERT')) { const id = grants.length + 1; grants.push({ id, address: params[0], ip: params[1] }); return { rows: [{ id }] }; }
if (query.startsWith('UPDATE')) { grants.find(g => g.id === Number(params[1])).tx_hash = params[0]; return { rows: [] }; }
if (query.startsWith('DELETE')) { const i = grants.findIndex(g => g.id === Number(params[0])); if (i >= 0) grants.splice(i, 1); return { rows: [] }; }
throw new Error('unexpected query ' + query);
};
return { sql, grants, calls };
}
function fakeRpc({ fail } = {}) {
const sent = [];
const rpc = async (method, params) => {
if (method === 'eth_getTransactionCount') return '0x5';
if (method === 'eth_getBlockByNumber') return { baseFeePerGas: '0x3b9aca00' };
if (method === 'eth_maxPriorityFeePerGas') return '0x3b9aca00';
if (method === 'eth_estimateGas') return '0x5208';
if (method === 'eth_sendRawTransaction') { if (fail) throw new Error('nonce too low'); sent.push(params[0]); return '0x' + 'ab'.repeat(32); }
throw new Error('unexpected rpc ' + method);
};
return { rpc, sent };
}
function call(handler, { method = 'POST', body = {}, ip = '203.0.113.7' } = {}) {
const res = { status: null, body: null, headers: {}, setHeader(k, v) { this.headers[k] = v; }, status(c) { this.status = c; return this; }, json(b) { this.body = b; return this; } };
const req = { method, body, headers: { 'x-forwarded-for': ip }, socket: {} };
return handler(req, res).then(() => res);
}
const ENV = { FAUCET_KEY: KEY, FAUCET_RPC: 'http://127.0.0.1:1', FAUCET_CHAIN_ID: '4463' };
test('keccak-256 known vectors', () => {
assert.equal(hex(keccak256(new Uint8Array(0))), '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470');
assert.equal(hex(keccak256(utf8('abc'))), '0x4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45');
assert.equal(hex(keccak256(utf8('The quick brown fox jumps over the lazy dog'))), '0x4d741b6f1eb29cb2a9b9911c82f56fa8d73b04959d3d9d222895df6c0b28aa15');
assert.equal(hex(keccak256(new Uint8Array(200).fill(0x61))), hex(keccak256(utf8('a'.repeat(200))))); // two blocks
});
test('rlp', () => {
assert.equal(hex(rlp(utf8('dog'))), '0x83646f67');
assert.equal(hex(rlp([utf8('cat'), utf8('dog')])), '0xc88363617483646f67');
assert.equal(hex(rlp(0n)), '0x80');
assert.equal(hex(rlp(15n)), '0x0f');
assert.equal(hex(rlp(1024n)), '0x820400');
assert.equal(hex(rlp([])), '0xc0');
assert.equal(hex(rlp(utf8('Lorem ipsum dolor sit amet, consectetur adipisicing elit'))), '0xb8384c6f72656d20697073756d20646f6c6f722073697420616d65742c20636f6e7365637465747572206164697069736963696e6720656c6974');
});
test('address of a key', () => {
assert.equal(addressOf(KEY), KEY_ADDRESS);
assert.equal(addressOf('0x0000000000000000000000000000000000000000000000000000000000000001'), '0x7e5f4552091a69125d5dfcb7b8c2659029395bdf');
});
test('ecdsa: deterministic, low s, verifiable recovery id', () => {
const h = keccak256(utf8('igneum'));
const a = sign(h, KEY), b = sign(h, KEY);
assert.deepEqual(a, b);
assert.ok(a.s <= 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0n);
assert.ok(a.v === 0 || a.v === 1);
});
test('eip-1559 envelope: type 2, the chain id first, the hash is keccak of the raw bytes', () => {
const tx = { chainId: 4463, nonce: 5, maxPriorityFeePerGas: 1_000_000_000n, maxFeePerGas: 3_000_000_000n, gas: 21000, to: KEY_ADDRESS, value: toWei(10), data: '0x' };
const s = signTransaction(tx, KEY);
assert.ok(s.raw.startsWith('0x02'));
assert.equal(s.hash, hex(keccak256(unhex(s.raw))));
// the unsigned fields are in the payload: chain id 4463 = 0x116f, nonce 5, gas 21000 = 0x5208
assert.ok(s.raw.includes('82116f05'));
assert.ok(s.raw.includes('825208'));
assert.equal(signTransaction(tx, KEY).raw, s.raw); // deterministic
});
test('toWei and isAddress', () => {
assert.equal(toWei(10), 10n * 10n ** 18n);
assert.equal(toWei(0.5), 5n * 10n ** 17n);
assert.ok(isAddress(KEY_ADDRESS)); assert.ok(!isAddress('0x123')); assert.ok(!isAddress(KEY_ADDRESS + '0'));
});
test('GET is refused', async () => {
const r = await call(createHandler({ env: ENV, sql: fakeDb().sql, rpc: fakeRpc().rpc }), { method: 'GET' });
assert.equal(r.status, 405);
});
test('not configured: 503, nothing touched', async () => {
const db = fakeDb();
const r = await call(createHandler({ env: {}, sql: db.sql, rpc: fakeRpc().rpc }), { body: { address: KEY_ADDRESS } });
assert.equal(r.status, 503);
assert.match(r.body.error, /not open yet/);
assert.equal(db.calls.length, 0);
});
test('a bad address is refused before the database', async () => {
const db = fakeDb();
for (const address of ['', 'abc', '0x12', KEY_ADDRESS.slice(0, 41), 'xyz' + KEY_ADDRESS]) {
const r = await call(createHandler({ env: ENV, sql: db.sql, rpc: fakeRpc().rpc }), { body: { address } });
assert.equal(r.status, 400, address);
}
assert.equal(db.calls.length, 0);
});
test('the first request sends 10 IGN and records the grant with the hash', async () => {
const db = fakeDb(); const node = fakeRpc();
const r = await call(createHandler({ env: ENV, sql: db.sql, rpc: node.rpc }), { body: { address: KEY_ADDRESS.toUpperCase().replace('0X', '0x') } });
assert.equal(r.status, 200);
assert.equal(r.body.ok, true);
assert.equal(r.body.amount, String(AMOUNT_IGN));
assert.equal(r.body.to, KEY_ADDRESS);
assert.equal(node.sent.length, 1);
assert.ok(node.sent[0].startsWith('0x02'));
assert.equal(db.grants.length, 1);
assert.equal(db.grants[0].address, KEY_ADDRESS);
assert.equal(db.grants[0].ip, '203.0.113.7');
assert.equal(db.grants[0].tx_hash, '0x' + 'ab'.repeat(32));
assert.equal(db.calls.find(c => c.query.startsWith('INSERT')).params[2], (10n * 10n ** 18n).toString());
});
test('the same address again within a day: 429, nothing sent', async () => {
const db = fakeDb([{ address: KEY_ADDRESS, ip: '198.51.100.1' }]); const node = fakeRpc();
const r = await call(createHandler({ env: ENV, sql: db.sql, rpc: node.rpc }), { body: { address: KEY_ADDRESS } });
assert.equal(r.status, 429);
assert.match(r.body.error, /address/);
assert.equal(node.sent.length, 0);
assert.equal(db.grants.length, 1);
});
test('the same IP again within a day with another address: 429, nothing sent', async () => {
const db = fakeDb([{ address: '0x' + '11'.repeat(20), ip: '203.0.113.7' }]); const node = fakeRpc();
const r = await call(createHandler({ env: ENV, sql: db.sql, rpc: node.rpc }), { body: { address: KEY_ADDRESS } });
assert.equal(r.status, 429);
assert.match(r.body.error, /connection/);
assert.equal(node.sent.length, 0);
});
test('a different IP and address after a grant: served', async () => {
const db = fakeDb([{ address: '0x' + '11'.repeat(20), ip: '198.51.100.1' }]); const node = fakeRpc();
const r = await call(createHandler({ env: ENV, sql: db.sql, rpc: node.rpc }), { body: { address: KEY_ADDRESS }, ip: '203.0.113.9' });
assert.equal(r.status, 200);
assert.equal(db.grants.length, 2);
});
test('the node refuses: 502 and the reservation is released', async () => {
const db = fakeDb(); const node = fakeRpc({ fail: true });
const r = await call(createHandler({ env: ENV, sql: db.sql, rpc: node.rpc }), { body: { address: KEY_ADDRESS } });
assert.equal(r.status, 502);
assert.match(r.body.error, /nonce too low/);
assert.equal(db.grants.length, 0);
});
test('the database fails: 500, nothing sent', async () => {
const node = fakeRpc();
const r = await call(createHandler({ env: ENV, sql: async () => { throw new Error('db down'); }, rpc: node.rpc }), { body: { address: KEY_ADDRESS } });
assert.equal(r.status, 500);
assert.equal(node.sent.length, 0);
});