igneum/site/lib/eth.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

165 lines
8.9 KiB
JavaScript

// Ethereum signing with no dependencies, for the faucet function (site/api/faucet.mjs). The site project installs
// nothing at build time (its install command is `echo skip`), so viem is not an option there; this is the minimum:
// keccak-256, RLP, secp256k1 ECDSA with RFC 6979 nonces, the EIP-1559 envelope and the address of a key.
// Checked against the live devnet node (eth_sendRawTransaction accepts the transaction and the receipt's `from` is
// this key's address) and against known keccak vectors in site/api/faucet.test.mjs.
import { createHmac } from 'node:crypto';
// ---- keccak-256 (the original Keccak padding 0x01..0x80, not SHA-3's 0x06) ------------------------------------------
const RC = [
0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n, 0x000000000000808bn, 0x0000000080000001n,
0x8000000080008081n, 0x8000000000008009n, 0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n, 0x8000000000008002n, 0x8000000000000080n,
0x000000000000800an, 0x800000008000000an, 0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
];
// rotation offsets r[x + 5y]
const ROT = [0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14];
const M64 = (1n << 64n) - 1n;
const rotl = (v, n) => n === 0 ? v : (((v << BigInt(n)) | (v >> BigInt(64 - n))) & M64);
function keccakF(A) {
const C = new Array(5), D = new Array(5), B = new Array(25);
for (let round = 0; round < 24; round++) {
for (let x = 0; x < 5; x++) C[x] = A[x] ^ A[x + 5] ^ A[x + 10] ^ A[x + 15] ^ A[x + 20];
for (let x = 0; x < 5; x++) D[x] = C[(x + 4) % 5] ^ rotl(C[(x + 1) % 5], 1);
for (let i = 0; i < 25; i++) A[i] ^= D[i % 5];
for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) B[y + 5 * ((2 * x + 3 * y) % 5)] = rotl(A[x + 5 * y], ROT[x + 5 * y]);
for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) A[x + 5 * y] = B[x + 5 * y] ^ ((~B[(x + 1) % 5 + 5 * y] & M64) & B[(x + 2) % 5 + 5 * y]);
A[0] ^= RC[round];
}
}
export function keccak256(bytes) {
const rate = 136;
const padded = new Uint8Array(Math.ceil((bytes.length + 1) / rate) * rate);
padded.set(bytes); padded[bytes.length] ^= 0x01; padded[padded.length - 1] ^= 0x80;
const A = new Array(25).fill(0n);
for (let off = 0; off < padded.length; off += rate) {
for (let i = 0; i < rate / 8; i++) {
let lane = 0n;
for (let b = 7; b >= 0; b--) lane = (lane << 8n) | BigInt(padded[off + i * 8 + b]);
A[i] ^= lane;
}
keccakF(A);
}
const out = new Uint8Array(32);
for (let i = 0; i < 4; i++) { let lane = A[i]; for (let b = 0; b < 8; b++) { out[i * 8 + b] = Number(lane & 0xffn); lane >>= 8n; } }
return out;
}
// ---- bytes and hex --------------------------------------------------------------------------------------------------
export const hex = bytes => '0x' + Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
export function unhex(s) {
const h = String(s).replace(/^0x/i, '');
if (h.length % 2 || /[^0-9a-f]/i.test(h)) throw new Error(`bad hex ${s}`);
const out = new Uint8Array(h.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
return out;
}
const bigToBytes = (v, len) => { const h = v.toString(16).padStart(len * 2, '0'); return unhex(h); };
const bytesToBig = b => { let v = 0n; for (const x of b) v = (v << 8n) | BigInt(x); return v; };
const concat = (...parts) => { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; };
const utf8 = s => new TextEncoder().encode(s);
// ---- RLP -----------------------------------------------------------------------------------------------------------
// an item is a Uint8Array, a bigint or number (minimal big-endian, 0 = empty), a hex string, or an array of items
function toBytes(item) {
if (item instanceof Uint8Array) return item;
if (typeof item === 'bigint' || typeof item === 'number') { const v = BigInt(item); if (v < 0n) throw new Error('negative'); if (v === 0n) return new Uint8Array(0); let h = v.toString(16); if (h.length % 2) h = '0' + h; return unhex(h); }
if (typeof item === 'string') return unhex(item);
throw new Error('rlp: unsupported item');
}
function rlpLength(len, offset) {
if (len < 56) return new Uint8Array([offset + len]);
const l = toBytes(len);
return concat(new Uint8Array([offset + 55 + l.length]), l);
}
export function rlp(item) {
if (Array.isArray(item)) { const body = concat(...item.map(rlp)); return concat(rlpLength(body.length, 0xc0), body); }
const b = toBytes(item);
if (b.length === 1 && b[0] < 0x80) return b;
return concat(rlpLength(b.length, 0x80), b);
}
// ---- secp256k1 ------------------------------------------------------------------------------------------------------
const P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
export const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
const G = [0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n, 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n];
const mod = (a, m) => { const r = a % m; return r < 0n ? r + m : r; };
function inv(a, m) { // extended Euclid
let lm = 1n, hm = 0n, low = mod(a, m), high = m;
if (low === 0n) throw new Error('no inverse of 0');
while (low > 1n) { const r = high / low; [lm, hm] = [hm - lm * r, lm]; [low, high] = [high - low * r, low]; }
return mod(lm, m);
}
function pointAdd(a, b) {
if (!a) return b; if (!b) return a;
const [x1, y1] = a, [x2, y2] = b;
if (x1 === x2) { if (mod(y1 + y2, P) === 0n) return null; return pointDouble(a); }
const l = mod((y2 - y1) * inv(x2 - x1, P), P);
const x3 = mod(l * l - x1 - x2, P);
return [x3, mod(l * (x1 - x3) - y1, P)];
}
function pointDouble(a) {
const [x, y] = a;
const l = mod(3n * x * x * inv(2n * y, P), P);
const x3 = mod(l * l - 2n * x, P);
return [x3, mod(l * (x - x3) - y, P)];
}
function pointMul(k, point = G) {
let r = null, q = point;
for (let e = mod(k, N); e > 0n; e >>= 1n) { if (e & 1n) r = pointAdd(r, q); q = pointDouble(q); }
return r;
}
export function publicKey(priv) { // uncompressed, 64 bytes (x || y), no 0x04 prefix
const d = bytesToBig(unhex(priv));
if (d <= 0n || d >= N) throw new Error('private key out of range');
const [x, y] = pointMul(d);
return concat(bigToBytes(x, 32), bigToBytes(y, 32));
}
export function addressOf(priv) { return hex(keccak256(publicKey(priv)).slice(12)); }
// RFC 6979 nonce for secp256k1 with HMAC-SHA256 (deterministic: the same key and hash give the same signature)
function rfc6979(privBytes, hash) {
const hmac = (k, ...msgs) => { const h = createHmac('sha256', k); for (const m of msgs) h.update(m); return new Uint8Array(h.digest()); };
let v = new Uint8Array(32).fill(1), k = new Uint8Array(32);
const x = privBytes, h1 = bigToBytes(mod(bytesToBig(hash), N), 32);
k = hmac(k, v, new Uint8Array([0]), x, h1); v = hmac(k, v);
k = hmac(k, v, new Uint8Array([1]), x, h1); v = hmac(k, v);
for (;;) {
v = hmac(k, v);
const t = bytesToBig(v);
if (t >= 1n && t < N) return t;
k = hmac(k, v, new Uint8Array([0])); v = hmac(k, v);
}
}
// returns {r, s, v} with low s and the recovery id v (0 or 1)
export function sign(hash, priv) {
const privBytes = unhex(priv); const d = bytesToBig(privBytes);
if (d <= 0n || d >= N) throw new Error('private key out of range');
const z = mod(bytesToBig(hash), N);
for (let attempt = 0; attempt < 8; attempt++) {
const k = rfc6979(privBytes, attempt ? keccak256(concat(hash, new Uint8Array([attempt]))) : hash);
const R = pointMul(k);
const r = mod(R[0], N);
if (r === 0n) continue;
let s = mod(inv(k, N) * (z + r * d), N);
if (s === 0n) continue;
let v = Number(R[1] & 1n);
if (s > N / 2n) { s = N - s; v ^= 1; }
return { r, s, v };
}
throw new Error('no signature');
}
// ---- the EIP-1559 transaction ------------------------------------------------------------------------------------------
// fields: chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gas, to (hex), value, data (hex, default 0x)
export function signTransaction(tx, priv) {
const fields = [BigInt(tx.chainId), BigInt(tx.nonce), BigInt(tx.maxPriorityFeePerGas), BigInt(tx.maxFeePerGas), BigInt(tx.gas), unhex(tx.to), BigInt(tx.value), unhex(tx.data || '0x'), []];
const signingHash = keccak256(concat(new Uint8Array([2]), rlp(fields)));
const { r, s, v } = sign(signingHash, priv);
const raw = concat(new Uint8Array([2]), rlp([...fields, BigInt(v), r, s]));
return { raw: hex(raw), hash: hex(keccak256(raw)), v, r, s };
}
export const toWei = ign => BigInt(Math.round(Number(ign) * 1e6)) * 10n ** 12n; // whole-number IGN or up to 6 decimals
export const isAddress = s => typeof s === 'string' && /^0x[0-9a-fA-F]{40}$/.test(s);
export { utf8, bytesToBig, bigToBytes };