用golang开发ethereum

之前看到一个用golang开发以太坊的教程

这个教程非常详细,然而它太陈旧了,目前很多go-ethereum函数接口已经有所修改。最明显的是,EIP1559之后,交易的格式的已经大不一样。因此,我基于上述教程,依据最新的go-ethereum(v1.10.26)对代码demo进行了修改。

用golang开发以太坊的一个好处是,可以很方便的查看和调试geth的源码,可以帮助我们更深入地理解以太坊的底层实现。

代码库为

下面对主要功能做简单介绍。

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)
)

订阅函数为SubscribeNewHead

headers := make(chan *types.Header)
sub, err := client.SubscribeNewHead(context.Background(), headers)
if err != nil {
	  log.Fatal(err)
}

利用下面的代码可以监控最新block

for {
		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作为例子,找出消息签名和消息hash

txHash := 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标准,

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调用

连接rpc

var (
	url         = "https://eth-mainnet.g.alchemy.com/v2/" + os.Getenv("ALCHEMY_ID")
	client, err = rpc.DialHTTP(url)
)

利用rpc的eth_call方法,查询usdt的totalSupply

usdt := "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)

写合约比较复杂,因为需要发交易,因此需要构造transaction

auth, 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
源码调试EVM
Subscribe to rbtree
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.