Workshop Devcon 2024
This website contains step by step instructions of getting to know how to use discovery. The process follows uncovering contracts that constitute Zora from the scratch. Along the way we’ll explore discovery itself as well as many of it’s features like custom handlers, source code flattening and template matching.
I’ll be going over all of these steps and explaining what is happening at every stop. Feel invited to follow me during the presentation or after it at your own speed.
Installation and configuration
Discovery is written in TypeScript, so to install it run:
npm install @mradomski/discovery
Warning: please keep in mind that this is a fork of the actual L2BEAT repository for the purposes of the presentation. For up to date code checkout L2BEAT repo.
Test if the installation was successful by running:
npx discovery
You should see something like this:
discovery <subcommand>
where <subcommand> can be one of:
- single-discovery
- invert
- discover
For more help, try running `discovery <subcommand> --help`
Now you’ll need to configure the environment variables that we’re going to be using.
Use .env
for that purpose or just export VAR_NAME=value
, whatever you prefer.
Because discovery makes a lot of calls you’ll need to provide an RPC more powerfull than just some random public one.
For the simplicity I’m going to be using a free Alchemy account as the RPC provider.
Configure the following:
ETHEREUM_ETHERSCAN_API_KEY=<YOUR_ETHERSCAN_API_KEY>
ETHEREUM_RPC_URL=<YOUR_RPC_URL>
Creating a config file
In the directory you plan to run discovery create the following directory structure <root>/discovery/zora/ethereum
.
Below is a quick explanation of the meaning behind it:
<root>/
discovery/ # Main folder for all your scanning projects
zora/ # Folder for the Zora project
ethereum/ # For Zora stuff on Ethereum chain
optimism/ # For Zora stuff on Optimism chain
In the ethereum
folder create a config.jsonc
file.
Let’s start with the following configuration:
{
"chain": "ethereum", // chain sanity check
"name": "zora", // project sanity check
// Addresses of contracts in which we're going to start crawling
"initialAddresses": [
"0x3e2Ea9B92B7E48A52296fD261dc26fd995284631" // L1StandardBridge
]
}
Running discovery
If you did everything correctly you should be able to run npx discovery discover ethereum zora
.
After it finishes a new file will appear in the <root>/discovery/zora/ethereum
directory named discovered.json
.
The file contains all contracts that could be found starting from the initial contracts.
For each contract in the values
key the state of the contract is stored.
By state we mean any public variables or values returned from functions which do not have any input arguments (like f()
not fa(arg1)
).
Additionally if a function takes a single argument of type uint256
we’ll try to call it with increasing values starting from zero to create an array.
PRO TIP ⚡️
Discovery has many switches, you can view them with --help
.
The one that you’d be most interested in is --dev
.
It reruns discovery on the same block that is currently in discovered.json
.
Because discovery has a cache built in it will speed up any re-discoveries a lot.
Getting rid of the errors
Most of the errors are related to “Too many values
”.
It’s caused by the default sanity check created to keep the automatic state array building described above in reasonable bounds.
Sometimes what we thought was a function which which could be an array turns out to be something else entirely like isValidated(uint256 hash) -> boolean
.
In that case we would want to ignore this function.
But if function actually returns something useful like getValidator(uint256 index) -> Validator
and we want to have all of them we need to increase the limit of calls.
By default the automatic state arrays are limited to 5 entries.
Let’s take a look at the first example isOutputFinalized
.
Looking at the ABI of that function we see that: function isOutputFinalized(uint256 _l2OutputIndex) view returns (bool)
.
We can assume that we don’t care about this value because there are A LOT of state values.
To ignore this function and not call it during discovery we can add the following to the config:
{
"chain": "ethereum", // chain sanity check
"name": "zora", // project sanity check
"initialAddresses": [
"0x3e2Ea9B92B7E48A52296fD261dc26fd995284631" // L1StandardBridge
],
"overrides": {
"0x1a0ad011913A150f69f6A19DF447A0CfD9551054": { // Overrides for this contract
"ignoreMethods": ["isOutputFinalized"] // A list of method names to ignore
}
}
}
Now on your own try to ignore all of the other errors.
Handlers
A handler is a set of custom instructions to perform and later save the result in the state. One example of a handler is a storage handler. You give it a slot to read and the result of running that handler is the value in that storage slot in the contract for which it’s configured.
Let’s explore a simpler handler, the call handler.
In the ABI of the GnosisSafe you can find: function isOwner(address owner) view returns (bool)
.
Just for fun we’re going to use this handler to see if Vitalik is the owner of some random multisig.
Add the following changes to the config file:
"0x9BA6e03D8B90dE867373Db8cF1A58d2F7F006b3A": {
"fields": {
"isVitalikAnOwner": { // a new you want this field to have
"handler": { // define the handler
"type": "call", // choose the handler type
// handler specific configuration
"method": "function isOwner(address owner) view returns (bool)",
"args": ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"]
}
}
}
}
After rerunning we can see a new state variable "isVitalikAnOwner": false
.
Reading the source code
Now that we see the state we have to read the source code to understand what does it represent.
Discovery has it’s own flattener built in are save each contracts source code in the <root>/discovery/zora/ethereum/.flat
directory.
Flat files include only contracts that will influence the produced bytecode.
You can focus only on the code in the file and be sure that you’re not analyzing something that will not influence the final result.
Templates
As in any codebase duplication is inevitable, similar projects will have copy pasted configuration from each other. In programming the solution to code duplication is to abstract the functionality to a function or a class. Discovery allows to abstract the configuration of a contract to a template. They hold the same configuration values as each contract in the overrides section. Their strength is that you can reuse them across projects and any changes in the template propagate to all projects that use that template.
Since Zora uses the OP Stack you can imagine how abstracting common configuration across projects is really useful.
You have to options to use the template you have created either by extending the contract configuration yourself or allowing the discovery to perform an automatic match.
But before we can use it, we need to create it.
Create the following directory structure: <root>/discovery/_templates/MyTemplate/
.
<root>/
discovery/
_templates/ # Main folder for all templates
MyTemplate/ # The folder containing all the data for that template
Create a file template.jsonc
in the MyTemplate
directory and just copy paste from the contract configuration.
We’re going to move our usage of the call handler to the template, so the template.jsonc
looks like this:
{
"fields": {
"isVitalikAnOwner": { // a new you want this field to have
"handler": { // define the handler
"type": "call", // choose the handler type
// handler specific configuration
"method": "function isOwner(address owner) view returns (bool)",
"args": ["0xf5636Df6f86f31668aeAe9bB8a1C4F0ED147926a"]
}
}
}
}
Usage by manual extension
The most simple way to use our new Template is to manually say that we want to use it. Our multisig configuration entry looks like this
"0x9BA6e03D8B90dE867373Db8cF1A58d2F7F006b3A": {
"extends": "MyTemplate"
}
We can easily use the same template in a different multisig, for example:
"0x9BA6e03D8B90dE867373Db8cF1A58d2F7F006b3A": {
"extends": "MyTemplate"
},
// This is new
"0x09f7150D8c019BeF34450d6920f6B3608ceFdAf2": {
"extends": "MyTemplate"
}
And now the powerful thing is that we can modify only the template and it’s going to affect all the contract that use this template.
To showcase this let’s check if donnoh.eth
is also an admin.
"isDonnohAnOwner": {
"handler": {
"type": "call",
"method": "function isOwner(address owner) view returns (bool)",
"args": ["0x33D66941465ac776C38096cb1bc496C673aE7390"]
}
}
Rerun discovery and both of the multisigs have this new state variable.
Usage by automatic flat source match
Manual extension can also get pretty boring really quick.
To solve this we’re going to use flat source files.
Creating a shape
directory inside the template and putting in flat sources files we want to match is all we need to do.
The logic is that we have looked at the source of this contract and determined it to be something.
Since we have abstracted that something into a template, anything else that will match the source of it should also receive the same template.
In MyTemplate
create shape
directory and copy the flat source of any Multisig to it.
Rerun discovery and 7 multisigs in the discovered.json
should also contain the new state variables we have defined in the template.