从cannon的角度理解Layer2 - 3:代码才是最好的老师

上一次,我们通过一个实际例子梳理了cannon的运行过程,更细节的部分,让我们使用代码的形式进行了解,由于业务流程已经连贯并且完整了,所以,下面的代码部分我将采用知识点的形式进行记录,可能会较为零散,但结合业务进行理解,应该也是轻而易举的

让我们从项目目录开始入手

在开始了解代码之前,让我们先了解一下cannon的目录结构:

  • minigeth:是Go Ethereum针对cannon进行裁剪后的精简版本

  • mipigo:minigeth是一个golang项目,可以由golang编译器直接编译到MIPS平台上,mipigo的作用,就是编译minigeth后,加入启动参数,并输出为可执行文件

  • unicorn:CPU架构模拟引擎,在cannon中提供了MIPS平台模拟功能

  • mipsevm:将mipigo输出的MIPS平台下的minigeth可执行文件,输入到unicorn引擎中,并通过回调,为unicorn的模拟环境提供数据的加载与输出功能

  • contracts:在EVM中,用solidity模拟了MIPS的操作码运行过程

    • Challenge.sol:L1智能合约的API
    • MIPS.sol:核心为 function stepPC(bytes32 stateHash, uint32 pc, uint32 nextPC) internal returns (bytes32),用于在EVM中单步模拟MIPS操作码运行
    • MIPSMemory.sol:实现了一颗MPT树,用于映射mipsevm中内存快照的MPT(引用minigeth的MPT),并使用 mapping(bytes32 => Preimage) public preimage,存储 contracts/Challenge.sol/callWithTrieNodes 方法所提供的链上历史数据(数据原像)

mipsevm

// mipsevm/main.go

func main() {
	...
		mu := GetHookedUnicorn(root, ram, func(step int, mu uc.Unicorn, ram map[uint32](uint32)) {
			if step == regfault {
				fmt.Printf("regfault at step %d\n", step)
				mu.RegWrite(uc.MIPS_REG_V0, 0xbabababa)
			}
			if step == target {
				SyncRegs(mu, ram)
				fn := fmt.Sprintf("%s/checkpoint_%d.json", root, step)
				WriteCheckpoint(ram, fn, step)
				if step == target {
					// done
					mu.RegWrite(uc.MIPS_REG_PC, 0x5ead0004)
				}
			}
			lastStep = step + 1
		})
	...
}
// mipsevm/run_unicorn.go

import (
	...
	uc "github.com/unicorn-engine/unicorn/bindings/go/unicorn"
)

func GetHookedUnicorn(root string, ram map[uint32](uint32), callback func(int, uc.Unicorn, map[uint32](uint32))) uc.Unicorn {
	mu, err := uc.NewUnicorn(uc.ARCH_MIPS, uc.MODE_32|uc.MODE_BIG_ENDIAN)
	check(err)

	...

	check(mu.MemMap(0, 0x80000000))
	return mu
}

我们可以看到,mipsevm通过 import uc "github.com/unicorn-engine/unicorn/bindings/go/unicorn" 引入了unicorn的golang sdk,并设置了 (uc.ARCH_MIPS, uc.MODE_32|uc.MODE_BIG_ENDIAN),也就是32位的MIPS架构,并使用了大端模式

// mipsevm/run_unicorn.go

