Ethereum转账流程剖析

转载请注明出处:www.huamo.online
字节杭州 求贤若渴:

  1. https://job.toutiao.com/s/JXTdQaH
  2. https://job.toutiao.com/s/JXTMWW3
  3. https://job.toutiao.com/s/JXT1tpC
  4. https://job.toutiao.com/s/JXTdu6h

js console中转账ETH

在我之前的文章《Ethereum初探》中,已经详述了在以太坊私链中转账的操作,可以在那里细细查看。

背后流程

Geth Console执行JS通用流程

在探讨sendTransaction转账流程之前,让我们先看下JS执行的通用流程。如下图所示。

图中展示了从geth console启动jsre,到用户输入js代码,解释器执行代码,并通过RPC调用实际的go方法的完整流程。这个流程是通用的,即无论你输入什么js代码,刚开始的阶段都是如此,下面让我来详细剖析一下各个环节。

监听命令阶段

  • [1]当用户在命令行输入geth console启动客户端时,会调用localConsole()方法,启动一个全新的geth节点,同时附加上一个JS console,亦即JSRE。
1
2
3
4
5
6
7
// cmd/geth/consolecmd.go

func localConsole(ctx *cli.Context) error {
console, err := console.New(config)
console.Welcome() // 打印欢迎信息
console.Interactive()
}

代码中列出了3个比较关键的方法。console.New()会启动一个console实例,这个console实例包含了一个JSRE对象。

1
2
3
4
5
6
7
8
9
type Console struct {
client *rpc.Client // RPC client to execute Ethereum requests through
jsre *jsre.JSRE // JavaScript runtime environment running the interpreter
prompt string // Input prompt prefix string
prompter UserPrompter // Input prompter to allow interactive user feedback
histPath string // Absolute path to the console scrollback history
history []string // Scroll history maintained by the console
printer io.Writer // Output writer to serialize any display strings to
}

同时,console.New()中还包含了console的初始化工作:console.init()。这个初始化工作有2点比较重要:

  1. 加载web3.js文件,并注入到JSRE

  2. rpc传输接口注入到JSRE中:jethObj.Object().Set("send", bridge.Send),这为之后执行JS代码提供了通信基础。其中bridge.go正如名字表示的那样,是一个工具方法集合,将js运行时环境和Go RPC调用桥接起来。

注入的方式为otto原生提供,它允许你自定义Go方法,并注入到解释器中,这样你就可以直接在js代码中调用这个go方法。如下图所示

Welcome()没什么好说,打印欢迎信息。

  • [2]接下来的console.Interactive()则是略为关键,它开启了一个交互式会话,包含2个关键的通道(chan):scheduler, abort。前者监听用户输入,后者则接受用户强行结束会话的信号。

    Interactive()单独启动一个goroutine,将用户的输入放在scheduler通道中,然后在主进程中使用select获取两个通道的输出。

1
2
3
// console/console.go

func (c *Console) Interactive() {}
  • [3]在获取到用户的输入后,接下来就开始执行代码,这里面借用了一个第三方库github.com/robertkrimen/otto,它是一个golang实现的js解释器。在scheduler通道获取用户输入后,会使用jsre.Evaluate()方法执行input内容,该方法内部直接调用ottovm.Run()方法,传入解释器并运行。如前所述,在console.init()过程中,jsre中已经注入了web3.jssend,所以实际执行过程中,都会通过bridge.Send()将方法RPC到以太坊的API服务中去执行,并返回执行结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

// 调用栈
c.Evaluate(input) -> c.jsre.Evaluate(statement, c.printer) -> vm.Run(code)

// console/console.go
func (c *Console) Evaluate(statement string) error {}

// internal/jsre/jsre.go
func (self *JSRE) Evaluate(code string, w io.Writer) error {}

// vendor/github.com/robertkrimen/otto/otto.go
func (self Otto) Run(src interface{}) (Value, error) {}

// console/bridge.go
func (b *bridge) Send(call otto.FunctionCall) (response otto.Value) {}

发送请求

上面说过,基本上所有的方法都是通过console/bridge.go中的bridge.Send()方法发送出去,我在该方法中加了一条调试语句,可以看到在请求发送前,数据包是什么样的。

