go-ethereum 源码笔记(accounts, transaction 模块-账户和转账)

这一篇分析 geth 中与账户和转账相关的源代码。

交易是以太坊里的一个很核心的概念,它不仅仅体现在价值的转移上。我们知道智能合约是以太坊的一个重大创新,而智能合约的执行是依靠交易来触发的,可以这么说,在以太坊中,大部分场景的状态转换都依靠交易实现,而交易又与账户紧密相关,所以这一篇我们将账户,转账这两个模块结合在一起来探讨。

基本概念

以太坊引入账户状态概念取代比特币 UTXO 模型。账户以地址为索引,地址是公钥的最后20字节。

以太坊中有两类账户,一类是外部账户,一类是合约账户。每个账户都有一个与之关联的账户状态和一个10字节地址,都可以用来存储以太币。

  • 外部账户(EOA):由人创建,用私钥控制,没有代码与之关联,地址由公钥决定。私钥可用于对交易签名从而主动向其他账户发起交易进行消息传递。
  • 合约账户:外部账户创建,由合约代码控制,有代码与之关联,其地址由合约创造者地址和该地址发出过的交易数量 nonce 共同决定。不能主动向其他账户发起交易,但可以『响应』其他账户进行消息调用。

生成外部账户

生成一个账户地址大致是3步:

  1. 设置账户秘钥(根据用户密码等信息)
  2. 通过 secp256k1 椭圆曲线密码算法,由私钥生成对应的公钥
  3. 根据公钥得到相应的账户地址

私钥的三种形态

  • Private Key,随机生成的256位二进制数字
  • Keystore & Password,私钥和公钥以加密的方式保存一份 JSON 文件,存在 keystore 子目录下,这份 JSON 文件就是 Keystore,用户需要保存 Keystore,以及创建钱包时设置的密码。
  • Memonic code,由 bip 39 提出,随机生成12~24个比较容易记住的单词,通过 PBKDF2 和 HMAC-SHA512 函数创建随机种子,再生成钱包。

外部账户和合约账户的区别

外部账户可以通过创建以及用自己的私钥对交易进行签名,来发送消息给另一个外部账户或合约账户,在两个外部账户之间传送的消息只是一个简单的价值转移,但从外部账户到合约账户的消息会激活合约账户的代码,允许它执行各种动作(包括转移代币,写入内部存储,挖出新代币,执行运算,创建一个新合约等)。合约账户不能自己发起交易,它只能在接收到一个消息(可以来自外部账户或合约账户)之后,为响应该消息触发一个交易。

交易相关的基本概念

交易是指存储一条从外部账户发送到区块链上另一个账户的消息的签名数据包,它可以是以太币的转账,也可以是包含智能合约代码的消息。

交易有3种类型。

  1. 转账交易,从一个账户向另一个账户发送以太币
  2. 创建智能合约的交易,将合约部署到区块链上。
  3. 执行智能合约,执行已经部署在区块链上的智能合约。

转账流程

  • 用户输入转出的地址,转入的地址和转出的金额
  • 系统通过转出地址私钥对转账信息进行签名,以确保这笔交易是本人进行的
  • 系统对交易信息进行确认
  • 将交易加入到本地交易池
  • 将交易信息广播到其他节点

数据结构

账户(在 accounts/accounts.go 中定义)

1
2
3
4
type Account struct {
Address common.Address `json:"address"`
URL URL `json:"url"`
}

交易相关的数据结构

交易的数据结构在 core/types/transaction.go 中定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Transaction struct {
data txdata
hash atomic.Value
size atomic.Value
from atomic.Value
}

type txdata struct {
AccountNonce uint64 `json:"nonce" gencodec:"required"`
Price *big.Int `json:"gasPrice" gencodec:"required"`
GasLimit uint64 `json:"gas" gencodec:"required"`
Recipient *common.Address `json:"to" rlp:"nil"`
Amount *big.Int `json:"value" gencodec:"required"`
Payload []byte `json:"input" gencodec:"required"`
V *big.Int `json:"v" gencodec:"required"`
R *big.Int `json:"r" gencodec:"required"`
S *big.Int `json:"s" gencodec:"required"`
Hash *common.Hash `json:"hash" rlp:"-"`
}

