// 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); });