func GetHookedUnicorn(root string, ram map[uint32](uint32), callback func(int, uc.Unicorn, map[uint32](uint32))) uc.Unicorn {
	mu, err := uc.NewUnicorn(uc.ARCH_MIPS, uc.MODE_32|uc.MODE_BIG_ENDIAN)
	check(err)

	_, outputfault := os.LookupEnv("OUTPUTFAULT")

	mu.HookAdd(uc.HOOK_INTR, func(mu uc.Unicorn, intno uint32) {
		if intno != 17 {
			log.Fatal("invalid interrupt ", intno, " at step ", steps)
		}
		syscall_no, _ := mu.RegRead(uc.MIPS_REG_V0)
		v0 := uint64(0)
		if syscall_no == 4020 {
			oracle_hash, _ := mu.MemRead(0x30001000, 0x20)
			hash := common.BytesToHash(oracle_hash)
			key := fmt.Sprintf("%s/%s", root, hash)
			value, err := ioutil.ReadFile(key)
			check(err)

			tmp := []byte{0, 0, 0, 0}
			binary.BigEndian.PutUint32(tmp, uint32(len(value)))
			mu.MemWrite(0x31000000, tmp)
			mu.MemWrite(0x31000004, value)

			WriteRam(ram, 0x31000000, uint32(len(value)))
			value = append(value, 0, 0, 0)
			for i := uint32(0); i < ram[0x31000000]; i += 4 {
				WriteRam(ram, 0x31000004+i, binary.BigEndian.Uint32(value[i:i+4]))
			}
		}
		...
	}, 0, 0)

	if callback != nil {
		mu.HookAdd(uc.HOOK_MEM_WRITE, func(mu uc.Unicorn, access int, addr64 uint64, size int, value int64) {
			rt := value
			rs := addr64 & 3
			addr := uint32(addr64 & 0xFFFFFFFC)
			if outputfault && addr == 0x30000804 {
				fmt.Printf("injecting output fault over %x\n", rt)
				rt = 0xbabababa
			}
			//fmt.Printf("%X(%d) = %x (at step %d)\n", addr, size, value, steps)
			if size == 1 {
				mem := ram[addr]
				val := uint32((rt & 0xFF) << (24 - (rs&3)*8))
				mask := 0xFFFFFFFF ^ uint32(0xFF<<(24-(rs&3)*8))
				WriteRam(ram, uint32(addr), (mem&mask)|val)
			} else if size == 2 {
				mem := ram[addr]
				val := uint32((rt & 0xFFFF) << (16 - (rs&2)*8))
				mask := 0xFFFFFFFF ^ uint32(0xFFFF<<(16-(rs&2)*8))
				WriteRam(ram, uint32(addr), (mem&mask)|val)
			} else if size == 4 {
				WriteRam(ram, uint32(addr), uint32(rt))
			} else {
				log.Fatal("bad size write to ram")
			}

		}, 0, 0x80000000)

		mu.HookAdd(uc.HOOK_CODE, func(mu uc.Unicorn, addr uint64, size uint32) {
			callback(steps, mu, ram)
			steps += 1
		}, 0, 0x80000000)
	}
	...
}

unicorn的sdk提供了hook方式,作为模拟引擎与外部交互的通道,mipsevm通过3个hook函数完成了加载、内存映射和单步执行的功能:

  • uc.HOOK_INTR:系统中断时的回调,通过读取寄存器uc.MIPS_REG_V0中syscall_no(系统中断码),例如 syscall_no == 4020,会将预生成(minigeth/main.go中会详细介绍)的Preimage(原像,block的历史数据)通过 mu.MemWrite 写入到unicorn引擎的内存中,用于支持minigeth在MIPS中的运行过程(即,重跑L1的交易)
  • uc.HOOK_MEM_WRITE:写内存时的回调,将unicorn引擎中的内存状态映射出来,mipsevm使用 ram map[uint32](uint32) 全程记录,可以将ram理解为MIPS的内存状态
  • uc.HOOK_CODE:操作码运行的回调,每个操作码运行完毕后都会回调一次,这是mipsevm的单步运行基础

以上HOOK的注册是OS级别的,就是这个特性支持了cannon与主链的解藕,可以将minigeth替换成其他主链的终端,运行逻辑依然成立

// mipsevm/main.go

func main() {
		...

		LoadMappedFileUnicorn(mu, "mipigo/minigeth.bin", ram, 0)
		if root == "" {
			WriteCheckpoint(ram, fmt.Sprintf("%s/golden.json", basedir), -1)
			fmt.Println("exiting early without a block number")
			os.Exit(0)
		}

		LoadMappedFileUnicorn(mu, fmt.Sprintf("%s/input", root), ram, 0x30000000)

		mu.Start(0, 0x5ead0004)
		SyncRegs(mu, ram)
	}
// minigeth/main.go

func main() {
	...

	// init secp256k1BytePoints
	crypto.S256()

	// get inputs
	inputBytes := oracle.Preimage(oracle.InputHash())
	var inputs [6]common.Hash
	for i := 0; i < len(inputs); i++ {
		inputs[i] = common.BytesToHash(inputBytes[i*0x20 : i*0x20+0x20])
	}

	...
}