1
2
3
4
5
6
7
// console/bridge.go

func (b *bridge) Send(call otto.FunctionCall) (response otto.Value) {
//...
log.Info("-->[my]check bridge send", "reqs", reqs)
//...
}

从图中可以看出,例如eth.accountspersonal.unlockAccount()eth.sendTransaction()这些方法都走到了Send()中。其中eth.sendTransaction()这个js方法对应于eth_sendTransaction()的golang方法,而传参是一个结构体对象。

请求reqs其实是一个数组,也就是说可以支持批量发送请求,在Send()代码中,也是用一个for循环遍历发送请求的。

reqs中的ID用以标记请求编号,这样收到resp后,可以用同样的ID标记它,这样好对应起来。

如果你在go-ethereumeth_sendTransaction()这个方法,会很失望,并没有想要的方法声明。问题的关键是bridge是如何将reqs请求send出去的,看起来是又做了一层处理,发送代码为b.client.Call(&result, req.Method, req.Params...)。所以我们需要看看Call()的实现。好吧,失算了,Call()并没有做什么特殊处理,只是包装了一个msg,并将其发送了出去。

接受请求

ok,既然走到rpc/client.go中的send()方法也并没有什么特殊处理,那么只能到rpc/server.go中寻找eth_sendTransaction()的答案了。

由于我们是geth attach方式运行的jsre,所以是通过IPC方式和服务端进行的通信,这里重点查看IPC RPC服务端的逻辑。

而玄机就在rpc/server.goreadRequest()方法中,该方法就像一个预处理器,会读取一批次请求数据,解析数据,将结果递交上层serveRequest()方法,而后者则会递交给handle()方法,通过反射调用对应的方法,并将结果返回给客户端。

对于readRequest()方法,它首先通过调用rpc/json.go中的parseRequest()方法,将请求中的Method进行处理,这里便给出了eth_sendTransaction()的处理方案。让我们看看代码中是如何处理的。

1
2
3
4
5
6
7
8
9
// rpc/json.go

func parseRequest(incomingMsg json.RawMessage) ([]rpcRequest, bool, Error) {
...
// serviceMethodSeparator = "_"
elems := strings.Split(in.Method, serviceMethodSeparator)
...
return []rpcRequest{{service: elems[0], method: elems[1], id: &in.Id, params: in.Payload}}, false, nil
}

所以,对于我们在jsre中输入的eth.sendTransaction()这样的命令,客户端会包装为eth_sendTransaction作为Method发送给远程服务器,而到了服务端,则会拆分这个字符串,并把eth作为服务名service,而把sendTransaction作为方法名method交给后续处理。

RPC服务端启动详解

服务端的这种解析请求的方式需要说明一下,这来自于RPC服务端启动的实现,这里面有很多独到的设计,大量用到了Golang的反射特性,可以让各个API分散在独立的模块中,而调用则可以统一在一个handle()中。

在以太坊RPC服务端启动的时候,实际上是位于P2P服务启动之后,恰好,我完整的替换过以太坊的P2P实现,所以对P2P这一块还算略微熟悉,哈哈。P2P包含了两层,底层KAD实现通信基础设施,上层运行各种子协议,包括eth / shh / bzz / les等协议,实现了以太坊的大部分主体功能。这里有张图,简要讲述了以太坊的节点发现原理。

再扯回来,节点可以统一管理各种子服务,之所以称为子服务更准确点,是因为它包含了P2P的子协议,也包含了RPC的API。以太坊首先在各个独立模块中,实现了这些子服务,在geth启动时,会将这些子服务实例化为node/service.go:Service类型(实现了这个接口的所有方法即可),并装入到map[reflect.Type]Service哈希表中。其中主键是这个子服务结构体的数据类型。这个过程我们称之为注册子服务

下面用注册eth子服务来举例说明

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
// cmd/utils/flags.go

// 先把服务创建方法托管好
// 定义一个可以创建eth.Ethereum对象的方法,将其托管到node.serviceFuncs中
func RegisterEthService(stack *node.Node, cfg *eth.Config) {
...
err = stack.Register(func(ctx *node.ServiceContext) (node.Service, error) {
fullNode, err := eth.New(ctx, cfg)
...
return fullNode, err
})
...
}

