sevm
Version:
A Symbolic Ethereum Virtual Machine (EVM) bytecode decompiler & analyzer library & CLI
442 lines (403 loc) • 17.8 kB
JavaScript
/* eslint-env node */
import c from 'ansi-colors';
import assert from 'assert';
import envPaths from 'env-paths';
import { existsSync, mkdirSync, promises, readFileSync, writeFileSync } from 'fs';
import js_sha3 from 'js-sha3';
import path from 'path';
import { debuglog } from 'util';
import yargs from 'yargs';
import { Contract, sol, yul, ERCIds } from 'sevm';
import 'sevm/4byte';
import { isValidAddress } from './.address.mjs';
import { Provider } from './.provider.mjs';
const paths = envPaths('sevm');
const { cyan: info, yellow: warn } = c;
const trace = debuglog('sevm');
/**
* @param {Contract} contract
* @param {import('yargs').ArgumentsCamelCase} argv
*/
function dis(contract, argv) {
const MAX_STACK = 10;
console.info(`${c.dim('pc'.padStart(5))} ${c.magenta('opcode')} ${'push data (PUSHx)'}`);
for (const chunk of contract.chunks()) {
console.info(c.blue(chunk.pcbegin.toString()), ':', chunk.states === undefined ? c.red('unreachable') : '');
if (chunk.content instanceof Uint8Array) {
console.info(Buffer.from(chunk.content).toString('hex'));
} else {
const block = contract.blocks.get(chunk.pcbegin);
for (const { opcode, stack } of block?.opcodes ?? []) {
const pc = opcode.pc.toString().padStart(5);
const pushData = opcode.data
? (opcode.mnemonic.length === 5 ? ' ' : '') + `0x${opcode.hexData()}`
: '';
let values;
if (argv['with-stack']) {
values = info('〒 ');
values += stack === undefined
? warn('<no stack>')
: stack.values.slice(0, MAX_STACK).map(e => yul`${e}`).join(c.dim('|')) +
(stack.values.length > MAX_STACK ? c.dim(`| ..${stack.values.length - MAX_STACK} more items`) : '');
} else {
values = '';
}
console.info(`${c.dim(pc)} ${c.magenta(opcode.mnemonic)} ${pushData} ${values}`);
}
}
if (argv['with-trace'] && chunk.states !== undefined) {
for (const state of chunk.states) {
console.info('state', '〒 ', state.stack.values.map(e => yul`${e}`).join(' | '));
state.stmts.forEach(stmt => console.info(' ', yul`${stmt}`));
}
}
}
}
/**
* @param {string} pathOrAddress
* @param {boolean} cache
* @param {string} rpcUrl
* @returns {Promise<string | null>}
*/
async function getBytecode(pathOrAddress, cache, rpcUrl) {
const readInputFile = async () => {
if (pathOrAddress === '') {
const buffer = readFileSync(process.stdin.fd, 'utf-8').trim();
if (buffer !== '') return Promise.resolve(buffer);
throw new Error('No input from stdin');
}
return (await promises.readFile(pathOrAddress, 'utf8')).trimEnd();
};
/** @param {unknown} field */
const fromJSONField = field =>
field !== null && typeof field === 'object' && 'object' in field && typeof field['object'] === 'string'
? field['object']
: typeof field === 'string'
? field
: null;
const tries = [
async () => {
const text = await readInputFile();
/** @type {Record<string, unknown>} */
let json;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
json = JSON.parse(text);
} catch {
return text;
}
const { deployedBytecode, bytecode } = json;
const value = fromJSONField(deployedBytecode) ?? fromJSONField(bytecode);
if (value !== null)
return value;
throw new Error('Cannot find `deployedBytecode`|`bytecode` in json file');
},
async () => {
const cacheFolder = path.join(paths.cache, 'mainnet');
const cachePath = path.join(cacheFolder, `${pathOrAddress}.bytecode`);
try {
if (!cache) throw new Error(`Cache to fetch contract bytecode disabled`);
return await promises.readFile(cachePath, 'utf8');
} catch (err) {
trace('%s', err instanceof Error ? err.message : err);
const provider = new Provider(rpcUrl);
if (!isValidAddress(pathOrAddress)) throw new Error('Invalid address, bad address checksum');
const bytecode = await provider.getCode(pathOrAddress);
trace('Contract bytecode fetched from remote network');
if (cache) {
if (!existsSync(cacheFolder)) {
mkdirSync(cacheFolder, { recursive: true });
}
writeFileSync(cachePath, bytecode, 'utf8');
}
return bytecode;
}
},
];
for (const fn of tries) {
try {
return await fn();
} catch (err) {
const msg = err instanceof Error ? err.message : err;
trace('%s', msg);
}
}
return null;
}
/** @param {(contract: Contract, argv: import('yargs').ArgumentsCamelCase) => void} handler */
function make(handler) {
/** @param {import('yargs').ArgumentsCamelCase} argv */
return async argv => {
const pathOrAddress = /** @type {string} */ (argv['contract']);
const cache = /**@type {boolean}*/(argv['cache']);
const rpcUrl = /**@type {string}*/(argv['rpc-url']);
const bytecode = await getBytecode(pathOrAddress, cache, rpcUrl);
const name = pathOrAddress === '' ? '-' : pathOrAddress;
if (bytecode === null) {
console.error(warn(`Cannot find bytecode for contract ${info(name)}`));
process.exit(2);
} else if (bytecode.toLowerCase() === '0x') {
console.error(warn(`Bytecode for contract ${info(name)} is '0x', it might have been self-destructed or it is an EOA`));
process.exit(3);
} else {
try {
let contract = new Contract(bytecode);
if (argv['patch']) {
const hash = '0x' + js_sha3.keccak256(contract.bytecode).substring(0, 20);
trace('Bytecode keccak256 hash', hash);
const abisFolder = path.join(paths.cache, 'abis');
const abiPath = path.join(abisFolder, `${hash}.abi.json`);
/** @type {object | undefined} */
let lookup;
if (cache && existsSync(abiPath)) {
trace('Found ABI cache %s', abiPath);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
lookup = JSON.parse(readFileSync(abiPath, 'utf8'));
} else {
if (cache) trace('ABI cache %s not found', abiPath);
else trace('Cache ABI disabled');
lookup = {};
}
contract = await contract.patch(lookup);
if (cache && !existsSync(abiPath)) {
if (!existsSync(abisFolder)) {
mkdirSync(abisFolder, { recursive: true });
}
writeFileSync(abiPath, JSON.stringify(lookup, null, 2));
}
}
handler(contract, argv);
} catch (err) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
console.error(`${err}`);
process.exit(1);
}
}
};
}
/** @param {import('yargs').Argv} argv */
const pos = argv => argv.positional('contract', {
type: 'string',
describe: 'Path or a Ethereum address where to locate the bytecode of the contract. When `-` is used, bytecode will be read from standard input.',
});
/** @param {import('yargs').Argv} argv */
const decompileOpts = argv => pos(argv).option('reduce', {
description: `Simplify the contract by reducing statements and inlining expressions before decompiling ${warn('[experimental]')}`,
});
/**
*
*/
const DEFAULT_RPC_URL = 'https://cloudflare-eth.com/';
void yargs(process.argv.slice(2))
.scriptName('sevm')
.usage('$0 <cmd> <contract>\n\nCLI tool to analyze EVM bytecode')
.command('metadata <contract>', 'Shows the Metadata of the contract[1]', pos, make(contract => {
console.info(c.underline('Contract Metadata'));
if (contract.metadata) {
console.info(c.blue('protocol'), contract.metadata.protocol);
console.info(c.blue('hash'), contract.metadata.hash);
console.info(c.blue('solc'), contract.metadata.solc);
console.info(c.blue('url'), contract.metadata.url);
} else {
console.info(warn('No metadata'));
}
}))
.command('abi <contract>', 'Shows the ABI of the contract[2]', pos, make(contract => {
const functions = Object.values(contract.functions)
.map(fn => ['0x' + fn.selector, fn.label]);
const events = Object.entries(contract.events)
.map(([selector, event]) => ['0x' + selector, event.sig]);
const notfound = c.dim('<signature not found>');
console.info(c.underline('Function Selectors'));
functions.forEach(([selector, sig]) => console.info(' ', selector, sig !== undefined ? c.cyan(sig) : notfound));
console.info();
console.info(c.underline('Events'));
events.forEach(([selector, sig]) => console.info(' ', selector, sig !== undefined ? c.magenta(sig) : notfound));
}))
.command('selectors <contract>', 'Shows the function selectors of the contract[3]', pos, make(contract => {
for (const [selector, fn] of Object.entries(contract.functions)) {
console.info(
c.cyan('0x' + selector),
fn.label === undefined ? warn('<no signature>') : fn.label
);
}
}))
.command('dis <contract>', 'Disassemble the bytecode into Opcodes', argv => pos(argv)
.option('with-stack', {
description: 'Include the current stack next to each decoded opcode',
})
.option('with-trace', {
description: 'Include the trace of statements at the end of each basic block',
}), make(dis))
.command('cfg <contract>', 'Writes the cfg of the selected function in `dot` format into standard output', pos, make(cfg))
.command('sol <contract>', "Decompile the contract into Solidity-like source", decompileOpts, make((contract, argv) => {
console.info((argv['reduce'] ? contract.reduce() : contract).solidify());
}))
.command('yul <contract>', "Decompile the contract into Yul-like source[4]", decompileOpts, make((contract, argv) => {
console.info((argv['reduce'] ? contract.reduce() : contract).yul());
}))
.command('ercs <contract>', 'Try to detect supported ERCs in the bytecode contract based on function and events selectors', argv => pos(argv)
.option('check-events', {
description: 'Check for events when detecting ERCs, use `--no-check-events` to only use function selectors when detect ERC',
default: true,
}),
make((contract, argv) => {
const checkEvents = !!argv['checkEvents'];
const ercs = [];
for (const erc of ERCIds) {
if (contract.isERC(erc, checkEvents)) {
ercs.push(erc);
}
}
if (ercs.length === 0) {
console.info(c.dim('No supported ERCs detected in this contract'));
} else {
console.info('Detected ERCs');
ercs.forEach(erc => console.info(` - ${c.magenta(erc)}`));
}
}))
.command('config', 'Shows cache path used to store downloaded bytecode', {}, () =>
console.info(paths.cache)
)
.command('supported-ercs', 'Shows supported ERCs that can be detected', {}, () =>
ERCIds.forEach(erc => console.info(erc))
)
.middleware(argv => {
if (!argv['color']) {
c.enabled = false;
}
})
.option('color', {
type: 'boolean',
description: 'Displays with colors, use `--no-color` to deactivate colors',
default: true,
})
.option('patch', {
type: 'boolean',
description: 'Patches the Contract public functions and events with signatures from https://openchain.xyz, use `--no-patch` to skip patching',
default: true,
})
.option('cache', {
type: 'boolean',
description: 'Enables cache of contracts and ABIs fetched from remote networks and https://openchain.xyz respectively, use `--no-cache` to skip catching',
default: true,
})
.option('rpc-url', {
type: 'string',
description: 'JSON-RPC network provider URL. Alternatively, set the env variable `SEVM_RPC_URL` (the flag takes precedence over the env variable)',
default: process.env['SEVM_RPC_URL'] ?? DEFAULT_RPC_URL,
defaultDescription: `"${DEFAULT_RPC_URL}"`,
})
// .option('selector', {
// alias: 's',
// type: 'string',
// description:
// 'Function signature, e.g., `balanceOf(address)` or selector hash to choose a specific function',
// })
.demandCommand(1, 'At least one command must be specified')
.recommendCommands()
.example('$0 abi 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', '')
.example('$0 sol 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', '')
.example('$0 sol --no-patch 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', '')
.example('echo 0x600160020160005500 | $0 yul -', 'Use `-` to read bytecode from stdin')
.epilog(
`[1] See https://docs.soliditylang.org/en/latest/metadata.html for more information regarding Metadata generated by the Solidity compiler.
[2] See https://docs.soliditylang.org/en/latest/abi-spec.html#abi-json for more information regarding the ABI specification.
[3] See https://docs.soliditylang.org/en/latest/abi-spec.html#function-selector for more information regarding Function Selectors
[4] See https://docs.soliditylang.org/en/latest/yul.html for more information regarding Yul.`
)
.strict()
.help().argv;
/** @param {Contract} contract */
function cfg(contract) {
/** @type {WeakMap<import('sevm').State, string>} */
const ids = new WeakMap();
let id = 0;
for (const block of contract.blocks.values()) {
for (const state of block.states) {
assert(!ids.has(state));
if (!ids.has(state)) {
ids.set(state, `id-${id}`);
id++;
}
}
}
const write = console.log;
write(`digraph G {
color="#efefef";
#rankdir = LR;
#graph[fontsize=6];
node[shape=box style=filled fontsize=12 fontname="Verdana" fillcolor="#efefef"];
`);
let edges = '';
for (const [pc, block] of contract.blocks) {
write(`subgraph cluster_${pc} {`);
write(` style=filled;`);
let label = `pc ${pc}\\l`;
// for (let i = pc; i < chunk.pcend; i++) {
// const opcode = evm.opcodes[i];
// label += opcode.formatOpcode() + '\\l';
// }
write(` label = "${label}";`);
for (const state of block.states) {
writeNode(pc, state);
switch (state.last?.name) {
case 'Jumpi':
writeEdge(state, state.last.destBranch);
writeEdge(state, state.last.fallBranch);
break;
case 'SigCase':
// writeEdge(pc, state.last.condition.hash);
writeEdge(state, state.last.fallBranch);
break;
case 'Jump':
writeEdge(state, state.last.destBranch);
break;
case 'JumpDest':
writeEdge(state, state.last.fallBranch);
break;
default:
}
}
write('}\n');
}
write(edges);
write('}');
/**
* @param {number} pc
* @param {import('sevm').State} state
*/
function writeNode(pc, state) {
const id = ids.get(state);
let label = `key:${pc} ${id}`;
label += '\\l';
// label += 'doms: ' + [...doms].join(', ');
// label += '\\l';
// if (tree) {
// label += 'tree: ' + [...tree].join(', ');
// label += '\\l';
// }
// label += block.entry.state.stack.values.map(elem => `=| ${elem.toString()}`).join('');
// label += '\\l';
// label += block.opcodes.map(op => formatOpcode(op)).join('\\l');
// label += '\\l';
label += state.stack.values.map(elem => sol`=| ${elem.eval()}`).join('');
label += '\\l';
// label += inspect(state.memory);
// label += '\\l';
for (const [k, v] of state.memory.entries())
label += sol`${k}: ${v}\\l`;
label += state.stmts.map(stmt => sol`${stmt}`).join('\\l');
label += '\\l';
write(`"${id}" [label="${label}" fillcolor="${'#ffa500'}"];`);
}
/**
* @param {import('sevm').State} src
* @param {import('sevm/ast').Branch} branch
*/
function writeEdge(src, branch) {
const id = ids.get(src);
edges += `"${id}" -> "${ids.get(branch.state)}";\n`;
}
}