通过 LoadMappedFileUnicorn 方法,将mipigo输出的MIPS平台下的miniget可执行文件加载到MIPS内存的开头中,并将预生成的input(minigeth/main.go中会详细介绍)加载到特定位置,由于 mipigo/minigeth.bin 是不跟参数的,所以,minigeth/main.go 在MIPS中运行时是直接从上述代码 crypto.S256 开始的,而 oracle.InputHash() 得到的值,便是刚才加载到特定位置的input值,并且使用 orcal.Preimage()获取Preimage的时候,系统会触发 syscall == 4020,导致Preimage文件被加载到MIPS的内存中

// mipsevm/main.go

func WriteCheckpoint(ram map[uint32](uint32), fn string, step int) {
	trieroot := RamToTrie(ram)
	dat := TrieToJson(trieroot, step)
	fmt.Printf("writing %s len %d with root %s\n", fn, len(dat), trieroot)
	ioutil.WriteFile(fn, dat, 0644)
}

func main() {
		...
			if step == target {
				SyncRegs(mu, ram)
				fn := fmt.Sprintf("%s/checkpoint_%d.json", root, step)
				WriteCheckpoint(ram, fn, step)
				if step == target {
					// done
					mu.RegWrite(uc.MIPS_REG_PC, 0x5ead0004)
				}
			}
			lastStep = step + 1
		})

		...
}

通过uc.HOOK_CODE的HOOK逻辑,mipsevm具备在任何MIPS操作码运行后,输出MIPS内存快照的能力,而输出的快照其实是 ram map[uint32]uint32 MPT化后的Json文件

minigeth

// minigeth/oracle/prefetch.go

...

func PrefetchStorage(blockNumber *big.Int, addr common.Address, skey common.Hash, postProcess func(map[common.Hash][]byte)) {
	...
}

func PrefetchAccount(blockNumber *big.Int, addr common.Address, postProcess func(map[common.Hash][]byte)) {
	...
}

func PrefetchCode(blockNumber *big.Int, addrHash common.Hash) {
	...
}

...

func prefetchUncles(blockHash common.Hash, uncleHash common.Hash, hasher types.TrieHasher) {
	...
}

func PrefetchBlock(blockNumber *big.Int, startBlock bool, hasher types.TrieHasher) {
	...
}

func getProofAccount(blockNumber *big.Int, addr common.Address, skey common.Hash, storage bool) []string {
	...
}

...

prefetch.go 中的prefetch*函数可以从远端主链上得到所需要的运行时数据,包括完整block信息、account数据、account验证数据等等,minigeth在重放区块交易时,所使用到的区块历史数据等信息便是依赖 prefetch.go 得到的

// minigeth/main.go

func main() {
	...

	if len(os.Args) > 1 {
		...

		blockNumber, _ := strconv.Atoi(os.Args[1])
		// TODO: get the chainid
		oracle.SetRoot(fmt.Sprintf("%s/0_%d", basedir, blockNumber))
		oracle.PrefetchBlock(big.NewInt(int64(blockNumber)), true, nil)
		oracle.PrefetchBlock(big.NewInt(int64(blockNumber)+1), false, pkwtrie)
		hash, err := pkwtrie.Commit()
		check(err)
		fmt.Println("committed transactions", hash, err)
	}

	// init secp256k1BytePoints
	crypto.S256()

	// get inputs
	inputBytes := oracle.Preimage(oracle.InputHash())
	var inputs [6]common.Hash
	for i := 0; i < len(inputs); i++ {
		inputs[i] = common.BytesToHash(inputBytes[i*0x20 : i*0x20+0x20])
	}

	...
}
// minigeth/oracle/prefetch.go