Transaction 结构体中,实际上只有一个 data 是有效字段,另外3个 hash, size, from 是用做缓存的。txdata 结构体中没有交易发送者,因为发起者可以通过签名数据获得。txdata 结构体其他字段的含义可以在下表中查看:

字段 描述
AccountNonce 交易发送者已经发送交易的次数
Price 该交易的 gas 费用
GasLimit 本次交易允许消耗 gas 的最大数量
Recipient 交易接收者
Amount 交易的以太坊数量
Payload 交易携带的数据
V, R, S 交易的签名数据

代码分析

接口定义

accounts 模块实现以太坊客户端钱包,账户管理。智能合约的 ABI 代码也在 accounts/abi 目录下。钱包的接口在 accounts/accounts.go 中定义,目前有两种该接口实现,一个是 keyStore,一个是 usbwallet。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Wallet interface {
URL() URL
Status() (string, error)
Open(passphrase string) error
Close() error
Accounts() []Account
Contains(account Account) bool
Derive(path DerivationPath, pin bool) (Account, error)
SelfDerive(base DerivationPath, chain ethereum.ChainStateReader)
SignHash(account Account, hash []byte) ([]byte, error)
SignTx(account Account, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error)
SignHashWithPassphrase(account Account, passphrase string, hash []byte) ([]byte, error)
SignTxWithPassphrase(account Account, passphrase string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error)
}
接口 描述
URL() 获取钱包可以访问的规范路径。它会被用来给所有的后端钱包进行排序
Status() (string, error) 返回一个文本值标识当前钱包状态,同时返回一个 error 标识钱包遇到的任何错误。
Open(passphrase string) error 初始化对钱包实例的访问。
Close() error 释放由 Open 方法占用的任何资源
Accounts() []Account 获取钱包中签名的账户
Contains(account Account) bool 判断一个账户是否属于本钱包
Derive(path DerivationPath, pin bool) (Account, error) 尝试在指定派生路径上派生出分层确定性账户,如果 pin 为 true,派生账户添加到钱包的跟踪账户列表中。
SelfDerive(base DerivationPath, chain ethereum.ChainStateReader) 设置一个基本账户导出路径,从中钱包尝试发现非零账户,自动将其添加到跟踪账户列表中。
SignHash(account Account, hash []byte) ([]byte, error) 钱包需要额外验证才能签名时使用这个接口。
SignTx(account Account, tx types.Transaction, chainID big.Int) (*types.Transaction, error) 请求钱包对指定交易进行签名。
SignHashWithPassphrase(account Account, passphrase string, hash []byte) ([]byte, error) 请求钱包使用指定的 passphrase 给给定 hash 签名
SignTxWithPassphrase(account Account, passphrase string, tx types.Transaction, chainID big.Int) (*types.Transaction, error) 请求钱包使用给定 passphrase 给给定 transaction 签名。
1
2
3
4
type Backend interface {
Wallets() []Wallet
Subscribe(sink chan<- WalletEvent) event.Subscription
}

Backend 接口是一个钱包 provider,它包含一个钱包列表,在检测到钱包开启或关闭时可以接收到通知,可以用来请求签名交易。其中 Wallets() 返回当前可用的钱包,按字母顺序排序,Subscribe() 创建异步订阅的方法,当钱包发生变动时通过 chan 接收消息。

accounts/manager.go 中,定义了 Manager 结构体。

1
2
3
4
5
6
7
8
9
type Manager struct {
backends map[reflect.Type][]Backend
updaters []event.Subscription
updates chan WalletEvent
wallets []Wallet
feed event.Feed
quit chan chan error
lock sync.RWMutex
}

Manager 是管理账户的入口,可以与各种 backends 进行通信。

