¿Cómo mapear el seguimiento de EVM a la fuente del contrato?

En otras palabras, ¿cómo obtener el "trace de pila clásico" de una transacción fallida?

Por ejemplo, tenemos un rastro (no mire "Missing opcode 0xfd" - es la instrucción de reversión) y la fuente de solidez del contrato . ¿Cómo saber en qué línea de la fuente se lanzó la excepción?

Logré ensamblar el contrato (usando solc --asm), pero no hay sugerencias de PC (contador de programa), por lo que no puedo encontrar una línea que corresponda a PC = 557. Además, creo que la optimización se realizó durante la compilación, pero a pesar de eso, el ensamblaje aún se puede leer hasta cierto punto.

Estoy usando solc 0.4.16+commit.d7661dd9.Linux.g++.

Gracias por adelantado.

Respuestas (2)

Bajé por este agujero de conejo y obtuve una prueba de concepto para trabajar al final. No puedo recomendar el viaje. Hay discrepancias de impedancia en muchos niveles, lo que requiere muchas conversiones de formato. Al final, mi implementación aún no maneja llamadas de contratos cruzados. (Parece que no hay forma de averiguar a qué dirección de contrato pertenece un contador de programa en particular, aparte de interpretar las instrucciones de llamada).

Mi implementación es demasiado sucia para compartir, pero los pasos principales son:

1) Necesita solcproducir un mapa fuente en tiempo de ejecución. No puede generar esto directamente, pero puede generarlo como parte de la 'salida json combinada'. Para esto, ejecuta solc --combined-json bin-runtime,srcmap-runtime.

const srcmaps = JSON.parse(fs.readFileSync("./Contract.json"));
const srcmap =
  srcmaps.contracts["./contracts/Contract.sol:Contract"]["srcmap-runtime"];

const source = fs.readFileSync("./contracts/Contract.sol").toString();

const bin = Buffer.from(
  srcmaps.contracts["./contracts/Contract.sol:Contract"]["bin-runtime"],
  "hex"
);

2) El formato del mapa fuente está comprimido y necesita escribir un decodificador. La especificación de los formatos se encuentra en la documentación de solidez. Ahora tiene una forma de asignar índices de instrucción a compensaciones de origen.

3) no queremos compensaciones de bytes en los archivos fuente, sino números de línea y columna. Para esto, debe analizar los archivos de origen y crear una asignación desde el desplazamiento de bytes hasta los pares de línea/columna. Decidí ignorar esto por ahora y usé el get-line-from-pospaquete npm.

Los pasos 2 y 3 juntos son:

const parsed = srcmap
  .split(";")
  .map(l => l.split(":"))
  .map(([s, l, f, j]) => ({ s: s === "" ? undefined : s, l, f, j }))
  .reduce(
    ([last, ...list], { s, l, f, j }) => [
      {
        s: parseInt(s || last.s, 10),
        l: parseInt(l || last.l, 10),
        f: parseInt(f || last.f, 10),
        j: j || last.j
      },
      last,
      ...list
    ],
    [{}]
  )
  .reverse()
  .slice(1)
  .map(
    ({ s, l, f, j }) => `${srcmaps.sourceList[f]}:${getLineFromPos(source, s)}`
  );

4) El mapa de origen está en el número de instrucción, pero necesitamos direcciones de bytecode. Para resolver esto, necesitamos construir un mapa desde el bytecode offset hasta el número de instrucción (o al revés). Me resultó más fácil analizar el binario de tiempo de ejecución yo mismo. Todas las instrucciones tienen 1 byte de largo, excepto PUSH_nlas que son n+1largas.

const isPush = inst => inst >= 0x60 && inst < 0x7f;

const pushDataLength = inst => inst - 0x5f;

const instructionLength = inst => (isPush(inst) ? 1 + pushDataLength(inst) : 1);

const byteToInstIndex = bin => {
  const result = [];
  let byteIndex = 0;
  let instIndex = 0;
  while (byteIndex < bin.length) {
    const length = instructionLength(bin[byteIndex]);
    for (let i = 0; i < length; i += 1) {
      result.push(instIndex);
    }
    byteIndex += length;
    instIndex += 1;
  }
  return result;
};

Luego, debe obtener el seguimiento de una transacción determinada:

const promisify = func => async (...args) =>
  new Promise((accept, reject) =>
    func(...args, (error, result) => (error ? reject(error) : accept(result)))
  );

const rpcCommand = method => async (...params) =>
  (await promisify(web3.currentProvider.sendAsync)({
    jsonrpc: "2.0",
    method,
    params,
    id: Date.now()
  })).result;

const traceTransaction = rpcCommand("debug_traceTransaction");

Una vez que tenga todo eso, puede obtener algo parecido a un stracktrace clásico:

const trace = await traceTransaction(result.tx);
trace.structLogs.forEach(({op, pc, gasCost}) =>
  console.log(
    `${pc}\t${op}\t${gasCost}\t${byteToInstr[pc]}\t${parsed[
      byteToInstr[pc]
    ]}`
  )
);

Espero poder limpiar esto y convertirlo en una biblioteca pronto. La capacidad de manejar rastros y mapearlos de nuevo a la solidez tiene muchos usos.

Gracias @Remco, realizaste un gran trabajo, esa publicación explica muchas cosas. En mi opinión, la falta de solución al problema en cuestión es una gran deficiencia de la infraestructura de ethereum. Espero que pronto llegue una solución.
Ha pasado más de 1 año desde que se publicó esto. ¿Alguna actualización sobre mejores herramientas?
Gran trabajo @Remco. Tengo un ejemplo de contrato en el que hay alrededor de 100 códigos de operación, pero sourceMap solo tiene alrededor de 15 ";". ¡Pensé que las entradas de sourceMap deberían ser iguales a la cantidad de instrucciones en el código de operación! ¿Me estoy perdiendo algo, tal vez compresión? ¡Gracias de antemano!
Actualización de 2021: Remix Debugger maneja bastante bien los rastros de pila ahora.
Consulte también source-maps.ts de Hardhat .
@JuanIgnacioPérezSacristán Hay compresión, sí. docs.soliditylang.org/en/v0.7.5/internals/source_mappings.html

Si puede obtener el código fuente del contrato, puede usar Hardhat para obtener los rastros de la pila de Solidity. Hardhat Network es una primera implementación de EVM de depuración, creada para el desarrollo de bajo nivel de contratos inteligentes.

Complemento desvergonzado: comience desde mi plantilla Solidity, que usa Hardhat: https://github.com/paulrberg/solidity-template :

Nota al margen: consulte el anuncio de Nomic Labs .