237 lines
18 KiB
JavaScript
237 lines
18 KiB
JavaScript
// ethereum.mjs (quantum exposure report, C12): EIP-55 addresses, a minimal RLP encoder, the signing hash of a transaction by type
|
|
// (legacy / EIP-2930 / EIP-1559 / EIP-4844 / EIP-7702) and the RECOVERY of the sender's public key from its signature. An EOA
|
|
// address is keccak256(publicKey)[12:]: the key is not on chain until the account SIGNS something; the first transaction it sends
|
|
// carries an ECDSA signature from which anyone recovers the key. This module re-derives that key from the bytes an endpoint serves
|
|
// and checks that it hashes to the address: the evidence is the recovered key, not the endpoint's `from` field.
|
|
// Pure functions; nothing here touches the network.
|
|
import { keccak_256, secp256k1 } from './noble.mjs';
|
|
|
|
export const keccak = (b) => Buffer.from(keccak_256(Buffer.from(b)));
|
|
const hexb = (h) => { const t = String(h || '').replace(/^0x/, ''); return Buffer.from(t.length % 2 ? '0' + t : t, 'hex'); };
|
|
const N = secp256k1.CURVE.n;
|
|
|
|
/** EIP-55: lowercase or uppercase hex is accepted as unchecked; mixed case must match the checksum. -> { address (lowercase), checksum: 'valid'|'none'|'wrong' } or { error } */
|
|
export function decodeazaAdresaEth(line) {
|
|
const s = String(line).trim();
|
|
if (!/^0x[0-9a-fA-F]{40}$/.test(s)) return { input: s, error: 'not an Ethereum address (0x + 40 hex)' };
|
|
const low = s.slice(2).toLowerCase();
|
|
const h = keccak(Buffer.from(low, 'ascii')).toString('hex');
|
|
let cs = '0x'; for (let i = 0; i < 40; i++) cs += parseInt(h[i], 16) >= 8 ? low[i].toUpperCase() : low[i];
|
|
if (s === cs) return { input: s, address: '0x' + low, checksum: 'valid', eip55: cs };
|
|
if (s.slice(2) === low || s.slice(2) === low.toUpperCase()) return { input: s, address: '0x' + low, checksum: 'none', eip55: cs };
|
|
return { input: s, error: 'EIP-55 checksum does not match (mixed-case address with a wrong checksum)' };
|
|
}
|
|
export const adresaDinCheie = (pub65) => '0x' + keccak(Buffer.from(pub65).subarray(1)).subarray(12).toString('hex');
|
|
|
|
// ---------------------------------------------------------------- RLP (the subset a transaction needs)
|
|
const encLen = (n, off) => { if (n < 56) return Buffer.from([off + n]); const l = hexb(n.toString(16)); return Buffer.concat([Buffer.from([off + 55 + l.length]), l]); };
|
|
export function rlpEncode(x) {
|
|
if (Array.isArray(x)) { const body = Buffer.concat(x.map(rlpEncode)); return Buffer.concat([encLen(body.length, 0xc0), body]); }
|
|
const b = Buffer.from(x);
|
|
if (b.length === 1 && b[0] < 0x80) return b;
|
|
return Buffer.concat([encLen(b.length, 0x80), b]);
|
|
}
|
|
/** a served value as a BigInt; a value that does not convert throws a FIXED message (the conversion's own message would carry the served
|
|
* text into the report) */
|
|
const big = (x, nume) => { try { return BigInt(x); } catch { throw new Error(`${nume} is not a JSON-RPC quantity`); } };
|
|
/** a JSON-RPC quantity (0x-hex) as minimal big-endian bytes; 0 -> empty */
|
|
export const scalar = (h) => { if (h === undefined || h === null) return Buffer.alloc(0); const n = big(h, 'a field'); return n === 0n ? Buffer.alloc(0) : hexb(n.toString(16)); };
|
|
const bytes = (h) => (h === undefined || h === null ? Buffer.alloc(0) : hexb(h));
|
|
const accessList = (al) => (al || []).map((e) => [bytes(e.address), (e.storageKeys || []).map(bytes)]);
|
|
const authList = (al) => (al || []).map((a) => [scalar(a.chainId), bytes(a.address), scalar(a.nonce), scalar(a.yParity), scalar(a.r), scalar(a.s)]);
|
|
|
|
/**
|
|
* the unsigned fields and the signature of a transaction as a JSON-RPC endpoint returns it (eth_getBlockByNumber with transactions /
|
|
* eth_getTransactionByHash). -> { type, unsigned: [..], prefix: Buffer, rec: 0|1, r, s, chainId } ; throws on an unknown type.
|
|
*/
|
|
export function campuriTx(tx) {
|
|
const type = Number(big(tx.type || 0, 'type'));
|
|
const r = big(tx.r, 'r'), s = big(tx.s, 's');
|
|
if (type === 0) {
|
|
const v = Number(big(tx.v, 'v'));
|
|
let chainId = null, rec;
|
|
if (v === 27 || v === 28) rec = v - 27;
|
|
else { chainId = Math.floor((v - 35) / 2); rec = v - 35 - 2 * chainId; }
|
|
if (rec !== 0 && rec !== 1) throw new Error('legacy v does not yield a recovery bit');
|
|
const base = [scalar(tx.nonce), scalar(tx.gasPrice), scalar(tx.gas), bytes(tx.to), scalar(tx.value), bytes(tx.input)];
|
|
const unsigned = chainId === null ? base : [...base, scalar('0x' + chainId.toString(16)), Buffer.alloc(0), Buffer.alloc(0)];
|
|
return { type, unsigned, signed: [...base, scalar(tx.v), scalar(tx.r), scalar(tx.s)], prefix: Buffer.alloc(0), rec, r, s, chainId };
|
|
}
|
|
const yp = tx.yParity !== undefined ? Number(big(tx.yParity, 'yParity')) : Number(big(tx.v, 'v'));
|
|
if (yp !== 0 && yp !== 1) throw new Error('yParity is not 0 or 1');
|
|
let unsigned;
|
|
if (type === 1) unsigned = [scalar(tx.chainId), scalar(tx.nonce), scalar(tx.gasPrice), scalar(tx.gas), bytes(tx.to), scalar(tx.value), bytes(tx.input), accessList(tx.accessList)];
|
|
else if (type === 2) unsigned = [scalar(tx.chainId), scalar(tx.nonce), scalar(tx.maxPriorityFeePerGas), scalar(tx.maxFeePerGas), scalar(tx.gas), bytes(tx.to), scalar(tx.value), bytes(tx.input), accessList(tx.accessList)];
|
|
else if (type === 3) unsigned = [scalar(tx.chainId), scalar(tx.nonce), scalar(tx.maxPriorityFeePerGas), scalar(tx.maxFeePerGas), scalar(tx.gas), bytes(tx.to), scalar(tx.value), bytes(tx.input), accessList(tx.accessList), scalar(tx.maxFeePerBlobGas), (tx.blobVersionedHashes || []).map(bytes)];
|
|
else if (type === 4) unsigned = [scalar(tx.chainId), scalar(tx.nonce), scalar(tx.maxPriorityFeePerGas), scalar(tx.maxFeePerGas), scalar(tx.gas), bytes(tx.to), scalar(tx.value), bytes(tx.input), accessList(tx.accessList), authList(tx.authorizationList)];
|
|
else throw new Error('transaction type 0x' + type.toString(16) + ' is not known to this tool');
|
|
return { type, unsigned, signed: [...unsigned, scalar('0x' + yp.toString(16)), scalar(tx.r), scalar(tx.s)], prefix: Buffer.from([type]), rec: yp, r, s, chainId: Number(big(tx.chainId, 'chainId')) };
|
|
}
|
|
/** the hash the sender signed (EIP-155 / EIP-2718 typed envelopes) and the transaction hash as the chain computes it */
|
|
export function hashuriTx(tx) {
|
|
const c = campuriTx(tx);
|
|
return { signingHash: keccak(Buffer.concat([c.prefix, rlpEncode(c.unsigned)])), txHash: '0x' + keccak(Buffer.concat([c.prefix, rlpEncode(c.signed)])).toString('hex'), ...c };
|
|
}
|
|
/**
|
|
* recover the sender's public key from a served transaction. -> { ok: true, publicKey (65 bytes), address, txHashMatches, type }
|
|
* or { ok: false, reason }. The recovered address is compared with tx.from by the caller; the tx hash re-derived from the served
|
|
* fields is compared with tx.hash so that an endpoint cannot serve fields that are not those of the transaction it names.
|
|
*/
|
|
export function recupereazaExpeditor(tx) {
|
|
let h; try { h = hashuriTx(tx); } catch (e) { return { ok: false, reason: e.message }; }
|
|
if (h.r === 0n || h.r >= N || h.s === 0n || h.s >= N) return { ok: false, reason: 'r or s outside [1, n-1]' };
|
|
let pub;
|
|
try { pub = Buffer.from(new secp256k1.Signature(h.r, h.s).addRecoveryBit(h.rec).recoverPublicKey(h.signingHash).toRawBytes(false)); }
|
|
catch (e) { return { ok: false, reason: 'no public key recovers from the signature (' + String(e.message).slice(0, 60) + ')' }; }
|
|
return { ok: true, publicKey: pub, address: adresaDinCheie(pub), txHashMatches: String(tx.hash || '').toLowerCase() === h.txHash, txHash: h.txHash, type: h.type };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- EIP-7702 (Pectra, 2025-05-07)
|
|
/** an account whose code is the delegation designator 0xef0100 || address: the chain sets it ONLY after an authorization signed with
|
|
* that account's own key was included in a type-4 transaction, so the designator is on-chain evidence that the key signed */
|
|
export const esteDelegare7702 = (code) => typeof code === 'string' && /^0xef0100[0-9a-fA-F]{40}$/.test(code);
|
|
export const delegatDin7702 = (code) => '0x' + code.slice(8).toLowerCase();
|
|
/** recover the AUTHORITY of an authorization tuple {chainId, address, nonce, yParity, r, s}: the signature is over
|
|
* keccak256(0x05 || rlp([chain_id, address, nonce])). -> { ok, publicKey, address, delegate } or { ok: false, reason } */
|
|
export function recupereazaAutoritate(au) {
|
|
try {
|
|
const delegat = hexb(au.address);
|
|
const msg = keccak(Buffer.concat([Buffer.from([5]), rlpEncode([scalar(au.chainId), delegat, scalar(au.nonce)])]));
|
|
const r = big(au.r, 'r'), s = big(au.s, 's'), yp = Number(big(au.yParity !== undefined ? au.yParity : au.v, 'yParity'));
|
|
if (r === 0n || r >= N || s === 0n || s >= N || (yp !== 0 && yp !== 1)) return { ok: false, reason: 'authorization signature out of range' };
|
|
const pub = Buffer.from(new secp256k1.Signature(r, s).addRecoveryBit(yp).recoverPublicKey(msg).toRawBytes(false));
|
|
// the delegate is written as the bytes the authority signed, never as the served string (which may hold more than those bytes)
|
|
return { ok: true, publicKey: pub, address: adresaDinCheie(pub), delegate: '0x' + delegat.toString('hex') };
|
|
} catch (e) { return { ok: false, reason: 'no public key recovers from the authorization (' + String(e && e.message).slice(0, 60) + ')' }; }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- the account AT a block: eth_getProof (EIP-1186)
|
|
// The nonce and the code of an account are read from a Merkle-Patricia proof under the STATE ROOT of the block the classification judges
|
|
// (the head block, whose time is judged), not from eth_getTransactionCount / eth_getCode at 'latest': behind a load balancer 'latest'
|
|
// may be answered by another machine, at another block. Every node of a proof is looked up by its own keccak hash, starting from the
|
|
// root, so a node an endpoint added or changed is never reached: a proof counts only when it leads, hash by hash, from that state root to
|
|
// the account (or shows it absent). The walker follows the same rules as the published one beside the AERE anchor verifier
|
|
// (verificator-falcon/mpt.mjs); the test runs both on the same proofs.
|
|
/** keccak256 of empty code: the code hash of an account without code */
|
|
export const HASH_COD_GOL = keccak(Buffer.alloc(0)).toString('hex');
|
|
/** strict RLP decoding: lists become arrays, strings Buffers; a length that runs past the input, or bytes left after the item, throw */
|
|
export function rlpDecode(buf) {
|
|
const lung = (b, o, l) => { if (o + l > b.length) throw new Error('RLP runs past its input'); return Number(BigInt('0x' + (b.subarray(o, o + l).toString('hex') || '0'))); };
|
|
const dec = (b, o) => {
|
|
const p = b[o];
|
|
if (p === undefined) throw new Error('RLP runs past its input');
|
|
if (p < 0x80) return [b.subarray(o, o + 1), o + 1];
|
|
if (p < 0xb8) { const n = p - 0x80; if (o + 1 + n > b.length) throw new Error('RLP runs past its input'); return [b.subarray(o + 1, o + 1 + n), o + 1 + n]; }
|
|
if (p < 0xc0) { const l = p - 0xb7; const n = lung(b, o + 1, l); if (o + 1 + l + n > b.length) throw new Error('RLP runs past its input'); return [b.subarray(o + 1 + l, o + 1 + l + n), o + 1 + l + n]; }
|
|
let n, s;
|
|
if (p < 0xf8) { n = p - 0xc0; s = o + 1; } else { const l = p - 0xf7; n = lung(b, o + 1, l); s = o + 1 + l; }
|
|
if (s + n > b.length) throw new Error('RLP runs past its input');
|
|
const out = []; let q = s;
|
|
while (q < s + n) { const [x, r] = dec(b, q); out.push(x); q = r; }
|
|
if (q !== s + n) throw new Error('RLP list length mismatch');
|
|
return [out, s + n];
|
|
};
|
|
const [v, end] = dec(buf, 0);
|
|
if (end !== buf.length) throw new Error('trailing bytes after an RLP item');
|
|
return v;
|
|
}
|
|
const nibule = (buf) => { const o = []; for (const x of buf) o.push(x >> 4, x & 15); return o; };
|
|
/** the value at keccak(key) under root (a Buffer), or null when the proof shows it absent; throws when the proof does not hold */
|
|
export function mptGet(root, cheieHash, dovada) {
|
|
const dupaHash = new Map(dovada.map((p) => { const b = hexb(p); return [keccak(b).toString('hex'), b]; }));
|
|
let cheie = nibule(cheieHash), ref = hexb(root);
|
|
for (let adancime = 0; adancime < 70; adancime++) {
|
|
let nod;
|
|
if (Buffer.isBuffer(ref)) {
|
|
const brut = dupaHash.get(ref.toString('hex'));
|
|
if (!brut) throw new Error('the proof is missing the node ' + ref.toString('hex').slice(0, 16) + '..');
|
|
nod = rlpDecode(brut);
|
|
} else nod = ref;
|
|
if (!Array.isArray(nod)) throw new Error('a proof node is not a list');
|
|
if (nod.length === 17) {
|
|
if (cheie.length === 0) return nod[16].length ? nod[16] : null;
|
|
const urm = nod[cheie[0]]; cheie = cheie.slice(1);
|
|
if (Array.isArray(urm)) { ref = urm; continue; }
|
|
if (urm.length === 0) return null;
|
|
if (urm.length !== 32) throw new Error('a branch child is neither a hash nor an inline node');
|
|
ref = urm; continue;
|
|
}
|
|
if (nod.length === 2) {
|
|
if (!Buffer.isBuffer(nod[0]) || !nod[0].length) throw new Error('a proof node has no path');
|
|
const enc = nibule(nod[0]); const steag = enc[0];
|
|
if (steag > 3) throw new Error('bad hex-prefix flag');
|
|
const p = enc.slice(steag % 2 === 1 ? 1 : 2);
|
|
const potrivit = cheie.length >= p.length && p.every((n, i) => cheie[i] === n);
|
|
if (steag >= 2) return potrivit && cheie.length === p.length ? nod[1] : null;
|
|
if (!potrivit) return null;
|
|
cheie = cheie.slice(p.length);
|
|
const urm = nod[1];
|
|
if (Array.isArray(urm)) { ref = urm; continue; }
|
|
if (urm.length !== 32) throw new Error('an extension child is not a hash');
|
|
ref = urm; continue;
|
|
}
|
|
throw new Error('a proof node with ' + nod.length + ' items');
|
|
}
|
|
throw new Error('the proof is deeper than any key');
|
|
}
|
|
/**
|
|
* the account of `adresa` proven under `stateRoot` by an eth_getProof answer -> { nonce (BigInt), codeHash (hex, no 0x), absent } or
|
|
* { eroare } naming why the proof does not hold. Only the PROVEN values are used; the nonce and codeHash fields the endpoint also serves
|
|
* beside the proof are not read
|
|
*/
|
|
export function contDinDovada(stateRoot, adresa, dovada) {
|
|
try {
|
|
if (!dovada || typeof dovada !== 'object' || !Array.isArray(dovada.accountProof)) throw new Error('the answer carries no accountProof list');
|
|
if (dovada.accountProof.some((p) => typeof p !== 'string' || !/^0x(?:[0-9a-fA-F]{2})+$/.test(p))) throw new Error('an accountProof node is not hex bytes');
|
|
const v = mptGet(stateRoot, keccak(hexb(adresa)), dovada.accountProof);
|
|
if (v === null) return { absent: true, nonce: 0n, codeHash: HASH_COD_GOL };
|
|
const a = Buffer.isBuffer(v) ? rlpDecode(v) : null;
|
|
if (!Array.isArray(a) || a.length !== 4 || a.some((x) => !Buffer.isBuffer(x)) || a[0].length > 8 || a[2].length !== 32 || a[3].length !== 32) throw new Error('the value proven is not an account [nonce, balance, storageRoot, codeHash]');
|
|
return { absent: false, nonce: a[0].length ? BigInt('0x' + a[0].toString('hex')) : 0n, codeHash: a[3].toString('hex') };
|
|
} catch (e) { return { eroare: String((e && e.message) || e).slice(0, 120) }; }
|
|
}
|
|
/**
|
|
* the TEST side of the same structure (the tool itself only walks proofs): a Merkle-Patricia trie built from pairs { cheie (raw key, a
|
|
* Buffer), valoare (a Buffer) } -> { radacina (Buffer), dovada(cheie) -> the proof nodes of that key, as eth_getProof serves them }. A node
|
|
* whose encoding is under 32 bytes is inlined in its parent, as the yellow paper writes it; the test checks the root against known vectors
|
|
*/
|
|
export function trieDin(perechi) {
|
|
const noduri = new Map();
|
|
const hp = (n, frunza) => { const impar = n.length % 2; const a = [(frunza ? 2 : 0) + impar, ...(impar ? [] : [0]), ...n]; const o = Buffer.alloc(a.length / 2); for (let i = 0; i < o.length; i++) o[i] = (a[2 * i] << 4) | a[2 * i + 1]; return o; };
|
|
const ref = (nod) => { const e = rlpEncode(nod); if (e.length < 32) return nod; const h = keccak(e); noduri.set(h.toString('hex'), e); return h; };
|
|
const fa = (el) => {
|
|
if (el.length === 1) return [hp(el[0].k, true), el[0].v];
|
|
let p = 0; while (el.every((x) => x.k.length > p && x.k[p] === el[0].k[p])) p++;
|
|
if (p > 0) return [hp(el[0].k.slice(0, p), false), ref(fa(el.map((x) => ({ k: x.k.slice(p), v: x.v }))))];
|
|
const ramura = Array.from({ length: 17 }, () => Buffer.alloc(0));
|
|
for (let i = 0; i < 16; i++) { const g = el.filter((x) => x.k.length && x.k[0] === i).map((x) => ({ k: x.k.slice(1), v: x.v })); if (g.length) ramura[i] = ref(fa(g)); }
|
|
const capat = el.find((x) => !x.k.length); if (capat) ramura[16] = capat.v;
|
|
return ramura;
|
|
};
|
|
const el = perechi.map((x) => ({ k: nibule(x.cheie), v: Buffer.from(x.valoare) }));
|
|
const radacinaRlp = rlpEncode(el.length ? fa(el) : Buffer.alloc(0)); const radacina = keccak(radacinaRlp); noduri.set(radacina.toString('hex'), radacinaRlp);
|
|
const dovada = (cheie) => {
|
|
const out = [radacinaRlp]; let k = nibule(cheie); let nod = rlpDecode(radacinaRlp);
|
|
for (let d = 0; d < 70 && Array.isArray(nod); d++) {
|
|
let urm;
|
|
if (nod.length === 17) { if (!k.length) break; urm = nod[k[0]]; k = k.slice(1); }
|
|
else { const enc = nibule(nod[0]); const s = enc[0]; const p = enc.slice(s % 2 ? 1 : 2); if (s >= 2 || !(k.length >= p.length && p.every((x, i) => k[i] === x))) break; k = k.slice(p.length); urm = nod[1]; }
|
|
if (Array.isArray(urm)) { nod = urm; continue; }
|
|
const brut = urm.length === 32 ? noduri.get(urm.toString('hex')) : null; if (!brut) break;
|
|
out.push(brut); nod = rlpDecode(brut);
|
|
}
|
|
return out.map((b) => '0x' + b.toString('hex'));
|
|
};
|
|
return { radacina, dovada };
|
|
}
|
|
/** a STATE of accounts [{ address, nonce, code }] (balance 0, empty storage) as an endpoint serves it at a block: -> { stateRoot,
|
|
* dovada(address) -> the eth_getProof answer for that address (an absent account gets its proof of absence) }; used by the test and its
|
|
* mocks */
|
|
export function stareConturi(conturi) {
|
|
const RADACINA_GOALA = keccak(rlpEncode(Buffer.alloc(0)));
|
|
const camp = (c) => ({ nonce: BigInt(c.nonce || 0), codeHash: keccak(hexb(c.code || '0x')) });
|
|
const t = trieDin(conturi.map((c) => { const x = camp(c); return { cheie: keccak(hexb(c.address)), valoare: rlpEncode([x.nonce ? hexb(x.nonce.toString(16)) : Buffer.alloc(0), Buffer.alloc(0), RADACINA_GOALA, x.codeHash]) }; }));
|
|
const dovada = (adresa) => { const c = conturi.find((x) => x.address.toLowerCase() === String(adresa).toLowerCase()); const x = camp(c || {}); return { address: String(adresa).toLowerCase(), accountProof: t.dovada(keccak(hexb(adresa))), balance: '0x0', codeHash: '0x' + (c ? x.codeHash.toString('hex') : HASH_COD_GOL), nonce: '0x' + x.nonce.toString(16), storageHash: '0x' + RADACINA_GOALA.toString('hex'), storageProof: [] }; };
|
|
return { stateRoot: '0x' + t.radacina.toString('hex'), dovada };
|
|
}
|