How to guess unichain mainnet genesis?

What we have?

testnet genesis https://github.com/Uniswap/unichain-node/raw/main/chainconfig/sepolia/genesis-l2.json

a working L2 RPC https://mainnet-readonly.unichain.org

blockscout explorer https://unichain.blockscout.com

What we want to achieve?

A working unichain RPC node, In this article, we only focus on how to guess the correct genesis-l2.json file, making the block 0 has correct blockhash=0x3425162ddf41a0a1f0106d67b71828c9a9577e6ddeb94e4f33d2cde1fdc3befe

Steps

1. Fix block data

first, let's get the block and check the difference

download the testnet genesis, naming it to genesis-l2.json.bak

Copy
import os
os.environ["RPC"] = "https://mainnet-readonly.unichain.org"
from simplebase import *

b=eth_getBlockByNumber(RPC, 0, True)
g=json.load(open("genesis-l2.json.bak"))

>>> pprint({i:j for i,j in b.items() if i in g and b[i]!=g[i]})
{'nonce': '0x0000000000000000', 'timestamp': '0x67291fc7'}

Fix the above two differences, and note that config.chainId should also be changed to 130 (mainnet chainId).

2. Fix known contract code and data

Now let's fix alloc, observing the alloc struct:

Copy
"420000000000000000000000000000000000002f": {
"code": "0x60806040526004...",
"storage": {
"0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103": "0x0000000000000000000000004200000000000000000000000000000000000018"
},
"balance": "0x0"
},

Key is contract addr without 0x prefix, and value has code, storage dict, balance, and nonce.
I guess the balance and nonce wont be different, so querying storage and code, find the mismatching one and replace:

Copy
import os
os.environ["RPC"] = "https://mainnet-readonly.unichain.org"
from simplebase import *

def cached_simple_rpccall(rpc, method, params, cacheprefix=""):
cachekey = hashlib.sha256(f"{rpc}_{method}_{json.dumps(params)}".encode()).hexdigest()
cachefile = f"__pycache__/cached_simple_rpccall{cacheprefix}_{cachekey}"
if os.path.isfile(cachefile):
return json.load(open(cachefile))
res = simple_rpccall(rpc, method, params)
open(cachefile, "w").write(json.dumps(res))
return res

if 1:
g = json.load(open("genesis-l2.json.bak"))
known = set()
for addr,v in g["alloc"].items():
addr = "0x"+addr
cache = "-".join([v[i] for i in sorted(v.keys()) if i!="storage"])
if "storage" in v:
cache += json.dumps(v["storage"])
if cache in known:
continue
known.add(cache)
#print(v.keys()) code,balance,storage,nonce
if "code" in v:
#realcode = eth_getCode(RPC, addr)
realcode = cached_simple_rpccall(RPC, "eth_getCode", [addr, "0x0"])
if v["code"]==realcode:
print("ok code", addr)
else:
print("ERROR code:", addr, len(v["code"]), "=>", len(realcode))
g["alloc"][addr[2:]]["code"] = realcode
if cache in known:
known.remove(cache)
if "storage" in v:
for s_slot, s_value in v["storage"].items():
#realvalue = eth_getStorageAt(RPC, addr, s_slot, height=0)
realvalue = int(cached_simple_rpccall(RPC, "eth_getStorageAt", [addr, s_slot, "0x0"]), 16)
if realvalue==int(s_value, 16):
print("ok storage", addr, s_slot)
else:
print("ERROR storage:", addr, s_slot, int(s_value, 16), "=>", realvalue)
g["alloc"][addr[2:]]["storage"][s_slot] = "%064x"%(realvalue)
if cache in known:
known.remove(cache)

print(len(known))
open("genesis-l2.json", "w").write(json.dumps(g))

As both testnet and mainnet using opstack, the most of contract should have the same address,code, and storage. So we adopt a cache mechanism, if many contracts have the exact same value in the testnet genesis, it's likely they will have the same value in mainnet.

3. Find missing contracts

After running the above code, we find two contract that appears in the testnet, but mainnet do not have code or storage, so these 2 keys should be removed:

0x5c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f

0x1f98431c8ad98523631ae4a59f267346ea31f984

Searching these addrs show they are UniV2Factory and UniV3Factory, respectively.

