之前看到一个用golang开发以太坊的教程
这个教程非常详细,然而它太陈旧了,目前很多go-ethereum函数接口已经有所修改。最明显的是,EIP1559之后,交易的格式的已经大不一样。因此,我基于上述教程,依据最新的go-ethereum(v1.10.26)对代码demo进行了修改。
用golang开发以太坊的一个好处是,可以很方便的查看和调试geth的源码,可以帮助我们更深入地理解以太坊的底层实现。
代码库为
下面对主要功能做简单介绍。
首先需要调用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)
获取当前区块/指定编号的区块
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())
}
}
可以直接从区块获得交易
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)
发送交易需要私钥,这里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)
随便找一个链上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()
打包
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])
连接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)
}
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)
读合约比较简单,不需要上链。
// 获取合约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
这一节本来是没必要写的,因为和第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)
我们以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
}
}
之前的例子,我们用了自己本来就有的私钥生成的账户,实际上,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启一个区块链,是没有办法进行这样的源码调试的。