// node/node.go

func (n *Node) Start() error {
...
// 调用托管的方法,实例化服务,并放到哈希表中
services := make(map[reflect.Type]Service)
for _, constructor := range n.serviceFuncs {
...
service, err := constructor(ctx) // 其实这里constructor方法就是返回一个`&eth.Ethereum结构体`,所以service是个结构体。
...
kind := reflect.TypeOf(service)
...
services[kind] = service
}
...
}

再来细看看所谓的子服务到底是个什么玩意。

1
2
3
4
5
6
7
8
9
10
11
12
// node/service.go

type Service interface {

Protocols() []p2p.Protocol

APIs() []rpc.API

Start(server *p2p.Server) error

Stop() error
}

这里面有2个关键的方法Protocols() & APIs(),前者维护了子协议实例,它是一个结构体,其中有一个重要的Run()方法,定义了特定的子协议该如何运行,包括启动和消息循环,这都是P2P的内容,暂时不用理会,而后者,APIs()则是我们今天的主角,它会返回这个子协议提供的所有API方法。

1
2
3
4
5
6
7
8
// rpc/types.go

type API struct {
Namespace string
Version string
Service interface{}
Public bool
}

这里的Namespace标记了该API方法所属的命名空间,Service则托管了实际的API方法。sendTransaction()隶属eth协议,所以eth_sendTransaction的意思是:调用eth协议中的sendTransaction()方法。

到了API struct这一层还不够,因为API.Service接受的是API方法句柄,它并不能够被具名调用,而我们知道(假设大家都知道),反射可以做这样的事情:我反射一个方法,然后再通过reflect:MethodByName()获取到方法反射体,进而reflect:Call()就可以调用这个方法。这正是我们需要的,所以在rpc/server.go:RegisterName()中,将方法命名空间和方法句柄注册到服务器哈希表中,其中方法句柄被反射,方便之后处理请求时直接调用。

1
2
3
4
5
6
7
8
9
10
11
12
// rpc/endpoints.go

// api.Service托管了API实际方法,在这里连同其命名空间注册到API哈希表中。
func StartIPCEndpoint(ipcEndpoint string, apis []API) (net.Listener, *Server, error) {
handler := NewServer()
for _, api := range apis {
if err := handler.RegisterName(api.Namespace, api.Service); err != nil {
return nil, nil, err
}
}
...
}

注册完之后,就是启动RPC服务了,在node/node.go:startRPC()中,我们可以看到,以太坊实际上会启动4种RPC服务:startInProc() / startIPC() / startHTTP() / startWS(),虽然我们常常看到的各种文档上都是3种:IPC / HTTP / WebSocket,那是因为InProc是进程内部通讯,不暴露出来的而已。注意:InProcIPC是2种服务,前者是InProcess进程内通信,后者是Inter Process Communication进程间通信。

sendTransaction()实现

根据上面的原理,我们可以通过Namespace: "eth"在代码中寻找合适的sendTransaction()方法,你也许会找到如下两个疑似正确的方法:

1
2
3
4
5
// internal/ethapi/api.go

func (s *PrivateAccountAPI) SendTransaction()

func (s *PublicTransactionPoolAPI) SendTransaction()

但你最终会发现PublicTransactionPoolAPI.SendTransaction()才是我们需要的,因为它的Namespaceeth,而PrivateAccountAPI.SendTransaction()的命名空间则是personal

至于为什么这里的方法名大写开头,而我们传进来的则是eth_sendTransaction,这是如何匹配上的,玄机在于RPC服务反射注册API方法时,suitableCallbacks()做了这个dirty work,有兴趣的可以到这里查看它做了哪些工作。

EIP155是关于代号伪龙的以太坊第四次硬分叉中,一种简单的防重放攻击的保护措施文档:在伪龙升级(区块编号2675000)以后,当交易的transaction.v满足一定条件时,计算交易哈希时,不再仅哈希前6个元素,而是哈希9个元素。

参考资料

转载请注明出处:www.huamo.online