其中 backends 是当前已注册的所有 Backendupdaters 是所有 Backend 的更新订阅器,updatesBackend 对应 wallet 事件更新的 chan,wallets 是所有已经注册的 Backends 的钱包的缓存,feed 用于钱包事件的通知,quit 用于退出的事件。manager.go 的代码没有什么很特别的地方,有兴趣的话可以自行查看源代码,这里只做概述。这里只挑几个典型的,下面讲解业务实例时会用到的方法。

NewManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func NewManager(backends ...Backend) *Manager {
var wallets []Wallet
for _, backend := range backends {
wallets = merge(wallets, backend.Wallets()...)
}
updates := make(chan WalletEvent, 4*len(backends))

subs := make([]event.Subscription, len(backends))
for i, backend := range backends {
subs[i] = backend.Subscribe(updates)
}
am := &Manager{
backends: make(map[reflect.Type][]Backend),
updaters: subs,
updates: updates,
wallets: wallets,
quit: make(chan chan error),
}
for _, backend := range backends {
kind := reflect.TypeOf(backend)
am.backends[kind] = append(am.backends[kind], backend)
}
go am.update()
return am
}

NewManager 会将所有 backends 的 wallets 收集起来,获取所有的 backends 的时间订阅,然后根据这些参数创建新的 manager

update()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func (am *Manager) update() {
defer func() {
am.lock.Lock()
for _, sub := range am.updaters {
sub.Unsubscribe()
}
am.updaters = nil
am.lock.Unlock()
}()
for {
select {
case event := <-am.updates:
am.lock.Lock()
switch event.Kind {
case WalletArrived:
am.wallets = merge(am.wallets, event.Wallet)
case WalletDropped:
am.wallets = drop(am.wallets, event.Wallet)
}
am.lock.Unlock()

am.feed.Send(event)

case errc := <-am.quit:
errc <- nil
return
}
}
}

updateNewManager 作为一个 goroutine 被调用,一直运行,监控所有 backend 触发的更新消息,发给 feed 用来进行进一步的处理。

Subscribe(sink chan<- WalletEvent) event.Subscription

1
2
3
func (am *Manager) Subscribe(sink chan<- WalletEvent) event.Subscription {
return am.feed.Subscribe(sink)
}

返回一个异步的消息订阅对象,当钱包发生变动时可以收到信息。

Find(account Account) (Wallet, error)

1
2
3
4
5
6
7
8
9
10
11
func (am *Manager) Find(account Account) (Wallet, error) {
am.lock.RLock()
defer am.lock.RUnlock()

for _, wallet := range am.wallets {
if wallet.Contains(account) {
return wallet, nil
}
}
return nil, ErrUnknownAccount
}

Find 方法会对钱包进行遍历,找到某个账户的钱包,由于钱包中的账户是动态的增加或删除的,所以我们需要加锁。

创建账户

如果是通过命令行创建账户,可以使用 geth account new 命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func accountCreate(ctx *cli.Context) error {
cfg := gethConfig{Node: defaultNodeConfig()}
if file := ctx.GlobalString(configFileFlag.Name); file != "" {
if err := loadConfig(file, &cfg); err != nil {
utils.Fatalf("%v", err)
}
}
utils.SetNodeConfig(ctx, &cfg.Node)
scryptN, scryptP, keydir, err := cfg.Node.AccountConfig()
if err != nil {
utils.Fatalf("Failed to read configuration: %v", err)
}
password := getPassPhrase("Your new account is locked with a password. Please give a password. Do not forget this password.", true, 0, utils.MakePasswordList(ctx))
address, err := keystore.StoreKey(keydir, password, scryptN, scryptP)
if err != nil {
utils.Fatalf("Failed to create account: %v", err)
}
fmt.Printf("Address: {%x}\n", address)
return nil
}

逻辑很简单。首先获取配置信息,通过 getPassPhrase 获取密码后,通过 keystore.StoreKey 获得账户地址。