func PrefetchBlock(blockNumber *big.Int, startBlock bool, hasher types.TrieHasher) {
	r := jsonreq{Jsonrpc: "2.0", Method: "eth_getBlockByNumber", Id: 1}
	r.Params = make([]interface{}, 2)
	r.Params[0] = fmt.Sprintf("0x%x", blockNumber.Int64())
	r.Params[1] = true
	jsonData, err := json.Marshal(r)
	check(err)

	/*dat, _ := ioutil.ReadAll(getAPI(jsonData))
	fmt.Println(string(dat))*/

	jr := jsonrespt{}
	check(json.NewDecoder(getAPI(jsonData)).Decode(&jr))
	//fmt.Println(jr.Result)
	blockHeader := jr.Result.ToHeader()

	// put in the start block header
	if startBlock {
		blockHeaderRlp, err := rlp.EncodeToBytes(&blockHeader)
		check(err)
		hash := crypto.Keccak256Hash(blockHeaderRlp)
		preimages[hash] = blockHeaderRlp
		emptyHash := common.Hash{}
		if inputs[0] == emptyHash {
			inputs[0] = hash
		}
		return
	}

	// second block
	if blockHeader.ParentHash != inputs[0] {
		fmt.Println(blockHeader.ParentHash, inputs[0])
		panic("block transition isn't correct")
	}
	inputs[1] = blockHeader.TxHash
	inputs[2] = blockHeader.Coinbase.Hash()
	inputs[3] = blockHeader.UncleHash
	inputs[4] = common.BigToHash(big.NewInt(int64(blockHeader.GasLimit)))
	inputs[5] = common.BigToHash(big.NewInt(int64(blockHeader.Time)))

	// save the inputs
	saveinput := make([]byte, 0)
	for i := 0; i < len(inputs); i++ {
		saveinput = append(saveinput, inputs[i].Bytes()[:]...)
	}
	inputhash = crypto.Keccak256Hash(saveinput)
	preimages[inputhash] = saveinput
	ioutil.WriteFile(fmt.Sprintf("%s/input", root), inputhash.Bytes(), 0644)
	//ioutil.WriteFile(fmt.Sprintf("%s/input", root), saveinput, 0644)

	...
	// save the uncles
	prefetchUncles(blockHeader.Hash(), blockHeader.UncleHash, hasher)
}

我们应该可以理解到,不管是在MIPS平台下,还是在linux平台下,使用历史的blockNumber在minigeth中,运行顺序相同的交易后,所得出的结果都是相同的,所以,cannon在linux平台下预生成了一些文件,作为缓存使用:

  • Preimage文件:链上的历史数据,使用prefetch*得到
  • input文件:blockHeader的 Keccak256Hash,用于索引一个唯一的block数据

通过缓存这些数据,minigeth在MIPS下多次运行时,就不必再次远程到链上了

// minigeth/main.go

func main() {
	...

	bc := core.NewBlockChain(&parent)
	database := state.NewDatabase(parent)
	statedb, _ := state.New(parent.Root, database, nil)
	vmconfig := vm.Config{}
	processor := core.NewStateProcessor(params.MainnetChainConfig, bc, bc.Engine())

	...

	// read txs
	//traverseStackTrie(newheader.TxHash)

	//fmt.Println(fn)
	//fmt.Println(txTrieRoot)
	var txs []*types.Transaction

	triedb := trie.NewDatabase(parent)
	tt, _ := trie.New(newheader.TxHash, &triedb)
	tni := tt.NodeIterator([]byte{})
	for tni.Next(true) {
		//fmt.Println(tni.Hash(), tni.Leaf(), tni.Path(), tni.Error())
		if tni.Leaf() {
			tx := types.Transaction{}
			var rlpKey uint64
			check(rlp.DecodeBytes(tni.LeafKey(), &rlpKey))
			check(tx.UnmarshalBinary(tni.LeafBlob()))
			// TODO: resize an array in go?
			for uint64(len(txs)) <= rlpKey {
				txs = append(txs, nil)
			}
			txs[rlpKey] = &tx
		}
	}

	var uncles []*types.Header
	check(rlp.DecodeBytes(oracle.Preimage(newheader.UncleHash), &uncles))

	var receipts []*types.Receipt
	block := types.NewBlock(&newheader, txs, uncles, receipts, trie.NewStackTrie(nil))

	...

	// validateState is more complete, gas used + bloom also
	receipts, _, _, err := processor.Process(block, statedb, vmconfig)

	...
}

minigeth在将blockNumber推进到blockNumber+1之前,会做一些准备:

  • 使用 database := state.NewDatabase(parent) 构建起运行的数据基线,其内部使用 PrefetchAccount
  • 找出需要重放的交易 var txs []*types.Transaction 并将其封装到block中

