Code Snippets #1: What the heck is hardhat's console.log?

Mar 24, 2023Last modified: Mar 24, 2023

I was tasked to create ethereum smart contracts in my previous job.

the library most recommended by other developers to create smart contracts was hardhat.

to create and debug the smart contracts, I developed them using test-driven-development methods. The tests gave me a way to print out and inspect variables.

just as I was done with the development I read somewhere that hardhat has a console.log() utility. You import it with import hardhat/console.sol and use it like JS’s console.log. I wondered how it worked. Upon testing, it seemed to only allow a maximum amount of parameters.

so i decided to look at hardhat’s internals and find out how they do it.

the first step was to find the console.log() utility function.

this is their repository https://github.com/NomicFoundation/hardhat

1$ ls 
2config  CONTRIBUTING.md  crates  docs  LICENSE  package.json  packages  README.md  scripts  yarn.lock

im not familliar with their file structure conventions. But I can guess that the source code is not in config/, docs/, or scipts/.

1$ ls crates packages
2crates:
3rethnet_evm_napi
4
5packages:
6common                 hardhat-core       hardhat-foundry          hardhat-solhint  hardhat-truffle4  hardhat-waffle
7eslint-plugin          hardhat-ethers     hardhat-network-helpers  hardhat-solpp    hardhat-truffle5  hardhat-web3
8hardhat-chai-matchers  hardhat-etherscan  hardhat-shorthand        hardhat-toolbox  hardhat-vyper     hardhat-web3-legacy

i didn’t know what crates/ were so I also tried it. anyway, packages/ contains hardhat-core/. Inside it we’ll find console.sol.

let’s see the code.

1$ cat packages/hardhat-core/console.log

link to it: https://github.com/NomicFoundation/hardhat/blob/main/packages/hardhat-core/console.sol

opening it, we find 1513 lines of repetitive, possibly auto-generated code.

here’s an interesting part of the code:

 1$ cat packages/hardhat-core/console.sol  -n | tail -n 30
 21503
 31504          function log(address p0, address p1, bool p2, string memory p3) internal view {
 41505                  _sendLogPayload(abi.encodeWithSignature("log(address,address,bool,string)", p0, p1, p2, p3));
 51506          }
 61507
 71508          function log(address p0, address p1, bool p2, bool p3) internal view {
 81509                  _sendLogPayload(abi.encodeWithSignature("log(address,address,bool,bool)", p0, p1, p2, p3));
 91510          }
101511
111512          function log(address p0, address p1, bool p2, address p3) internal view {
121513                  _sendLogPayload(abi.encodeWithSignature("log(address,address,bool,address)", p0, p1, p2, p3));
131514          }
141515
151516          function log(address p0, address p1, address p2, uint256 p3) internal view {
161517                  _sendLogPayload(abi.encodeWithSignature("log(address,address,address,uint256)", p0, p1, p2, p3));
171518          }
181519
191520          function log(address p0, address p1, address p2, string memory p3) internal view {
201521                  _sendLogPayload(abi.encodeWithSignature("log(address,address,address,string)", p0, p1, p2, p3));
211522          }
221523
231524          function log(address p0, address p1, address p2, bool p3) internal view {
241525                  _sendLogPayload(abi.encodeWithSignature("log(address,address,address,bool)", p0, p1, p2, p3));
251526          }
261527
271528          function log(address p0, address p1, address p2, address p3) internal view {
281529                  _sendLogPayload(abi.encodeWithSignature("log(address,address,address,address)", p0, p1, p2, p3));
291530          }
301531
311532  }

this looks fascinating to me. I’m less familiar with statically-typed languages, so I don’t know how common this method is. I’ll explain why it intrigues me.

most of the file consist of functions with the name log. In python or probably any dynamically-typed languages, this would not work. For example, if log() is called, The interpreter would not know which log, to use out of the many logs defined.

11524          function log(address p0, address p1, address p2, bool p3) internal view {
21525                  _sendLogPayload(abi.encodeWithSignature("log(address,address,address,bool)", p0, p1, p2, p3));
31526          }
41527
51528          function log(address p0, address p1, address p2, address p3) internal view {
61529                  _sendLogPayload(abi.encodeWithSignature("log(address,address,address,address)", p0, p1, p2, p3));
71530          }

in solidity, this is a completely valid use case. Although the function names are the same, the compiler can determine which log to use by looking at the data types of the arguments, and finding which log function has the correct parameter data types.

11524          function log(address p0, address p1, address p2, bool p3) internal view {
21528          function log(address p0, address p1, address p2, address p3) internal view {

For example, these two function definitions are almost the same, except the fourth parmeter. Line 1524 handles log calls with specifically 4 parameters with respective types: address, address , address, and bool.

Whereas Line 1528 handles log calls that pass exactly 4 address data types.

11524          function log(address p0, address p1, address p2, bool p3) internal view {
21525                  _sendLogPayload(abi.encodeWithSignature("log(address,address,address,bool)", p0, p1, p2, p3));
31526          }

This design means that only a fixed number of arguments can be accepted by hardhat’s console.log. Console.log will not work if it’s supplied with arguments that are not pre-defined in console.sol.

I later found the script that generates this code. It’s in hardhat/packages/hardhat-core/scripts/console-library-generator.js.

The parameters p0 to p3, are passed to abi.encodeWithSignature which is then passed to _sendLogPayload.

 1$ cat packages/hardhat-core/console.log -n | head -n 14
 2 1  // SPDX-License-Identifier: MIT
 3 2  pragma solidity >= 0.4.22 <0.9.0;
 4 3
 5 4  library console {
 6 5          address constant CONSOLE_ADDRESS = address(0x000000000000000000636F6e736F6c652e6c6f67);
 7 6
 8 7          function _sendLogPayload(bytes memory payload) private view {
 9 8                  uint256 payloadLength = payload.length;
10 9                  address consoleAddress = CONSOLE_ADDRESS;
1110                  assembly {
1211                          let payloadStart := add(payload, 32)
1312                          let r := staticcall(gas(), consoleAddress, payloadStart, payloadLength, 0, 0)
1413                  }
1514          }

At first glance, I can deduce that Hardhat sends the messages from console.log to a dedicated public address. It then scans the address for messages and outputs any messages that are sent to it to the terminal.

So that’s Hardhat’s console.log. We looked at its internals and found mysterious code that turned out to be a smart solution to the problem.