internal/ethapi/api.go 中,也可以通过 NewAccount 获取新账户,这个 api 可以通过交互式命令行或 rpc 接口调用。

1
2
3
4
5
6
7
func (s *PrivateAccountAPI) NewAccount(password string) (common.Address, error) {
acc, err := fetchKeystore(s.am).NewAccount(password)
if err == nil {
return acc.Address, nil
}
return common.Address{}, err
}

首先调用 fetchKeystore,通过 backends 获得 KeyStore 对象,最后通过调用 keystore.go 中的 NewAccount 获得新账户。

1
2
3
4
5
6
7
8
9
func (ks *KeyStore) NewAccount(passphrase string) (accounts.Account, error) {
_, account, err := storeNewKey(ks.storage, crand.Reader, passphrase)
if err != nil {
return accounts.Account{}, err
}
ks.cache.add(account)
ks.refreshWallets()
return account, nil
}

NewAccount 会调用 storeNewKey

1
2
3
4
5
6
7
8
9
10
11
12
func storeNewKey(ks keyStore, rand io.Reader, auth string) (*Key, accounts.Account, error) {
key, err := newKey(rand)
if err != nil {
return nil, accounts.Account{}, err
}
a := accounts.Account{Address: key.Address, URL: accounts.URL{Scheme: KeyStoreScheme, Path: ks.JoinPath(keyFileName(key.Address))}}
if err := ks.StoreKey(a.URL.Path, key, auth); err != nil {
zeroKey(key.PrivateKey)
return nil, a, err
}
return key, a, err
}

注意第一个参数是 keyStore,这是一个接口类型。

1
2
3
4
5
type keyStore interface {
GetKey(addr common.Address, filename string, auth string) (*Key, error)
StoreKey(filename string, k *Key, auth string) error
JoinPath(filename string) string
}

storeNewKey 首先调用 newKey,通过椭圆曲线加密算法获取公私钥对。

1
2
3
4
5
6
7
func newKey(rand io.Reader) (*Key, error) {
privateKeyECDSA, err := ecdsa.GenerateKey(crypto.S256(), rand)
if err != nil {
return nil, err
}
return newKeyFromECDSA(privateKeyECDSA), nil
}

然后会根据参数 ks 的类型调用对应的实现,通过 geth account new 命令创建新账户,调用的就是 accounts/keystore/keystore_passphrase.go 中的实现。即

1
2
3
4
5
6
7
func (ks keyStorePassphrase) StoreKey(filename string, key *Key, auth string) error {
keyjson, err := EncryptKey(key, auth, ks.scryptN, ks.scryptP)
if err != nil {
return err
}
return writeKeyFile(filename, keyjson)
}

我们可以深入到 EncryptKey

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
func EncryptKey(key *Key, auth string, scryptN, scryptP int) ([]byte, error) {
authArray := []byte(auth)
salt := randentropy.GetEntropyCSPRNG(32)
derivedKey, err := scrypt.Key(authArray, salt, scryptN, scryptR, scryptP, scryptDKLen)
if err != nil {
return nil, err
}
encryptKey := derivedKey[:16]
keyBytes := math.PaddedBigBytes(key.PrivateKey.D, 32)

iv := randentropy.GetEntropyCSPRNG(aes.BlockSize) // 16
cipherText, err := aesCTRXOR(encryptKey, keyBytes, iv)
if err != nil {
return nil, err
}
mac := crypto.Keccak256(derivedKey[16:32], cipherText)

scryptParamsJSON := make(map[string]interface{}, 5)
scryptParamsJSON["n"] = scryptN
scryptParamsJSON["r"] = scryptR
scryptParamsJSON["p"] = scryptP
scryptParamsJSON["dklen"] = scryptDKLen
scryptParamsJSON["salt"] = hex.EncodeToString(salt)

cipherParamsJSON := cipherparamsJSON{
IV: hex.EncodeToString(iv),
}

cryptoStruct := cryptoJSON{
Cipher: "aes-128-ctr",
CipherText: hex.EncodeToString(cipherText),
CipherParams: cipherParamsJSON,
KDF: keyHeaderKDF,
KDFParams: scryptParamsJSON,
MAC: hex.EncodeToString(mac),
}
encryptedKeyJSONV3 := encryptedKeyJSONV3{
hex.EncodeToString(key.Address[:]),
cryptoStruct,
key.Id.String(),
version,
}
return json.Marshal(encryptedKeyJSONV3)
}