做好准备后,便可以使用 processor.Process 进行交易重放了,如果该过程是在MIPS中进行的,minigeth的运行逻辑最终会被转译为MIPS操作码,从而,其运行过程和数据会被mipsevm的HOOK所捕获

Challenge.sol

// contracts/Challenge.sol

contract Challenge {

...

  struct ChallengeData {
    uint256 L;
    uint256 R;
    mapping(uint256 => bytes32) assertedState;
    mapping(uint256 => bytes32) defendedState;
    address payable challenger;
    uint256 blockNumberN;
  }

...

  function initiateChallenge(
      uint blockNumberN, bytes calldata blockHeaderNp1, bytes32 assertionRoot,
      bytes32 finalSystemState, uint256 stepCount)
    external
    returns (uint256)
  {
    	...
  }

  function callWithTrieNodes(address target, bytes calldata dat, bytes[] calldata nodes) public {
    for (uint i = 0; i < nodes.length; i++) {
      mem.AddTrieNode(nodes[i]);
    }
    ...
  }

  function proposeState(uint256 challengeId, bytes32 stateHash) external {
    ChallengeData storage c = challenges[challengeId];
    ...

    uint256 stepNumber = getStepNumber(challengeId);
    require(c.assertedState[stepNumber] == bytes32(0), "state already proposed");
    c.assertedState[stepNumber] = stateHash;
  }

  function respondState(uint256 challengeId, bytes32 stateHash) external {
    ChallengeData storage c = challenges[challengeId];
    ...

    uint256 stepNumber = getStepNumber(challengeId);
    require(c.assertedState[stepNumber] != bytes32(0), "challenger state not proposed");
    require(c.defendedState[stepNumber] == bytes32(0), "state already proposed");

    c.defendedState[stepNumber] = stateHash;

    if (c.assertedState[stepNumber] == c.defendedState[stepNumber]) {
      c.L = stepNumber; // agree
    } else {
      c.R = stepNumber; // disagree
    }
  }

  function confirmStateTransition(uint256 challengeId) external {
    ChallengeData storage c = challenges[challengeId];
    ...

    bytes32 stepState = mips.Step(c.assertedState[c.L]);
    require(stepState == c.assertedState[c.R], "wrong asserted state for challenger");

    // pay out bounty!!
    (bool sent, ) = c.challenger.call{value: address(this).balance}("");
    require(sent, "Failed to send Ether");

    emit ChallengerWins(challengeId);
  }

  function denyStateTransition(uint256 challengeId) external {
    ChallengeData storage c = challenges[challengeId];
    ...

    bytes32 stepState = mips.Step(c.defendedState[c.L]);

    if (c.defendedState[c.R] == bytes32(0)) {
      emit ChallengerLosesByDefault(challengeId);
      return;
    }

    require(stepState == c.defendedState[c.R], "wrong asserted state for defender");
    ...
  }

...
}

Challenge.sol的API设计的清晰明了,在链上的storage上会存储 ChallengeData 数据结构,用于支持后续的一系列挑战过程,这些过程包括:

  • initiateChallenge:用于Challenger发起挑战,其中会生成 challengeData 以及与其对应的索引Id( challengeId
  • callWithTrieNodes:是一个调用代理,其中dat是真正要调用的方法,而代理的作用是将MIPS的内存快照(bytes[] calldata nodes)加载到 MIPSMemory.sol中,这里需要注意,MIPS的内存快照是很大的(测试时有10MB~30MB),在 scripts/lib.getTrieNodesForCall 看到的思路是用到时加载
  • proposeStaterespondState:Challenger与Defender的攻防过程,使用c.L与c.R的二分查找模式,可以最终确定争议的stepNumber
  • confirmStateTransitiondenyStateTransition:Challenger与Defender获取仲裁结果的过程,该过程需要调用 callWithTrieNodes 导入MIPS的内存快照后才可以运行,其使用 mips.Step 方法单步生成stepNumber+1的内存快照RootHash,以支持最终的仲裁过程

上述过程也就是:发起挑战 -> 来回攻防 -> 发现确定争议步骤 -> 做出仲裁

posted on 2022-07-15 09:57  稻草篮儿  阅读(173)  评论(0编辑  收藏  举报

导航