I went down this rabbit hole and got a proof of concept to work at the end. I can not recommend the journey. There's impedance mismatches on many levels, requiring lots of format conversions. In the end, my implementation still does not handle cross-contract calls. (There seems to be no way to figure out which contract address a particular program counter belongs too, short of interpreting the call instructions).
My implementation is too dirty to share, but the main steps are:
1) You need solc to produce a runtime sourcemap. It can not directly output this, but it can output this as part of the 'combined json output'. For this, run 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) The sourcemap format is compressed and you need to write a decoder. Specification of the formati is in the solidity documentation. You now have a way to map instruction-indices to source offsets.
3) we don't want byte offsets in the source files, but line and column numbers. For this, you need to parse the source files and create a mapping from byte offset to line/column pairs. I decided to ignore this for now and used the get-line-from-pos npm package.
Step 2 and 3 together are:
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) The source map is in instruction number, but we need bytecode addresses. To solve this we need to built a map from bytecode ofset to instruction number (or the other way). I found it easiest to just parse the runtime binary myself. All instructions are 1 byte long, except for PUSH_n which are n+1 long.
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;
};
Then you need to get the backtrace for a given transaction:
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");
Once you have all that, you can get a something ressembling a classic stracktrace:
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]
]}`
)
);
I hope to turn clean this up and turn it into a library soon. The ablity to handle traces and map them back to solidity has many uses.