EncryptKey 的 key 参数是加密的账户,包括 ID,公私钥,地址,auth 参数是用户输入的密码,scryptN 参数是 scrypt 算法中的 N,scryptP 参数是 scrypt 算法中的 P。整个过程,首先对密码使用 scrypt 算法加密,得到加密后的密码 derivedKey,然后用 derivedKey 对私钥使用 AES-CTR 算法加密,得到密文 cipherText,再对 derivedKey 和 cipherText 进行哈希运算得到 mac,mac 起到签名的作用,在解密的时候可以验证合法性,防止别人篡改。EncryptKey 最终返回 json 字符串,Storekey 方法接下来会将其保存在文件中。

列出所有账户

列出所有账户的入口也在 internal/ethapi/api.go 里。

1
2
3
4
5
6
7
8
9
func (s *PrivateAccountAPI) ListAccounts() []common.Address {
addresses := make([]common.Address, 0) // return [] instead of nil if empty
for _, wallet := range s.am.Wallets() {
for _, account := range wallet.Accounts() {
addresses = append(addresses, account.Address)
}
}
return addresses
}

该方法会从 Account Manager 中读取所有钱包信息,获取其对应的所有地址信息。

如果读者对 geth account 命令还有印象的话,geth account 命令还有 updateimport 等方法,这里就不再讨论了。

发起转账

发起一笔转账的函数入口在 internal/ethapi/api.go 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func (s *PublicTransactionPoolAPI) SendTransaction(ctx context.Context, args SendTxArgs) (common.Hash, error) {
account := accounts.Account{Address: args.From}
wallet, err := s.b.AccountManager().Find(account)
if err != nil {
return common.Hash{}, err
}
if args.Nonce == nil {
s.nonceLock.LockAddr(args.From)
defer s.nonceLock.UnlockAddr(args.From)
}
if err := args.setDefaults(ctx, s.b); err != nil {
return common.Hash{}, err
}
tx := args.toTransaction()

var chainID *big.Int
if config := s.b.ChainConfig(); config.IsEIP155(s.b.CurrentBlock().Number()) {
chainID = config.ChainId
}
signed, err := wallet.SignTx(account, tx, chainID)
if err != nil {
return common.Hash{}, err
}
return submitTransaction(ctx, s.b, signed)
}

转账时,首先利用传入的参数 from 构造一个 account,表示转出方。然后通过 accountManangerFind 方法获得这个账户的钱包(Find 方法在上面有介绍),接下来有一个稍特别的地方。我们知道以太坊采用的是账户余额的体系,对于 UTXO 的方式来说,防止双花的方式很直观,一个输出不能同时被两个输入而引用,这种方式自然而然地就防止了发起转账时可能出现的双花,采用账户系统的以太坊没有这种便利,以太坊的做法是,每个账户有一个 nonce 值,它等于账户累计发起的交易数量,账户发起交易时,交易数据里必须包含 nonce,而且该值必须大于账户的 nonce 值,否则为非法,如果交易的 nonce 值减去账户的 nonce 值大于1,这个交易也不能打包到区块中,这确保了交易是按照一定的顺序执行的。如果有两笔交易有相同 nonce,那么其中只有一笔交易能够成功,通过给 nonce 加锁就是用来防止双花的问题。接着调用 args.setDefaults(ctx, s.b) 方法设置一些交易默认值。最后调用 toTransaction 方法创建交易:

