# 用golang开发ethereum **Published by:** [rbtree](https://paragraph.com/@rbtree/) **Published on:** 2022-11-12 **URL:** https://paragraph.com/@rbtree/golang-ethereum ## Content 之前看到一个用golang开发以太坊的教程 https://goethereumbook.org/zh/ 这个教程非常详细,然而它太陈旧了,目前很多go-ethereum函数接口已经有所修改。最明显的是,EIP1559之后,交易的格式的已经大不一样。因此,我基于上述教程,依据最新的go-ethereum(v1.10.26)对代码demo进行了修改。 用golang开发以太坊的一个好处是,可以很方便的查看和调试geth的源码,可以帮助我们更深入地理解以太坊的底层实现。 代码库为 https://github.com/CryptoRbtree/goeth-client 下面对主要功能做简单介绍。1 账户首先需要调用ethclient.DialContext连接一个rpc,这个rpc可以是外部服务商提供的公链rpc,也可以是本地区块链的。var ( ctx = context.Background() url = "https://eth-mainnet.g.alchemy.com/v2/" + os.Getenv("ALCHEMY_ID") client, err = ethclient.DialContext(ctx, url) ) BalanceAt获取某账户的eth余额account := common.HexToAddress("0xab5801a7d398351b8be11c439e05c5b3259aec9b") // vitalik balance, err := client.BalanceAt(context.Background(), account, nil) CodeAt获取某账户的code(EOA账户code为空)contractAccount := common.HexToAddress("0x220866B1A2219f40e72f5c628B65D54268cA3A9D") // vitalik multisig code, err := client.CodeAt(context.Background(), contractAccount, nil) 2 区块获取当前区块/指定编号的区块header, err := client.HeaderByNumber(context.Background(), nil) blockNumber := big.NewInt(15500000) block, err := client.BlockByNumber(context.Background(), blockNumber) fmt.Println("block number =", block.Number().Uint64()) fmt.Println("block timestamp =", block.Time()) fmt.Println("block difficulty =", block.Difficulty().Uint64()) fmt.Println("block hash =", block.Hash().Hex()) fmt.Println("block transaction count =", len(block.Transactions())) 对于区块,可以实现订阅功能。不过如果想使用订阅,不能使用https的rpc,而要用wss,因为订阅之后,服务器需要主动向客户端发消息。var ( ctx = context.Background() url = "wss://eth-mainnet.g.alchemy.com/v2/" + os.Getenv("ALCHEMY_ID") client, err = ethclient.DialContext(ctx, url) ) 订阅函数为SubscribeNewHeadheaders := make(chan *types.Header) sub, err := client.SubscribeNewHead(context.Background(), headers) if err != nil { log.Fatal(err) } 利用下面的代码可以监控最新blockfor { select { case err := <-sub.Err(): log.Fatal(err) case header := <-headers: fmt.Println("----new block mined----") block, err := client.BlockByHash(context.Background(), header.Hash()) if err != nil { log.Fatal(err) } fmt.Println("block number:", block.Number().Uint64()) fmt.Println("block hash:", block.Hash().Hex()) fmt.Println("block timestamp:", block.Time()) } } 3 交易可以直接从区块获得交易blockNumber := big.NewInt(15000023) block, err := client.BlockByNumber(context.Background(), blockNumber) transactions := block.Transactions() fmt.Println("total transactions count:", len(transactions)) for ind, tx := range transactions { fmt.Println("----", ind, "----") fmt.Println("transaction hash:", tx.Hash().Hex()) fmt.Println("transaction value:", tx.Value().String()) fmt.Println("transaction gas limit:", tx.Gas()) } 也可以从交易哈希获得交易txHash := common.HexToHash("0xec9db5bfbcd30ad2e3070b626ed4f78abce88687c5d1eb23464242be5edcb537") tx, isPending, err := client.TransactionByHash(context.Background(), txHash) fmt.Println("transaction hash:", tx.Hash().Hex()) fmt.Println("transaction gas limit:", tx.Gas()) fmt.Println("isPending:", isPending) 4 发送交易发送交易需要私钥,这里ACCOUNT_KEY是环境变量里保存的私钥(不要用主钱包!)privateKey, err := crypto.HexToECDSA(os.Getenv("ACCOUNT_KEY")) publicKey := privateKey.Public() publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey) if !ok { log.Fatal("error casting public key to ECDSA") } fromAddress := crypto.PubkeyToAddress(*publicKeyECDSA) 获取账户nonce、链上gas等信息nonce, err := client.PendingNonceAt(context.Background(), fromAddress) value := big.NewInt(1) // 1 wei gasLimit := uint64(21000) gasFeeCap, err := client.SuggestGasPrice(context.Background()) gasTipCap, err := client.SuggestGasTipCap(context.Background()) toAddress := fromAddress // send eth to self var data []byte chainID, err := client.NetworkID(context.Background()) 构造交易,这里我们使用的是EIP1559之后的新交易类型。tx := types.NewTx(&types.DynamicFeeTx{ ChainID: chainID, Nonce: nonce, GasFeeCap: gasFeeCap, GasTipCap: gasTipCap, Gas: gasLimit, To: &toAddress, Value: value, Data: data, }) 私钥签名,发送交易signedTx, err := types.SignTx(tx, types.NewLondonSigner(chainID), privateKey) err = client.SendTransaction(context.Background(), signedTx) 5 用交易签名恢复出地址随便找一个链上transaction作为例子,找出消息签名和消息hashtxHash := common.HexToHash("0xec9db5bfbcd30ad2e3070b626ed4f78abce88687c5d1eb23464242be5edcb537") tx, _, err := client.TransactionByHash(context.Background(), txHash) // 消息签名sig v, r, s := tx.RawSignatureValues() R := r.Bytes() S := s.Bytes() V := byte(v.Uint64()) sig := make([]byte, 65) copy(sig[32-len(R):32], R) copy(sig[64-len(S):64], S) sig[64] = V // 消息hash signer := types.NewLondonSigner(tx.ChainId()) hash := signer.Hash(tx) 用Ecrecover计算地址,实际上是先计算公钥,再计算地址。publicKeyBytes, err := crypto.Ecrecover(hash.Bytes(), sig) publicKeyECDSA, err := crypto.UnmarshalPubkey(publicKeyBytes) address := crypto.PubkeyToAddress(*publicKeyECDSA).Hex() 6 rlp对消息进行打包和解包打包txHash := common.HexToHash("0xec9db5bfbcd30ad2e3070b626ed4f78abce88687c5d1eb23464242be5edcb537") tx, _, err := client.TransactionByHash(context.Background(), txHash) rawTx, err := rlp.EncodeToBytes(tx) 解包tx = new(types.Transaction) rawTxBytes, err := hex.DecodeString(hex.EncodeToString(rawTx)) rlp.DecodeBytes(rawTxBytes, &tx) 可以打印出解包得到的交易和原始交易进行对比,验证正确性。 不过,需要指出一点,这里的rlp得到的rawTx并不符合以太坊标准。以太坊交易type并没有参与rlp,而是被简单放在了其他参数的前面,可能是因为这样比较容易兼容老类型的交易。 EIP1559标准, https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1559.md We introduce a new EIP-2718 transaction type, with the format 0x02 || rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list, signature_y_parity, signature_r, signature_s])7 rpc调用连接rpcvar ( url = "https://eth-mainnet.g.alchemy.com/v2/" + os.Getenv("ALCHEMY_ID") client, err = rpc.DialHTTP(url) ) 利用rpc的eth_call方法,查询usdt的totalSupplyusdt := "0xdAC17F958D2ee523a2206206994597C13D831ec7" functionHash := crypto.Keccak256([]byte(string("totalSupply()"))) data := "0x" + hex.EncodeToString(functionHash[0:4]) req := request{usdt, data} var result string if err := client.Call(&result, "eth_call", req, "latest"); err != nil { log.Fatal(err) } 8 合约部署demo合约,Store.sol// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Store { event ItemSet(bytes32 key, bytes32 value); string public version; mapping (bytes32 => bytes32) public items; constructor(string memory _version) { version = _version; } function setItem(bytes32 key, bytes32 value) external { items[key] = value; emit ItemSet(key, value); } } 需要安装solidity编译器,solc。 顺便提一句,有时候我们可能需要安装不能版本的编译器,以方便编译不同时期的solidity文件,可以安装solc-select方便版本管理。 例如solc-select use 0.8.0,可指定当前版本为0.8.0 首先,我们需要用solc生成abi文件和deploy字节码文件solc --abi Store.sol -o . solc --bin Store.sol -o . 安装abigen工具go get -u github.com/ethereum/go-ethereum cd $GOPATH/src/github.com/ethereum/go-ethereum/ make make devtools 利用abigen生成go文件,pkg为生成的go包名。后面,我们的所有操作都是基于这个生成go文件。abigen --bin=Store.bin --abi=Store.abi --pkg=store --out=Store.go 部署合约auth, err := bind.NewKeyedTransactorWithChainID(privateKey, chainID) auth.Nonce = big.NewInt(int64(nonce)) auth.Value = big.NewInt(0) // in wei auth.GasLimit = uint64(500000) // in units //auth.GasPrice = big.NewInt(1e9) // only for legacy transactions auth.GasFeeCap = gasPrice auth.GasTipCap = gasTipCap input := "1.0" address, tx, instance, err := store.DeployStore(auth, client, input) 9 读/写合约读合约比较简单,不需要上链。// 获取合约instance address := common.HexToAddress("0xd3047d5bbcbcfe4256f9c1668c57b3d875c4adea") instance, err := store.NewStore(address, client) // 读合约 version, err := instance.Version(nil) 写合约比较复杂,因为需要发交易,因此需要构造transactionauth, err := bind.NewKeyedTransactorWithChainID(privateKey, chainID) auth.Nonce = big.NewInt(int64(nonce)) auth.Value = big.NewInt(0) // in wei auth.GasFeeCap = gasPrice auth.GasTipCap = gasTipCap auth.GasLimit = 500000 tx, err := instance.SetItem(auth, key, value) 此外,gasLimit可以通过调用EstimateGas来进行估计。parsed, err := abi.JSON(strings.NewReader(store.StoreABI)) encodedData, err := parsed.Pack("setItem", key, value) estimatedGas, err := client.EstimateGas(context.Background(), ethereum.CallMsg{ From: fromAddress, To: &address, Data: encodedData, GasFeeCap: gasPrice, GasTipCap: gasTipCap, }) auth.GasLimit = estimatedGas // in units 10 交互Erc20合约这一节本来是没必要写的,因为和第9节基本一致。 不同的是,因为只需要交互,不需要部署合约,所以并不需要全部的solidity文件源码,也不需要deploy字节码,只需要接口文件即可。solc --abi IERC20.sol -o . abigen --abi=IERC20.abi --pkg=token --out=IERC20.go 交互的代码和上节基本一样。tokenAddress := common.HexToAddress("0x6B175474E89094C44Da98b954EedeAC495271d0F") // DAI Address instance, err := token.NewToken(tokenAddress, client) address := common.HexToAddress("0x245cc372c84b3645bf0ffe6538620b04a217988b") // Olympus funds bal, err := instance.BalanceOf(&bind.CallOpts{}, address) 11 事件我们以ERC20的transfer为例,因此定义如下结构。type LogTransfer struct { From common.Address To common.Address Tokens *big.Int } 获取事件,指定合约地址和区块范围tokenAddress := common.HexToAddress("0x6B175474E89094C44Da98b954EedeAC495271d0F") // DAI Address query := ethereum.FilterQuery{ FromBlock: big.NewInt(15500000), ToBlock: big.NewInt(15500005), Addresses: []common.Address{ tokenAddress, }, } logs, err := client.FilterLogs(context.Background(), query) 过滤并打印出所有的Transfer事件contractAbi, err := abi.JSON(strings.NewReader(string(token.TokenABI))) logTransferSig := []byte("Transfer(address,address,uint256)") logTransferSigHash := crypto.Keccak256Hash(logTransferSig) for _, vLog := range logs { fmt.Printf("Log Block Number: %d\n", vLog.BlockNumber) fmt.Printf("Log Index: %d\n", vLog.Index) switch vLog.Topics[0].Hex() { case logTransferSigHash.Hex(): fmt.Printf("Log Name: Transfer\n") var transferEvent LogTransfer err := contractAbi.UnpackIntoInterface(&transferEvent, "Transfer", vLog.Data) if err != nil { log.Fatal(err) } transferEvent.From = common.HexToAddress(vLog.Topics[1].Hex()) transferEvent.To = common.HexToAddress(vLog.Topics[2].Hex()) fmt.Printf("From: %s\n", transferEvent.From.Hex()) fmt.Printf("To: %s\n", transferEvent.To.Hex()) fmt.Printf("Value: %s\n", transferEvent.Tokens.String()) } fmt.Printf("\n") } 和区块一样,事件也是支持订阅的(记得订阅时rpc不能用https,要用wss) 如下例子,可以用来监控所有DAI合约的事件。contractAddress := common.HexToAddress("0x6B175474E89094C44Da98b954EedeAC495271d0F") query := ethereum.FilterQuery{ Addresses: []common.Address{contractAddress}, } logs := make(chan types.Log) sub, err := client.SubscribeFilterLogs(context.Background(), query, logs) if err != nil { log.Fatal(err) } fmt.Printf("----subscribe DAI events----\n\n") for { select { case err := <-sub.Err(): log.Fatal(err) case vLog := <-logs: fmt.Printf("%+v\n\n", vLog) // pointer to log event } } 12 钱包生成之前的例子,我们用了自己本来就有的私钥生成的账户,实际上,geth提供了生成私钥的函数,有两种方式。 第一种方式,keystore.NewKeyStore。ks := keystore.NewKeyStore("./wallet", keystore.StandardScryptN, keystore.StandardScryptP) password := "secret" account, err := ks.NewAccount(password) fmt.Println("create Account", account.Address.Hex()) 这种方式的好处是,私钥可以被加密保存下来,以后可以从文件导出再次使用。file := "./wallet/UTC--2022-11-12T10-43-05.221514000Z--94e920211dd7f5ee2b3ab69ed2a491284a0690de" ks := keystore.NewKeyStore("./tmp", keystore.StandardScryptN, keystore.StandardScryptP) jsonBytes, err := ioutil.ReadFile(file) password := "secret" account, err := ks.Import(jsonBytes, password, password) fmt.Println("import Address", account.Address.Hex()) 第二种方式,crypto.GenerateKey。生成私钥之后,可以再算出公钥和地址。privateKey, err := crypto.GenerateKey() privateKeyBytes := crypto.FromECDSA(privateKey) fmt.Println("privateKey:", hexutil.Encode(privateKeyBytes)[2:]) publicKey := privateKey.Public() publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey) publicKeyBytes := crypto.FromECDSAPub(publicKeyECDSA) fmt.Println("publicKey:", hexutil.Encode(publicKeyBytes)[4:]) address := crypto.PubkeyToAddress(*publicKeyECDSA).Hex() fmt.Println("address:", address) hash := sha3.NewLegacyKeccak256() hash.Write(publicKeyBytes[1:]) fmt.Println("address:", hexutil.Encode(hash.Sum(nil)[12:])) 13 本地模拟区块链 backends.NewSimulatedBackend可在本地模拟,我们可以编辑创世区块的一些属性,例如给我们的地址赋予初始eth。genesisAlloc := map[common.Address]core.GenesisAccount{ address: { Balance: balance, }, } blockGasLimit := uint64(4712388) client := backends.NewSimulatedBackend(genesisAlloc, blockGasLimit) 发送交易和远端区块链一样,不同的是,我们可以通过Commit函数来控制矿工何时打包新区块。err = client.SendTransaction(context.Background(), signedTx) client.Commit() // mine 我觉得模拟区块链在学习区块链的底层知识时,是一个很有用的功能。我们可以通过dlv调试研究evm的执行细节,如果我们使用公链或者本地用hardhat启一个区块链,是没有办法进行这样的源码调试的。源码调试EVM ## Publication Information - [rbtree](https://paragraph.com/@rbtree/): Publication homepage - [All Posts](https://paragraph.com/@rbtree/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@rbtree): Subscribe to updates