用golang开发ethereum

之前看到一个用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)
)

订阅函数为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标准,

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

连接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