1
2
3
4
5
6
7
8
9
10
11
12
func (args *SendTxArgs) toTransaction() *types.Transaction {
var input []byte
if args.Data != nil {
input = *args.Data
} else if args.Input != nil {
input = *args.Input
}
if args.To == nil {
return types.NewContractCreation(uint64(*args.Nonce), (*big.Int)(args.Value), uint64(*args.Gas), (*big.Int)(args.GasPrice), input)
}
return types.NewTransaction(uint64(*args.Nonce), *args.To, (*big.Int)(args.Value), uint64(*args.Gas), (*big.Int)(args.GasPrice), input)
}

这里有两个分支,如果传入的交易的 to 参数不存在,那就表明这是一笔合约转账;如果有 to 参数,就是一笔普通的转账,深入后你会发现这两种转账最终调用的都是 newTransaction

1
2
3
4
5
6
7
func NewTransaction(nonce uint64, to common.Address, amount *big.Int, gasLimit uint64, gasPrice *big.Int, data []byte) *Transaction {
return newTransaction(nonce, &to, amount, gasLimit, gasPrice, data)
}

func NewContractCreation(nonce uint64, amount *big.Int, gasLimit uint64, gasPrice *big.Int, data []byte) *Transaction {
return newTransaction(nonce, nil, amount, gasLimit, gasPrice, data)
}

newTransaction 的功能很简单,实际上就是返回一个 Transaction 实例。我们接着看 SendTransaction 方法接下来的部分。创建好一笔交易,接着我们通过 ChainConfig 方法获得区块链的配置信息,如果是 EIP155 里描述的配置,需要做特殊处理(待深入),然后调用 SignTx 对交易签名来确保这笔交易是真实有效的。SignTx 的接口定义在 accounts/accounts.go 中,这里我们看 keystore 的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
func (ks *KeyStore) SignTx(a accounts.Account, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
ks.mu.RLock()
defer ks.mu.RUnlock()

unlockedKey, found := ks.unlocked[a.Address]
if !found {
return nil, ErrLocked
}
if chainID != nil {
return types.SignTx(tx, types.NewEIP155Signer(chainID), unlockedKey.PrivateKey)
}
return types.SignTx(tx, types.HomesteadSigner{}, unlockedKey.PrivateKey)
}

首先验证账户是否已解锁,若没有解锁,直接报异常退出。接着根据 chainID 判断使用哪一种签名方式,调用相应 SignTx 方法进行签名。

1
2
3
4
5
6
7
8
func SignTx(tx *Transaction, s Signer, prv *ecdsa.PrivateKey) (*Transaction, error) {
h := s.Hash(tx)
sig, err := crypto.Sign(h[:], prv)
if err != nil {
return nil, err
}
return tx.WithSignature(s, sig)
}

SignTx 的功能是调用椭圆加密函数获得签名,得到带签名的交易后,通过 SubmitTrasaction 提交交易。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func submitTransaction(ctx context.Context, b Backend, tx *types.Transaction) (common.Hash, error) {
if err := b.SendTx(ctx, tx); err != nil {
return common.Hash{}, err
}
if tx.To() == nil {
signer := types.MakeSigner(b.ChainConfig(), b.CurrentBlock().Number())
from, err := types.Sender(signer, tx)
if err != nil {
return common.Hash{}, err
}
addr := crypto.CreateAddress(from, tx.Nonce())
log.Info("Submitted contract creation", "fullhash", tx.Hash().Hex(), "contract", addr.Hex())
} else {
log.Info("Submitted transaction", "fullhash", tx.Hash().Hex(), "recipient", tx.To())
}
return tx.Hash(), nil
}

submitTransaction 首先调用 SendTx,这个接口在 internal/ethapi/backend.go 中定义,而实现在 eth/api_backend.go 中,这部分代码涉及到交易池,我们在单独的交易池章节进行探讨,这里就此打住。

将交易写入交易池后,如果没有因错误退出,submitTransaction 会完成提交交易,返回交易哈希值。发起交易的这个过程就结束了,剩下的就交给矿工将交易上链。挖矿相关的代码会在之后的博客中进行介绍。