1. 程式人生 > >死磕以太坊原始碼分析之Ethash共識演算法

死磕以太坊原始碼分析之Ethash共識演算法

> 死磕以太坊原始碼分析之Ethash共識演算法 > > 程式碼分支:https://github.com/ethereum/go-ethereum/tree/v1.9.9 ## 引言 目前以太坊中有兩個共識演算法的實現:`clique`和`ethash`。而`ethash`是目前以太坊主網(`Homestead`版本)的`POW`共識演算法。 ## 目錄結構 `ethash`模組位於以太坊專案目錄下的`consensus/ethash`目錄下。 - **algorithm.go** 實現了`Dagger-Hashimoto`演算法的所有功能,比如生成`cache`和`dataset`、根據`Header`和`Nonce`計算挖礦雜湊等。 - **api.go** 實現了供`RPC`使用的`api`方法。 - **consensus.go** 實現了以太坊共識介面的部分方法,包括`Verify`系列方法(`VerifyHeader`、`VerifySeal`等)、`Prepare`和`Finalize`、`CalcDifficulty`、`Author`、`SealHash`。 - **ethash.go** 實現了`cache`結構體和`dataset`結構體及它們各自的方法、`MakeCache`/`MakeDataset`函式、`Ethash`物件的`New`函式,和`Ethash`的內部方法。 - **sealer.go** 實現了共識介面的`Seal`方法,和`Ethash`的內部方法`mine`。這些方法實現了`ethash`的挖礦功能。 ## Ethash 設計原理 ### Ethash設計目標 以太坊設計共識演算法時,期望達到三個目的: 1. 抗`ASIC`性:為演算法建立專用硬體的優勢應儘可能小,讓普通計算機使用者也能使用CPU進行開採。 - 通過記憶體限制來抵制(`ASIC`使用礦機記憶體昂貴) - 大量隨機讀取記憶體資料時計算速度就不僅僅受限於計算單元,更受限於記憶體的讀出速度。 2. 輕客戶端可驗證性: 一個區塊應能被輕客戶端快速有效校驗。 3. 礦工應該要求儲存完整的區塊鏈狀態。 ### 雜湊資料集 `ethash`要計算雜湊,需要先有一塊資料集。這塊資料集較大,初始大小大約有`1G`,每隔 3 萬個區塊就會更新一次,且每次更新都會比之前變大`8M`左右。計算雜湊的資料來源就是從這塊資料集中來的;而決定使用資料集中的哪些資料進行雜湊計算的,才是`header`的資料和`Nonce`欄位。這部分是由`Dagger`演算法實現的。 #### Dagger `Dagger`演算法是用來生成資料集`Dataset`的,核心的部分就是`Dataset`的生成方式和組織結構。 可以把`Dataset`想成多個`item`(**dataItem**)組成的陣列,每個`item`是`64`位元組的byte陣列(一條雜湊)。`dataset`的初始大小約為`1G`,每隔3萬個區塊(一個`epoch`區間)就會更新一次,且每次更新都會比之前變大`8M`左右。 `Dataset`的每個`item`是由一個快取塊(`cache`)生成的,快取塊也可以看做多個`item`(**cacheItem**)組成,快取塊佔用的記憶體要比`dataset`小得多,它的初始大小約為`16M`。同`dataset`類似,每隔 3 萬個區塊就會更新一次,且每次更新都會比之前變大`128K`左右。 生成一條`dataItem`的程是:從快取塊中“隨機”(這裡的“隨機”不是真的隨機數,而是指事前不能確定,但每次計算得到的都是一樣的值)選擇一個`cacheItem`進行計算,得的結果參與下次計算,這個過程會迴圈 256 次。 快取塊是由`seed`生成的,而`seed`的值與塊的高度有關。所以生成`dataset`的過程如下圖所示: ![image-20201213144908721](https://tva1.sinaimg.cn/large/0081Kckwgy1glm8arq9t7j30x80eytji.jpg) `Dagger`還有一個關鍵的地方,就是確定性。即同一個`epoch`內,每次計算出來的`seed`、快取、`dataset`都是相同的。否則對於同一個區塊,挖礦的人和驗證的人使用不同的`dataset`,就沒法進行驗證了。 ----- ### Hashimoto演算法 是`Thaddeus Dryja`創造的。旨在通過`IO`限制來抵制礦機。在挖礦過程中,使記憶體讀取限制條件,由於記憶體裝置本身會比計算裝置更加便宜以及普遍,在記憶體升級優化方面,全世界的大公司也都投入巨大,以使記憶體能夠適應各種使用者場景,所以有了隨機訪問記憶體的概念`RAM`,因此,現有的記憶體可能會比較接近最優的評估演算法。`Hashimoto`演算法使用區塊鏈作為源資料,滿足了上面的 1 和 3 的要求。 它的作用就是使用區塊Header的雜湊和Nonce欄位、利用dataset資料,生成一個最終的雜湊值。 ------- ## 原始碼解析 ### 生成雜湊資料集 `generate`函式位於`ethash.go`檔案中,主要是為了生成`dataset`,其中包擴以下內容。 #### 生成cache size `cache size` 主要*某個特定塊編號的ethash驗證快取的大小* *, `epochLength` 為 30000,如果`epoch` 小於 2048,則從已知的`epoch`返回相應的`cache size`,否則重新計算`epoch` `cache`的大小是線性增長的,`size`的值等於(2^24^ + 2^17^ * epoch - 64),用這個值除以 64 看結果是否是一個質數,如果不是,減去128 再重新計算,直到找到最大的質數為止。 ```go csize := cacheSize(d.epoch*epochLength + 1) ``` ```go func cacheSize(block uint64) uint64 { epoch := int(block / epochLength) if epoch < maxEpoch { return cacheSizes[epoch] } return calcCacheSize(epoch) } ``` ```go func calcCacheSize(epoch int) uint64 { size := cacheInitBytes + cacheGrowthBytes*uint64(epoch) - hashBytes for !new(big.Int).SetUint64(size / hashBytes).ProbablyPrime(1) { // Always accurate for n < 2^64 size -= 2 * hashBytes } return size } ``` #### 生成dataset size `dataset Size` 主要*某個特定塊編號的ethash驗證快取的大小* , 類似上面生成`cache size` ```go dsize := datasetSize(d.epoch*epochLength + 1) ``` ```go func datasetSize(block uint64) uint64 { epoch := int(block / epochLength) if epoch < maxEpoch { return datasetSizes[epoch] } return calcDatasetSize(epoch) } ``` #### 生成 seed 種子 *seedHash是用於生成驗證快取和挖掘資料集的種子。*長度為 32。 ```go seed := seedHash(d.epoch*epochLength + 1) ``` ```go func seedHash(block uint64) []byte { seed := make([]byte, 32) if block < epochLength { return seed } keccak256 := makeHasher(sha3.NewLegacyKeccak256()) for i := 0; i < int(block/epochLength); i++ { keccak256(seed, seed) } return seed } ``` #### 生成cache ```go generateCache(cache, d.epoch, seed) ``` 接下來分析`generateCache`的關鍵程式碼: 先了解一下**hashBytes**,在下面的計算中都是以此為單位,它的值為 64 ,相當於一個`keccak512`雜湊的長度,下文以**item**稱呼`[hashBytes]byte`。 ①:初始化`cache` 此迴圈用來初始化`cache`:先將`seed`的雜湊填入`cache`的第一個`item`,隨後使用前一個`item`的雜湊,填充後一個`item`。 ```go for offset := uint64(hashBytes); offset < size; offset += hashBytes { keccak512(cache[offset:], cache[offset-hashBytes:offset]) atomic.AddUint32(&progress, 1) } ``` ②:對cache中資料按規則做異或 為對於每一個`item`(`srcOff`),“隨機”選一個`item`(`xorOff`)與其進行異或運算;將運算結果的雜湊寫入`dstOff`中。這個運算邏輯將進行`cacheRounds`次。 兩個需要注意的地方: - 一是`srcOff`是從尾部向頭部變化的,而`dstOff`是從頭部向尾部變化的。並且它倆是對應的,即當`srcOff`代表倒數第x個item時,`dstOff`則代表正數第x個item。 - 二是`xorOff`的選取。注意我們剛才的“隨機”是打了引號的。`xorOff`的值看似隨機,因為在給出`seed`之前,你無法知道xorOff的值是多少;但一旦`seed`的值確定了,那麼每一次`xorOff`的值都是確定的。而seed的值是由區塊的高度決定的。這也是同一個`epoch`內總是能得到相同`cache`資料的原因。 ```GO for i := 0; i < cacheRounds; i++ { for j := 0; j < rows; j++ { var ( srcOff = ((j - 1 + rows) % rows) * hashBytes dstOff = j * hashBytes xorOff = (binary.LittleEndian.Uint32(cache[dstOff:]) % uint32(rows)) * hashBytes ) bitutil.XORBytes(temp, cache[srcOff:srcOff+hashBytes], cache[xorOff:xorOff+hashBytes]) keccak512(cache[dstOff:], temp) atomic.AddUint32(&progress, 1) } } ``` ------ #### 生成dataset `dataset`大小的計算和`cache`類似,量級不同:2^30^ + 2^23^ * epoch - 128,然後每次減256尋找最大質數。 生成資料是一個迴圈,每次生成64個位元組,主要的函式是`generateDatasetItem`: `generateDatasetItem`的資料來源就是`cache`資料,而最終的dataset值會儲存在mix變數中。整個過程也是由多個迴圈構成。 ①:初始化`mix`變數 根據cache值對`mix`變數進行初始化。其中`hashWords`代表的是一個`hash`裡有多少個`word`值:一個`hash`的長度為`hashBytes`即64位元組,一個`word`(uint32型別)的長度為 4 位元組,因此`hashWords`值為 16。選取`cache`中的哪一項資料是由引數`index`和`i`變數決定的。 ```go mix := make([]byte, hashBytes) binary.LittleEndian.PutUint32(mix, cache[(index%rows)*hashWords]^index) for i := 1; i < hashWords; i++ { binary.LittleEndian.PutUint32(mix[i*4:], cache[(index%rows)*hashWords+uint32(i)]) } keccak512(mix, mix) ``` ②:將`mix`轉換成`[]uint32`型別 ```go intMix := make([]uint32, hashWords) for i := 0; i < len(intMix); i++ { intMix[i] = binary.LittleEndian.Uint32(mix[i*4:]) } ``` ③:將`cache`資料聚合進`intmix` ```go for i := uint32(0); i < datasetParents; i++ { parent := fnv(index^i, intMix[i%16]) % rows fnvHash(intMix, cache[parent*hashWords:]) } ``` `FNV`雜湊演算法,是一種不需要使用金鑰的雜湊演算法。 這個演算法很簡單:a乘以FNV質數0x01000193,然後再和b異或。 首先用這個演算法算出一個索引值,利用這個索引從`cache`中選出一個值(`data`),然後對`mix`中的每個位元組都計算一次`FNV`,得到最終的雜湊值。 ```go func fnv(a, b uint32) uint32 { return a*0x01000193 ^ b } func fnvHash(mix []uint32, data []uint32) { for i := 0; i < len(mix); i++ { mix[i] = mix[i]*0x01000193 ^ data[i] } } ``` ④:將`intMix`又恢復成`mix`並計算`mix`的雜湊返回 ```go for i, val := range intMix { binary.LittleEndian.PutUint32(mix[i*4:], val) } keccak512(mix, mix) return mix ``` `generateCache`和`generateDataset`是實現`Dagger`演算法的核心函式,到此整個生成雜湊資料集的的過程結束。 -------- ### 共識引擎核心函式 程式碼位於`consensus.go` ![image-20201214150532321](https://tva1.sinaimg.cn/large/0081Kckwgy1glnee6vhalj31py0lkgq4.jpg) ①:`Author` ```go // 返回coinbase, coinbase是打包第一筆交易的礦工的地址 func (ethash *Ethash) Author(header *types.Header) (common.Address, error) { return header.Coinbase, nil } ``` ②:`VerifyHeader` 主要有兩步檢查,第一步檢查**header是否已知**或者**是未知的祖先**,第二步是`ethash`的檢查: 2.1 header.Extra 不能超過32位元組 ```go if uint64(len(header.Extra)) > params.MaximumExtraDataSize { // 不超過32位元組 return fmt.Errorf("extra-data too long: %d > %d", len(header.Extra), params.MaximumExtraDataSize) } ``` 2.2 時間戳不能超過15秒,15秒以後的就被認定為未來的塊 ```go if !uncle { if header.Time > uint64(time.Now().Add(allowedFutureBlockTime).Unix()) { return consensus.ErrFutureBlock } } ``` 2.3 當前header的時間戳小於父塊的 ```go if header.Time <= parent.Time { // 當前header的時間小於等於父塊的 return errZeroBlockTime } ``` 2.4 根據時間戳和父塊的難度來驗證塊的難度 ```go expected := ethash.CalcDifficulty(chain, header.Time, parent) if expected.Cmp(header.Difficulty) != 0 { return fmt.Errorf("invalid difficulty: have %v, want %v", header.Difficulty, expected) } ``` 2.5驗證`gas limit`小於2^63^ -1 ```go cap := uint64(0x7fffffffffffffff) if header.GasLimit > cap { return fmt.Errorf("invalid gasLimit: have %v, max %v", header.GasLimit, cap) } ``` 2.6 確認`gasUsed`為<= `gasLimit` ```go if header.GasUsed > header.GasLimit { return fmt.Errorf("invalid gasUsed: have %d, gasLimit %d", header.GasUsed, header.GasLimit) } ``` 2.7 驗證塊號是父塊加1 ```go if diff := new(big.Int).Sub(header.Number, parent.Number); diff.Cmp(big.NewInt(1)) != 0 { return consensus.ErrInvalidNumber } ``` 2.8檢查給定的塊是否滿足pow難度要求 ```go if seal { if err := ethash.VerifySeal(chain, header); err != nil { return err } } ``` ③:`VerifyUncles` 3.1叔叔塊最多兩個 ```go if len(block.Uncles()) > maxUncles { return errTooManyUncles } ``` 3.2收集叔叔塊和祖先塊 ```GO number, parent := block.NumberU64()-1, block.ParentHash() for i := 0; i < 7; i++ { ancestor := chain.GetBlock(parent, number) if ancestor == nil { break } ancestors[ancestor.Hash()] = ancestor.Header() for _, uncle := range ancestor.Uncles() { uncles.Add(uncle.Hash()) } parent, number = ancestor.ParentHash(), number-1 } ancestors[block.Hash()] = block.Header() uncles.Add(block.Hash()) ``` 3.3 確保叔塊只被獎勵一次且叔塊有個有效的祖先 ```GO for _, uncle := range block.Uncles() { // Make sure every uncle is rewarded only once hash := uncle.Hash() if uncles.Contains(hash) { return errDuplicateUncle } uncles.Add(hash) // Make sure the uncle has a valid ancestry if ancestors[hash] != nil { return errUncleIsAncestor } if ancestors[uncle.ParentHash] == nil || uncle.ParentHash == block.ParentHash() { return errDanglingUncle } if err := ethash.verifyHeader(chain, uncle, ancestors[uncle.ParentHash], true, true); err != nil { return err } ``` ④:`Prepare` > 初始化`header`的`Difficulty`欄位 ```go parent := chain.GetHeader(header.ParentHash, header.Number.Uint64()-1) if parent == nil { return consensus.ErrUnknownAncestor } header.Difficulty = ethash.CalcDifficulty(chain, header.Time, parent) return nil ``` ⑤:`Finalize`會執行交易後的所有狀態修改(例如,區塊獎勵),但**不會組裝**該區塊。 5.1累積任何塊和叔塊的獎勵 ```go accumulateRewards(chain.Config(), state, header, uncles) ``` 5.2計算狀態樹的根雜湊並提交到`header` ```go header.Root = state.IntermediateRoot(chain.Config().IsEIP158(header.Number)) ``` ⑥:`FinalizeAndAssemble` 執行任何交易後狀態修改(例如,塊獎勵),並組裝最終塊。 ```GO func (ethash *Ethash) FinalizeAndAssemble(chain consensus.ChainReader, header *types.Header, state *state.StateDB, txs []*types.Transaction, uncles []*types.Header, receipts []*types.Receipt) (*types.Block, error) { accumulateRewards(chain.Config(), state, header, uncles) header.Root = state.IntermediateRoot(chain.Config().IsEIP158(header.Number)) return types.NewBlock(header, txs, uncles, receipts), nil } ``` 很明顯就是比`Finalize`多了 `types.NewBlock` ⑦:`SealHash`返回在`seal`之前塊的雜湊(會跟`seal`之後的塊雜湊不同) ```go func (ethash *Ethash) SealHash(header *types.Header) (hash common.Hash) { hasher := sha3.NewLegacyKeccak256() rlp.Encode(hasher, []interface{}{ header.ParentHash, header.UncleHash, header.Coinbase, header.Root, header.TxHash, header.ReceiptHash, header.Bloom, header.Difficulty, header.Number, header.GasLimit, header.GasUsed, header.Time, header.Extra, }) hasher.Sum(hash[:0]) return hash } ``` ⑧:`Seal`給定的輸入塊生成一個新的密封請求(**挖礦**),並將結果推送到給定的通道中。 注意,該方法將立即返回並將非同步傳送結果。 根據共識演算法,可能還會返回多個結果。這部分會在下面的挖礦中具體分析,這裡跳過。 ------- ### 挖礦細節 > 大家在閱讀本文時有任何疑問均可留言給我,我一定會及時回覆。如果覺得寫得不錯可以關注最下方**參考**的 `github專案`,可以第一時間關注作者文章動態。 挖礦的核心介面定義: ```go Seal(chain ChainReader, block *types.Block, results chan<- *types.Block, stop <-chan struct{}) error ``` 進入到`seal`函式: ①:如果執行錯誤的`POW`,直接返回空的`nonce`和`MixDigest`,同時塊也是空塊。 ```go if ethash.config.PowMode == ModeFake || ethash.config.PowMode == ModeFullFake { header := block.Header() header.Nonce, header.MixDigest = types.BlockNonce{}, common.Hash{} select { case results <- block.WithSeal(header): default: ethash.config.Log.Warn("Sealing result is not read by miner", "mode", "fake", "sealhash", ethash.SealHash(block.Header())) } return nil } ``` ②:共享`pow`的話,則轉到它的共享物件執行`Seal`操作 ```go if ethash.shared != nil { return ethash.shared.Seal(chain, block, results, stop) } ``` ③:獲取種子源,並根據其生成`ethash`需要的種子 ```go f ethash.rand == nil { // 獲得種子 seed, err := crand.Int(crand.Reader, big.NewInt(math.MaxInt64)) if err != nil { ethash.lock.Unlock() return err } ethash.rand = rand.New(rand.NewSource(seed.Int64())) // 給rand賦值 } ``` ④:挖礦的核心工作交給`mine` ```go for i := 0; i < threads; i++ { pend.Add(1) go func(id int, nonce uint64) { defer pend.Done() ethash.mine(block, id, nonce, abort, locals) // 真正執行挖礦的動作 }(i, uint64(ethash.rand.Int63())) } ``` ⑤:處理挖礦的結果 - 外部意外中止,停止所有挖礦執行緒 - 其中一個執行緒挖到正確塊,中止其他所有執行緒 - ethash物件發生改變,停止當前所有操作,重啟當前方法 ```go go func() { var result *types.Block select { case <-stop: close(abort) case result = <-locals: select { case results <- result: //其中一個執行緒挖到正確塊,中止其他所有執行緒 default: ethash.config.Log.Warn("Sealing result is not read by miner", "mode", "local", "sealhash", ethash.SealHash(block.Header())) } close(abort) case <-ethash.update: close(abort) if err := ethash.Seal(chain, block, results, stop); err != nil { ethash.config.Log.Error("Failed to restart sealing after update", "err", err) } } ``` 由上可以知道`seal`的核心工作是由`mine`函式完成的,重點介紹一下。 `mine`函式其實也比較簡單,它是*真正的`pow`礦工,用來搜尋一個`nonce`值,`nonce`值開始於`seed`值,`seed`值是能最終產生正確的可匹配可驗證的區塊難度* ①:從區塊頭中提取相關資料,放在全域性變數域中 ```go var ( header = block.Header() hash = ethash.SealHash(header).Bytes() target = new(big.Int).Div(two256, header.Difficulty) // 這是用來驗證的target number = header.Number.Uint64() dataset = ethash.dataset(number, false) ) ``` ②:開始產生隨機`nonce`,直到我們中止或找到一個好的`nonce` ```GO var ( attempts = int64(0) nonce = seed ) ``` ③: 聚集完整的`dataset`資料,為特定的header和nonce產生最終雜湊值 ```go func hashimotoFull(dataset []uint32, hash []byte, nonce uint64) ([]byte, []byte) { //定義一個lookup函式,用於在資料集中查詢資料 lookup := func(index uint32) []uint32 { offset := index * hashWords //hashWords是上面定義的常量值= 16 return dataset[offset : offset+hashWords] } return hashimoto(hash, nonce, uint64(len(dataset))*4, lookup) } ``` 可以發現實際上`hashimotoFull`函式做的工作就是將原始資料集進行了讀取分割,然後傳給`hashimoto`函式。接下來重點分析`hashimoto`函式: 3.1根據seed獲取區塊頭 ```go rows := uint32(size / mixBytes) ① seed := make([]byte, 40) ② copy(seed, hash) ③ binary.LittleEndian.PutUint64(seed[32:], nonce)④ seed = crypto.Keccak512(seed)⑤ seedHead := binary.LittleEndian.Uint32(seed)⑥ ``` 1. 計算資料集的行數 2. 合併`header+nonce`到一個 40 位元組的`seed` 3. 將區塊頭的`hash`拷貝到`seed`中 4. 將`nonce`值填入`seed`的後(40-32=8)位元組中去,(nonce本身就是`uint64`型別,是 64 位,對應 8 位元組大小),正好把`hash`和`nonce`完整的填滿了 40 位元組的 seed 5. `Keccak512`加密`seed` 6. 從`seed`中獲取區塊頭 3.2 從複製的種子開始混合 - `mixBytes`常量= 128,`mix`的長度為 32,元素為`uint32`,是 32位,對應為 4 位元組大小。所以`mix`總共大小為 4*32=128 位元組大小 ```go mix := make([]uint32, mixBytes/4) for i := 0; i < len(mix); i++ { mix[i] = binary.LittleEndian.Uint32(seed[i%16*4:]) } ``` 3.3 混合隨機資料集節點 ```go temp := make([]uint32, len(mix))//與mix結構相同,長度相同 for i := 0; i < loopAccesses; i++ { parent := fnv(uint32(i)^seedHead, mix[i%len(mix)]) % rows for j := uint32(0); j < mixBytes/hashBytes; j++ { copy(temp[j*hashWords:], lookup(2*parent+j)) } fnvHash(mix, temp) } ``` 3.4 壓縮混合 ```go for i := 0; i < len(mix); i += 4 { mix[i/4] = fnv(fnv(fnv(mix[i], mix[i+1]), mix[i+2]), mix[i+3]) } mix = mix[:len(mix)/4] digest := make([]byte, common.HashLength) for i, val := range mix { binary.LittleEndian.PutUint32(digest[i*4:], val) } return digest, crypto.Keccak256(append(seed, digest...)) ``` 最終返回的是`digest`和`digest`與`seed`的雜湊;而`digest`其實就是`mix`的`[]byte`形式。在前面`Ethash.mine`的程式碼中我們已經看到使用第二個返回值與`target`變數進行比較,以確定這是否是一個有效的雜湊值。 ----- ### 驗證pow 挖礦資訊的驗證有兩部分: 1. 驗證`Header.Difficulty`是否正確 2. 驗證`Header.MixDigest`和`Header.Nonce`是否正確 ①:驗證`Header.Difficulty`的程式碼主要在`Ethash.verifyHeader`中: ```go func (ethash *Ethash) verifyHeader(chain consensus.ChainReader, header, parent *types.Header, uncle bool, seal bool) error { ...... expected := ethash.CalcDifficulty(chain, header.Time.Uint64(), parent) if expected.Cmp(header.Difficulty) != 0 { return fmt.Errorf("invalid difficulty: have %v, want %v", header.Difficulty, expected) } } ``` 通過區塊高度和時間差作為引數來計算`Difficulty`值,然後與待驗證的區塊的`Header.Difficulty`欄位進行比較,如果相等則認為是正確的。 ②:`MixDigest`和`Nonce`的驗證主要是在`Header.verifySeal`中: 驗證的方式:使用`Header.Nonce`和頭部雜湊通過`hashimoto`重新計算一遍`MixDigest`和`result`雜湊值,並且驗證的節點是不需要dataset資料的。 ---- ## 總結&參考 > https://mindcarver.cn ☆☆☆ > > https://github.com/blockchainGuide ☆☆☆ > > https://eth.wiki/concepts/ethash/design-rationale > > https://eth.wiki/concepts/ethash/dag > > https://www.vijaypradeep.com/blog/2017-04-28-ethereums-memory-hardness-ex