Zero dependencies, no account, no API. Every command takes --rpc, so it runs against a node you operate and never needs ours. That is the point: an exposure measurement you cannot re-run is an opinion with a logo on it. Ships with two self-checks meant to be run by you, not just by us. The second, proba-vocabular.mjs, exists because of a real defect found on 2026-08-16: the scanner emits RED / YELLOW / GREEN, two downstream files each wrote their own copy of that list, and both wrote AMBER, so a hybrid contract, exactly a client who has already started migrating, was reported as unmeasurable. Both sides were self-consistent, so nothing we had could see it. The vocabulary now has one exported source, and the test walks the whole path with a negative control that makes it able to fail. The same scanner returns RED about our own contracts. See VERIFY-US.md.
258 lines
11 KiB
JavaScript
258 lines
11 KiB
JavaScript
// bytecode.js, opcode-aware static analysis of EVM runtime bytecode for
|
|
// quantum exposure. Zero dependencies.
|
|
//
|
|
// Two techniques make this honest rather than a grep:
|
|
//
|
|
// 1. Opcode-aware decoding. A naive search for "610ae1" (PUSH2 0x0AE1) in the
|
|
// hex produces false positives, because those bytes can appear inside
|
|
// another PUSH's immediate data or straddle an instruction boundary. The
|
|
// decoder walks the instruction stream, skipping each PUSH's immediate, so a
|
|
// matched constant is a genuine PUSH of that value.
|
|
//
|
|
// 2. Local stack simulation to resolve CALL targets. The real question is not
|
|
// "does the constant 0x0AE1 appear" but "is a CALL/STATICCALL actually made
|
|
// TO that address". We simulate the stack per basic block (reset at
|
|
// JUMPDEST / after JUMP,JUMPI / after terminators) tracking constant PUSH
|
|
// values, and at every CALL-family opcode we read the address argument (the
|
|
// 2nd stack item). This distinguishes a precompile call to 0x0AE1 from the
|
|
// bare number 0x0AE5 used as a memory length near an unrelated staticcall
|
|
// (the false positive a proximity heuristic would hit).
|
|
//
|
|
// Fallback: the constants 0x0AE1..0x0AE4 (2785..2788) are not round numbers or
|
|
// common offsets, so a genuine opcode-aligned PUSH of one is a medium-confidence
|
|
// signal even when the CALL target is loaded from memory and cannot be resolved
|
|
// locally. The common literals 0x01, 0x100 and 0x0AE5 are NEVER used as a
|
|
// fallback signal; they only count when stack simulation resolves them as an
|
|
// actual CALL target.
|
|
|
|
import { CLASSICAL, PQC, knownAddressTable } from './precompiles.js';
|
|
|
|
const CALL_NAME = { 0xf1: 'CALL', 0xf2: 'CALLCODE', 0xf4: 'DELEGATECALL', 0xfa: 'STATICCALL' };
|
|
const CALL_OPS = new Set([0xf1, 0xf2, 0xf4, 0xfa]);
|
|
// pops before the address arg is consumed: CALL/CALLCODE take (gas,addr,value,..)
|
|
// STATICCALL/DELEGATECALL take (gas,addr,..); in all four addr is the 2nd item.
|
|
const CALL_ARITY = { 0xf1: 7, 0xf2: 7, 0xf4: 6, 0xfa: 6 };
|
|
const HALTS = new Set([0x00, 0xf3, 0xfd, 0xfe, 0xff]); // STOP RETURN REVERT INVALID SELFDESTRUCT
|
|
|
|
// (pops, pushes) for opcodes whose stack effect we model to keep alignment.
|
|
// PUSH/DUP/SWAP/CALL-family are handled specially and are not in this table.
|
|
const ARITY = {
|
|
0x01: [2, 1], 0x02: [2, 1], 0x03: [2, 1], 0x04: [2, 1], 0x05: [2, 1], 0x06: [2, 1], 0x07: [2, 1],
|
|
0x08: [3, 1], 0x09: [3, 1], 0x0a: [2, 1], 0x0b: [2, 1],
|
|
0x10: [2, 1], 0x11: [2, 1], 0x12: [2, 1], 0x13: [2, 1], 0x14: [2, 1], 0x15: [1, 1],
|
|
0x16: [2, 1], 0x17: [2, 1], 0x18: [2, 1], 0x19: [1, 1], 0x1a: [2, 1], 0x1b: [2, 1], 0x1c: [2, 1], 0x1d: [2, 1],
|
|
0x20: [2, 1],
|
|
0x30: [0, 1], 0x31: [1, 1], 0x32: [0, 1], 0x33: [0, 1], 0x34: [0, 1], 0x35: [1, 1], 0x36: [0, 1],
|
|
0x37: [3, 0], 0x38: [0, 1], 0x39: [3, 0], 0x3a: [0, 1], 0x3b: [1, 1], 0x3c: [4, 0], 0x3d: [0, 1],
|
|
0x3e: [3, 0], 0x3f: [1, 1],
|
|
0x40: [1, 1], 0x41: [0, 1], 0x42: [0, 1], 0x43: [0, 1], 0x44: [0, 1], 0x45: [0, 1], 0x46: [0, 1],
|
|
0x47: [0, 1], 0x48: [0, 1], 0x49: [1, 1], 0x4a: [0, 1],
|
|
0x50: [1, 0], 0x51: [1, 1], 0x52: [2, 0], 0x53: [2, 0], 0x54: [1, 1], 0x55: [2, 0],
|
|
0x56: [1, 0], 0x57: [2, 0], 0x58: [0, 1], 0x59: [0, 1], 0x5a: [0, 1], 0x5b: [0, 0],
|
|
0x5c: [1, 1], 0x5d: [2, 0], 0x5e: [3, 0],
|
|
0xa0: [2, 0], 0xa1: [3, 0], 0xa2: [4, 0], 0xa3: [5, 0], 0xa4: [6, 0],
|
|
0xf0: [3, 1], 0xf5: [4, 1],
|
|
};
|
|
|
|
/**
|
|
* Decode runtime bytecode into a flat instruction list.
|
|
* @param {Uint8Array} code
|
|
*/
|
|
export function decode(code) {
|
|
const ops = [];
|
|
let pc = 0;
|
|
let i = 0;
|
|
while (pc < code.length) {
|
|
const op = code[pc];
|
|
if (op >= 0x60 && op <= 0x7f) {
|
|
const n = op - 0x5f;
|
|
let val = 0n;
|
|
for (let k = 1; k <= n && pc + k < code.length; k++) val = (val << 8n) | BigInt(code[pc + k]);
|
|
ops.push({ i: i++, pc, op, push: val, pushLen: n });
|
|
pc += 1 + n;
|
|
} else {
|
|
ops.push({ i: i++, pc, op });
|
|
pc += 1;
|
|
}
|
|
}
|
|
return ops;
|
|
}
|
|
|
|
/**
|
|
* Resolve CALL-family targets via per-basic-block local stack simulation.
|
|
* @returns {Array<{op:number, name:string, pc:number, target:bigint|null}>}
|
|
*/
|
|
export function resolveCallTargets(ops) {
|
|
const calls = [];
|
|
let stack = []; // items: bigint (known) or null (unknown)
|
|
|
|
const pop = () => (stack.length ? stack.pop() : null);
|
|
|
|
for (const o of ops) {
|
|
const op = o.op;
|
|
|
|
if (op === 0x5b) { stack = []; continue; } // JUMPDEST starts a new block
|
|
|
|
if (op >= 0x60 && op <= 0x7f) { stack.push(o.push); continue; } // PUSHn (0x5f PUSH0 handled below)
|
|
if (op === 0x5f) { stack.push(0n); continue; } // PUSH0
|
|
|
|
if (op >= 0x80 && op <= 0x8f) { // DUPk
|
|
const k = op - 0x80 + 1;
|
|
stack.push(stack.length >= k ? stack[stack.length - k] : null);
|
|
continue;
|
|
}
|
|
if (op >= 0x90 && op <= 0x9f) { // SWAPk
|
|
const k = op - 0x90 + 1;
|
|
const idx = stack.length - 1 - k;
|
|
if (stack.length >= 1 && idx >= 0) {
|
|
const top = stack.length - 1;
|
|
const tmp = stack[top]; stack[top] = stack[idx]; stack[idx] = tmp;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (CALL_OPS.has(op)) {
|
|
// address is the 2nd stack item from the top (below gas)
|
|
const target = stack.length >= 2 ? stack[stack.length - 2] : null;
|
|
calls.push({ op, name: CALL_NAME[op], pc: o.pc, target });
|
|
for (let k = 0; k < CALL_ARITY[op]; k++) pop();
|
|
stack.push(null); // success flag
|
|
if (op === 0x56 || op === 0x57) stack = [];
|
|
continue;
|
|
}
|
|
|
|
if (op === 0x56 || op === 0x57) { // JUMP / JUMPI
|
|
const arity = ARITY[op];
|
|
for (let k = 0; k < arity[0]; k++) pop();
|
|
stack = []; // next instruction is a new block
|
|
continue;
|
|
}
|
|
|
|
if (HALTS.has(op)) {
|
|
const a = ARITY[op];
|
|
if (a) for (let k = 0; k < a[0]; k++) pop();
|
|
stack = [];
|
|
continue;
|
|
}
|
|
|
|
const a = ARITY[op];
|
|
if (a) {
|
|
for (let k = 0; k < a[0]; k++) pop();
|
|
for (let k = 0; k < a[1]; k++) stack.push(null);
|
|
} else {
|
|
// unknown opcode: be conservative, drop the block alignment
|
|
stack = [];
|
|
}
|
|
}
|
|
return calls;
|
|
}
|
|
|
|
/**
|
|
* Analyze runtime bytecode for classical and post-quantum precompile usage.
|
|
* @param {Uint8Array} code runtime bytecode (empty => caller treats as EOA)
|
|
*/
|
|
// SINGURA SURSA a vocabularului de verdicte. Exportata anume, fiindca doua consumatoare si-au
|
|
// scris fiecare propria lista si una a scris AMBER acolo unde scanerul scrie YELLOW: un contract
|
|
// hibrid, adica exact clientul care a inceput deja migrarea si e cel mai probabil sa plateasca,
|
|
// cadea in NOT MEASURED atat in raportul platit cat si in scanul gratuit. Masurat 2026-08-16.
|
|
// Cine adauga un verdict il adauga AICI, si consumatoarele il primesc fara sa fie atinse.
|
|
export const VERDICTE = Object.freeze(['GREEN', 'YELLOW', 'RED']);
|
|
|
|
// Cum se citeste verdictul dintr-o iesire de text. Consumatoarele cheama ASTA, nu isi scriu
|
|
// propriul tipar: un tipar copiat e a doua sursa a aceluiasi adevar, si exact asta a fost
|
|
// defectul. Nu foloseste expresii regulate, ca sa nu existe nici macar un sir de escapat gresit.
|
|
// Intoarce numele verdictului, sau null daca textul nu poarta niciunul.
|
|
export function citesteVerdict(text) {
|
|
for (const linie of String(text).split(/\r?\n/)) {
|
|
const cuvant = linie.trim().split(/[^A-Z]+/).filter(Boolean)[0];
|
|
if (cuvant && VERDICTE.includes(cuvant)) return cuvant;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function analyzeBytecode(code) {
|
|
const ops = decode(code);
|
|
const table = knownAddressTable();
|
|
const calls = resolveCallTargets(ops);
|
|
|
|
// Resolved CALL targets that land on a known band address (definitive).
|
|
const resolved = calls
|
|
.filter((c) => c.target !== null && table.has(c.target))
|
|
.map((c) => ({ ...table.get(c.target), address: '0x' + c.target.toString(16), callOpcode: c.name, pc: c.pc }));
|
|
|
|
const pqcResolved = resolved.filter((r) => r.band === 'pqc-live');
|
|
const pqcTestnetResolved = resolved.filter((r) => r.band === 'pqc-testnet');
|
|
const ecrecoverResolved = resolved.filter((r) => r.key === 'ecrecover');
|
|
const p256Resolved = resolved.filter((r) => r.key === 'p256');
|
|
|
|
// Fallback presence signal: an opcode-aligned PUSH of a SIGNATURE-verify PQC
|
|
// address 0x0AE1..0x0AE4 (not 0x0AE5, not the common literals). Medium
|
|
// confidence: the constant is genuinely present even if we could not resolve
|
|
// the CALL target locally (e.g. it was staged through memory).
|
|
const sigPqcAddrs = new Set([PQC.falcon512.address, PQC.falcon1024.address, PQC.mldsa44.address, PQC.slhdsa128s.address]);
|
|
const pqcConstPresent = [];
|
|
for (const o of ops) {
|
|
if (o.push !== undefined && sigPqcAddrs.has(o.push)) {
|
|
const d = table.get(o.push);
|
|
pqcConstPresent.push({ address: '0x' + o.push.toString(16), name: d.name, pc: o.pc });
|
|
}
|
|
}
|
|
|
|
const uniqByAddr = (arr) => {
|
|
const m = new Map();
|
|
for (const x of arr) if (!m.has(x.address)) m.set(x.address, x);
|
|
return [...m.values()];
|
|
};
|
|
|
|
const pqcResolvedU = uniqByAddr(pqcResolved);
|
|
const pqcConstU = uniqByAddr(pqcConstPresent).filter((c) => !pqcResolvedU.find((r) => r.address === c.address));
|
|
|
|
const pqcUsed = pqcResolvedU.length > 0 || pqcConstU.length > 0;
|
|
const classicalUsed = ecrecoverResolved.length > 0 || p256Resolved.length > 0;
|
|
|
|
let readiness;
|
|
if (pqcUsed && classicalUsed) readiness = 'YELLOW';
|
|
else if (pqcUsed) readiness = 'GREEN';
|
|
else readiness = 'RED';
|
|
|
|
const pqcLivePrecompiles = [
|
|
...pqcResolvedU.map((r) => ({ address: r.address, name: r.name, callProximate: true, confidence: 'high' })),
|
|
...pqcConstU.map((c) => ({ address: c.address, name: c.name, callProximate: false, confidence: 'medium' })),
|
|
];
|
|
|
|
return {
|
|
kind: 'contract',
|
|
readiness,
|
|
bytecodeLength: code.length,
|
|
signals: {
|
|
pqcLivePrecompiles,
|
|
pqcTestnetPrecompiles: uniqByAddr(pqcTestnetResolved).map((r) => ({ address: r.address, name: r.name })),
|
|
p256: p256Resolved.length > 0
|
|
? { address: '0x100', name: CLASSICAL.p256.name, count: p256Resolved.length, confidence: 'high' }
|
|
: null,
|
|
ecrecover: ecrecoverResolved.length > 0
|
|
? { address: '0x1', name: CLASSICAL.ecrecover.name, count: ecrecoverResolved.length, confidence: 'high' }
|
|
: null,
|
|
},
|
|
flags: buildFlags({ pqcResolvedU, pqcConstU, pqcTestnetResolved, pqcUsed }),
|
|
detail: {
|
|
callsAnalyzed: calls.length,
|
|
resolvedBandCalls: resolved.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildFlags({ pqcResolvedU, pqcConstU, pqcTestnetResolved, pqcUsed }) {
|
|
const flags = [];
|
|
if (pqcConstU.length > 0 && pqcResolvedU.length === 0) {
|
|
flags.push('[VERIFY] A live PQC precompile address (0x0AE1..0x0AE4) is pushed but no CALL to it was resolved by local stack analysis; the call target may be staged through memory. Confirm against source or a call trace.');
|
|
}
|
|
if (pqcTestnetResolved.length > 0) {
|
|
flags.push('[VERIFY] Call to a TESTNET-only PQC precompile (0x0AE6..0x0AE8); these are NOT live on mainnet 2800 and return empty there.');
|
|
}
|
|
if (!pqcUsed) {
|
|
flags.push('Bytecode scanning only sees NATIVE precompile calls (0x0AE1..0x0AE5). A contract that verifies Falcon via the SOLIDITY AereFalcon512Verifier (0x4E8e...D8fFC) delegates to a normal contract address and will read as RED here even though it is PQC-capable. Confirm the auth path against source.');
|
|
}
|
|
return flags;
|
|
}
|