Looking at explorer verified contract page ( https://unichain.blockscout.com/verified-contracts ), we spot a UniV2Factory contract that has unique contract address: https://unichain.blockscout.com/address/0x1F98400000000000000000000000000000000002?tab=contract

So we're inspired to check neighbor contract addrs and find these contracts are also pre-deployed:

Copy
0x1F98400000000000000000000000000000000002
0x1F98400000000000000000000000000000000003
0x1F98400000000000000000000000000000000004

The latter two have not been verified, but from the emitted events, we can guess it's UniV3 and UniV4 contract, so let's add these 3 addrs to our genesis-l2.json.bak.

The storage slot number can be guessed from contract source code analysis, debug trace of mainnet deploy, or simply eth_getStorageAt calling for slot 0~10.

Copy
"1f98400000000000000000000000000000000002": {
"code": "0x",
"storage": {
"0x0000000000000000000000000000000000000000000000000000000000000001": "0x0000000000000000000000009b64f6e1d60032f5515bd167346cfcd2162ee73a"
},
"balance": "0x0"
},
"1f98400000000000000000000000000000000003": {
"code": "0x",
"storage": {
"0x0000000000000000000000000000000000000000000000000000000000000003": "0x0000000000000000000000009b64f6e1d60032f5515bd167346cfcd2162ee73a",
"0x083fc81be30b6287dea23aa60f8ffaf268f507cdeac82ed9644e506b59c54ff0": "0x0000000000000000000000000000000000000000000000000000000000000001",
"0x72dffa9b822156d9cf4b0090fa0b656bcb9cc2b2c60eb6acfc20a34f54b31743": "0x000000000000000000000000000000000000000000000000000000000000003c",
"0x8cc740d51daa94ff54f33bd779c2d20149f524c340519b49181be5a08615f829": "0x00000000000000000000000000000000000000000000000000000000000000c8",
"0xfb8cf1d12598d1a039dd1d106665851a96aadf67d0d9ed76fceea282119208b7": "0x000000000000000000000000000000000000000000000000000000000000000a"
},
"balance": "0x0"
},
"1f98400000000000000000000000000000000004": {
"code": "0x",
"storage": {
"0x0000000000000000000000000000000000000000000000000000000000000000": "0x0000000000000000000000009b64f6e1d60032f5515bd167346cfcd2162ee73a"
},
"balance": "0x0"
},

The detailed value does not matter, and the above code will fetch them from the RPC and replace to correct value.

After these steps, we're still getting wrong blockhash.

4. eth_getProof to confirm storage correctness

How can we know more detail of the genesis block? debug_traceBlock RPC method does support genesis block.

Inspecting all available RPC methods, we can find this eth_getProof, which can output accountProof, balance, codeHash, nonce, storageHash and storageProof.

By iterating all known addrs calling eth_getProof, we can find all known addrs have correct balance, codeHash, nonce, storageHash, but wrong accountProof, this means there're still contracts missing from the genesis.

5. Continue searching for missing contracts

Inspecting all APIs provided by blockscout, we can find this Get a list of contracts , it can return all contract addrs:

https://unichain.blockscout.com/api?module=contract&action=listcontracts&offset=10000

Filtering this output by calling eth_getCode using 0 block number, we find 4 missing addrs:

0x4300c0d3c0d3c0d3c0d3c0d3c0d3c0d3c0d30001

0x4300c0d3c0d3c0d3c0d3c0d3c0d3c0d3c0d30002

0x4300c0d3c0d3c0d3c0d3c0d3c0d3c0d3c0d30003

0x4300c0d3c0d3c0d3c0d3c0d3c0d3c0d3c0d30004

Using simple eth_getStorageAt to find the first 200 slots, we successfully find the correct storage value for the first 3, but the last one is still wrong even it's first 200 slots all zero.

Copy
g = json.load(open("api?module=contract&action=listcontracts&offset=10000"))
for addr in ["0x4300c0d3c0d3c0d3c0d3c0d3c0d3c0d3c0d30001", "0x4300c0d3c0d3c0d3c0d3c0d3c0d3c0d3c0d30002", "0x4300c0d3c0d3c0d3c0d3c0d3c0d3c0d3c0d30003", "0x4300c0d3c0d3c0d3c0d3c0d3c0d3c0d3c0d30004"]:
if not addr.startswith("0x"):
addr = "0x"+addr
addr = addr.lower()
truth = cached_simple_rpccall(RPC, "eth_getProof", [addr, [], "0x0"])
our = cached_simple_rpccall(OUR_PRC, "eth_getProof", [addr, [], "0x0"], cacheprefix="test2")
#[i==our['accountProof'][idx] for idx,i in enumerate(truth['accountProof'])]
#if not all([truth['balance']==our['balance'], truth['codeHash']==our['codeHash'], truth['nonce']==our['nonce'], truth['storageHash']==our['storageHash']]):
if 1:
print(truth['balance'], truth['codeHash'], truth["nonce"], truth["storageHash"])
print(addr, truth['balance']==our['balance'], truth['codeHash']==our['codeHash'], truth['nonce']==our['nonce'], truth['storageHash']==our['storageHash'], )

Now we use eth_getProof specifying slots, and find this contract indeed have storage, as empty contract wont return storageProof.

5. Reverse engineering the contract

Using dedaub to decompile 0x4300c0d3c0d3c0d3c0d3c0d3c0d3c0d3c0d30004, and manually format:

Copy
// Decompiled by library.dedaub.com
// 2024.11.17 14:07 UTC
// Compiled using the solidity compiler version 0.8.26

// Data structures and variables inferred from the use of storage instructions
uint256 _receive; // STORAGE[0x0]
mapping (address => uint256) storage1; // STORAGE[0x1]
mapping (address => uint256) _earnedFees; // STORAGE[0x2]
mapping (address => struct_313) _balanceOf; // STORAGE[0x3]

struct struct_313 { address addr; uint256 amount; };

// Events
Withdrawn(address, address, uint256);

function 0x396cb597(address varg0) public nonPayable {
require(msg.data.length - 4 >= 32);
return _balanceOf[varg0].addr;
}

function balanceOf(address account) public nonPayable {
require(msg.data.length - 4 >= 32);
return _balanceOf[account].amount;
}

function 0xc13886a4(address varg0, address varg1) public nonPayable {
require(_balanceOf[varg0].addr == msg.sender, Unauthorized());
_balanceOf[varg0].addr = varg1;
emit 0xf0476b1621059e9b7a94b31f4699ab07974f87c8e31db4cb9ad1f9892eb9b169(varg0, _balanceOf[varg0].addr, varg1);
}

function 0xc6743555(address contract1, address contract2, address owner2, uint256 amt) public nonPayable {
require(!_balanceOf[contract2].addr);
_balanceOf[contract2].addr = owner2;
_balanceOf[contract2].amount = 0;
emit 0xf0476b1621059e9b7a94b31f4699ab07974f87c8e31db4cb9ad1f9892eb9b169(contract2, 0, owner2);
0x6de(amt, contract2, contract1);
//require(msg.sender == _balanceOf[contract1].addr, Unauthorized());
}

function recipients(address varg0) public nonPayable {
return _balanceOf[varg0].addr, _balanceOf[varg0].amount;
}

function earnedFees(address addr) public nonPayable {
return _earnedFees[addr] + (_balanceOf[addr].amount * (_receive - storage1[addr])) /1e30;
}

function receive() public payable {
_receive += msg.value * 1e26;
}

function 0x6de(uint256 varg0, uint256 varg1, uint256 varg2) private {
require(msg.sender == _balanceOf[address(varg2)].addr, Unauthorized());
require(address(varg1));
require(0 - varg0);
require(_balanceOf[address(varg2)].amount >= varg0, InsufficientAllocation());
updateState(varg2);
updateState(varg1);
v0 = _SafeSub(_balanceOf[address(varg2)].amount, varg0);
_balanceOf[address(varg2)].amount = v0;
v1 = _SafeAdd(_balanceOf[address(varg1)].amount, varg0);
_balanceOf[address(varg1)].amount = v1;
emit 0x4c6e7131fb69f3c2cc88b05b76a7aa4809429fefa284dda9cf14884d25e3742b(msg.sender, address(varg2), address(varg1), varg0);
return ;
}

function updateState(addr) private {
_earnedFees[addr] += (_receive - storage1[addr])* _balanceOf[addr].amount /1e30 ;
storage1[addr] = _receive;
}

function 0x976(address varg0) private {
v0 = _SafeSub(_receive, storage1[varg0]);
v1 = _SafeMul(_balanceOf[varg0].amount, v0);
v2 = _SafeDiv(v1, 10 ** 30);
return v2;
}

function transferAllocation(address varg0, address varg1, uint256 amt) public nonPayable {
require(_balanceOf[varg1].addr);

//0x6de(uint256 varg0, uint256 varg1, uint256 varg2) 0x6de(varg2, varg1, varg0);
require(msg.sender == _balanceOf[varg0].addr, Unauthorized());
require(_balanceOf[varg0].amount >= amt, InsufficientAllocation());
updateState(varg0);
updateState(varg1);
_balanceOf[varg0].amount -= amt;
_balanceOf[address(varg1)].amount += amt;
emit 0x4c6e7131fb69f3c2cc88b05b76a7aa4809429fefa284dda9cf14884d25e3742b(msg.sender, varg0, address(varg1), amt);
}

function withdrawFees(address account_) public nonPayable {
updateState(msg.sender);
if (_earnedFees[msg.sender]) {
_earnedFees[msg.sender] = 0;
v0, /* uint256 */ v1 = account_.call().value(_earnedFees[msg.sender]).gas(msg.gas);
require(v0, WithdrawalFailed());
}
emit Withdrawn(msg.sender, account_, _earnedFees[msg.sender]);
return _earnedFees[msg.sender];
}

// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.

function __function_selector__() private {
MEM[64] = 128;
if (msg.data.length < 4) {
require(!msg.data.length);
receive();
} else if (0xc13886a4 > msg.data[0] >> 224) {
if (0x24e2c06 == msg.data[0] >> 224) {
transferAllocation(address,address,uint256);
} else if (0x164e68de == msg.data[0] >> 224) {
withdrawFees(address);
} else if (0x396cb597 == msg.data[0] >> 224) {
0x396cb597();
} else {
require(0x70a08231 == msg.data[0] >> 224);
balanceOf(address);
}
} else if (0xc13886a4 == msg.data[0] >> 224) {
0xc13886a4();
} else if (0xc6743555 == msg.data[0] >> 224) {
0xc6743555();
} else if (0xeb820312 == msg.data[0] >> 224) {
recipients(address);
} else {
require(0xfeb7219d == msg.data[0] >> 224);
earnedFees(address);
}
}

We can guess the functionality is revenue split, there're contract address=>owner address mapping, owner can transfer weight, and total weight is 10000.

6. Guess the slot

The storageProof is a merkle tree, each leaf node is sha3(slot) and rlp-encoded value, and the prefix is reference by the above branch node. So we can use eth_getProof to query some prefix to get the full tree. The first item of proof is always the root, decoding it we can find it has 4 childs among 16 possible ones. For example, by querying the storageProof of slot 2, we can get the proof of child prefix 4, as sha3(toarg(2))=405787fa12a823e0f2b7631cc41b3ba8828b3321ca811111fa75cd3aa3bb5ace.

Copy
let {toBytes,bytesToHex}=require("@ethereumjs/util")
const { utils } = require('ethers');
decodeNode=require("@ethereumjs/trie").decodeNode
nibblesToBytes=require("@ethereumjs/trie").nibblesToBytes

> decodeNode(toBytes("0xf891808080a0464322c988da3ad6becee03210d007812970a011da4ce89d63d7a8c7c46dd349a047213c020d0419fb3c2273dcddab7cb42ad2614cfd1042c358b5219d4d66d472808080a0937ec93a69abccbf4d7348457eb2e55ed59b568bc212aeb125abf3aee59cc897808080a0aa4ecc3c019aa37691f5cb2cf11822f456ce443b292d228219f1fb29502d104f80808080"))
BranchNode {
_branches: [
Uint8Array(0) [],
Uint8Array(0) [],
Uint8Array(0) [],
3 Uint8Array(32) [ 0x464322c988da3ad6becee03210d007812970a011da4ce89d63d7a8c7c46dd349 => v="0xf7a030efa3127a31b746c4ade29f2dc3d63d735226f92c8b5c62f93844e730e5387595943fcbacd76037534d2aaeb9a17f4e631dd64fbe31"
key = 0x30efa3127a31b746c4ade29f2dc3d63d735226f92c8b5c62f93844e730e53875
key = sha3(sha3(toarg(0x3fcbacd76037534d2aaeb9a17f4e631dd64fbe31)+toarg(3)))
value="0x3fcbacd76037534d2aaeb9a17f4e631dd64fbe31"
70, 67, 34, 201, 136, 218, 58, 214,
190, 206, 224, 50, 16, 208, 7, 129,
41, 112, 160, 17, 218, 76, 232, 157,
99, 215, 168, 199, 196, 109, 211, 73
],
4 Uint8Array(32) [ 0x47213c020d0419fb3c2273dcddab7cb42ad2614cfd1042c358b5219d4d66d472 => v="0xe5a03d2849548fa0011234fd0669e617f5df1374a0d525b71bedec3d14dd02bf7f9b83821db0"
key = 0x4d2849548fa0011234fd0669e617f5df1374a0d525b71bedec3d14dd02bf7f9b bytesToHex(nibblestoBytes([4].concat(decodeNode(toBytes(v)).key())))
key = sha3("%040x"%(int(sha3(toarg(0x3fcbacd76037534d2aaeb9a17f4e631dd64fbe31)+toarg(3)),16)+1))
value = 7600 utils.decodeRlp(bytesToHex(decodeNode(toBytes(v)).value()))
71, 33, 60, 2, 13, 4, 25, 251,
60, 34, 115, 220, 221, 171, 124, 180,
42, 210, 97, 76, 253, 16, 66, 195,
88, 181, 33, 157, 77, 102, 212, 114
],
Uint8Array(0) [],
Uint8Array(0) [],
Uint8Array(0) [],
8 Uint8Array(32) [ 0x937ec93a69abccbf4d7348457eb2e55ed59b568bc212aeb125abf3aee59cc897 => 0xf7a0328a4924e9ba234119f26396d33bbebdcb65d7af957d59f814558268b1c8e69a9594ae85bbb6c1c1807a64a88f1a1f978740c8a0dba0
Node key=0x828a4924e9ba234119f26396d33bbebdcb65d7af957d59f814558268b1c8e69a [8, 2, 8, 10, 4, 9, 2, 4, 14, 9, 11, 10, 2,3, 4, 1, 1, 9, 15, 2, 6, 3, 9, 6, 13,3, 3, 11, 11, 14, 11, 13, 12, 11, 6, 5, 13,7, 10, 15, 9, 5, 7, 13, 5, 9, 15, 8, 1,4, 5, 5, 8, 2, 6, 8, 11, 1, 12, 8, 14,6, 9, 10],
key = sha3(sha3(toarg(0xae85bbb6c1c1807a64a88f1a1f978740c8a0dba0)+toarg(3)))
value = > utils.decodeRlp(bytesToHex(decodeNode(toBytes("0xf7a0328a4924e9ba234119f26396d33bbebdcb65d7af957d59f814558268b1c8e69a9594ae85bbb6c1c1807a64a88f1a1f978740c8a0dba0")).value()))
'0xae85bbb6c1c1807a64a88f1a1f978740c8a0dba0'
147, 126, 201, 58, 105, 171, 204,
191, 77, 115, 72, 69, 126, 178,
229, 94, 213, 155, 86, 139, 194,
18, 174, 177, 37, 171, 243, 174,
229, 156, 200, 151
],
Uint8Array(0) [],
Uint8Array(0) [],
Uint8Array(0) [],
12 Uint8Array(32) [ 0xaa4ecc3c019aa37691f5cb2cf11822f456ce443b292d228219f1fb29502d104f => v="0xe5a03112f27f422905a81edb239837dddce63ac33c22d7066f8b0d9d4e00afc6650d83820960"
key = 0xc112f27f422905a81edb239837dddce63ac33c22d7066f8b0d9d4e00afc6650d bytesToHex(nibblestoBytes([12].concat(decodeNode(toBytes(v)).key())))
value = 0x0960=2400 utils.decodeRlp(bytesToHex(decodeNode(toBytes(v)).value()))
170, 78, 204, 60, 1, 154, 163, 118,
145, 245, 203, 44, 241, 24, 34, 244,
86, 206, 68, 59, 41, 45, 34, 130,
25, 241, 251, 41, 80, 45, 16, 79
],
Uint8Array(0) [],
Uint8Array(0) [],
Uint8Array(0) []
],
_value: Uint8Array(0) []
}

Finally, we get the all 4 missing slots, involving two addrs: 0xae85bbb6c1c1807a64a88f1a1f978740c8a0dba0 0x3fcbacd76037534d2aaeb9a17f4e631dd64fbe31, and stored in mapping slot3 and +1 (struct)

sha3(toarg(0xae85bbb6c1c1807a64a88f1a1f978740c8a0dba0)+toarg(3)) = f42505e4cfab5287d35a1886da3b550274dafd60099332aaa2a1f2edb64366e0 => 828a4924e9ba234119f26396d33bbebdcb65d7af957d59f814558268b1c8e69a

sha3(toarg(0xae85bbb6c1c1807a64a88f1a1f978740c8a0dba0)+toarg(3))+1 = f42505e4cfab5287d35a1886da3b550274dafd60099332aaa2a1f2edb64366e1 => c112f27f422905a81edb239837dddce63ac33c22d7066f8b0d9d4e00afc6650d

sha3(toarg(0x3fcbacd76037534d2aaeb9a17f4e631dd64fbe31)+toarg(3)) = 6499618908e6c3bd1552bf01e251de0ec9ec985e74571d0b89e1b71c4e49dc7c => 30efa3127a31b746c4ade29f2dc3d63d735226f92c8b5c62f93844e730e53875

sha3(toarg(0x3fcbacd76037534d2aaeb9a17f4e631dd64fbe31)+toarg(3))+1 = 6499618908e6c3bd1552bf01e251de0ec9ec985e74571d0b89e1b71c4e49dc7d => 4d2849548fa0011234fd0669e617f5df1374a0d525b71bedec3d14dd02bf7f9b