这篇文章深入探讨了以太坊的交互机制,强调通过以太坊支持的工具链实现链上合约与链下应用(如Chainlink和Arbitrum)的有效交互,特别是在合约调用、事件监控及交易构造等方面的具体实现和注意事项。
我们要谈的交互
首先要明确一点,以太坊是一个去中心化的平台,他不可能为了某个项目而新增交互接口。
我这里说的交互是指应用是链上合约的交互,更明确的说,是chainlink,arbitrum,cosmos这些链下应用与链上合约的交互。
所以这里我不是想说以下交互方式:
- 通过钱包交互:将应用打包成一个网页,连接类似与
小狐狸
这样的钱包,与链发生交互。 - 手动构造交易:构建交易tx并使用私钥对交易进行签名,然后直接发送到链给定的接口上。
对于一些简单的调用,通过上面两种方式是可行的,例如我们只是做一些nft的构造,通过钱包是最合适的。但是对于向arbitrum这类
链上的应用,如果要做一个交互式单步证明,在这个过程中我需要监控链上合约抛出的event,分析event并构造出相应结果。
这个时候钱包就很难插手,而如果主动构造交易并签名,那么过程太繁琐。实际上以太坊上已经提供了相关的工具链。
我们要谈的交互方式就是,通过以太坊支持的工具链实现与以太坊的交互
工具链
简单而言是使用以太坊工具将sol的合约代码转换成go的类文件,并对调用细节进行封装。
而在应用层(arbitrum,chainlink这一层)可以直接将对应参数传过去就可以.
以arbitrum为例:
生成go代码
生成工具在: https://github.com/OffchainLabs/nitro/blob/master/solgen/gen.go
由于arbitrum用到链makefile,所我们没法通过运行这个文件(go run gen.go)的方式,去生成合约文件。
不过实际上这是一个路径的问题,在代码的第71行:
filePaths, err := filepath.Glob(filepath.Join(parent, "contracts", "build", "contracts", "src", "*", "*.sol", "*.json"))
if err != nil {
log.Fatal(err)
}
filePathsSafeSmartAccount, err := filepath.Glob(filepath.Join(parent, "safe-smart-account", "build", "artifacts", "contracts", "*", "*.sol", "*.json"))
if err != nil {
log.Fatal(err)
}
filePathsSafeSmartAccountOuter, err := filepath.Glob(filepath.Join(parent, "safe-smart-account", "build", "artifacts", "contracts", "*.sol", "*.json"))
if err != nil {
log.Fatal(err)
}
这里实际上就指定了合约代码的路径,当然如果只是初次下载合约文件应该是看不到build目录的,需要在合约所在项目构建一下,才能生成这个build文件
构造方法:
yarn --cwd contracts build
yarn --cwd contracts build:forge:yul
# 其实就是hardhat compile的产物
然后就会在solgen这个目录下生成对应的go文件
我们具体看一下这个生成的代码怎么用
使用生成的代码
首先可以看到在生成的代码中,每一个合约都有一个对应的类
eg:
// ChallengeLibTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract.
type ChallengeLibTransactorRaw struct {
Contract *ChallengeLibTransactor // Generic write-only contract binding to access the raw methods on
}
合约中的方法则对应到类的方法
eg:
// Solidity: function oneStepProveExecution(uint64 challengeIndex, (uint256,uint256,bytes32[],uint256) selection, bytes proof) returns()
func (_ChallengeManager *ChallengeManagerTransactor) OneStepProveExecution(opts *bind.TransactOpts, challengeIndex uint64, selection ChallengeLibSegmentSelection, proof []byte) (*types.Transaction, error) {
return _ChallengeManager.contract.Transact(opts, "oneStepProveExecution", challengeIndex, selection, proof)
}
其中关键在与这里的opts,如果我们继续进到这个Transact方法里面会发现,链上的信息都是由这里的opts获取的,用户签名接口,用户信息等
那么对于一个合约调用就分为两部分,一是调用参数,也就是这里的opts和合约参数,一是对接一台的的client
参数
进入到这里的opts,这是以太坊里面的数据结构
// valid Ethereum transaction.
type TransactOpts struct {
From common.Address // Ethereum account to send the transaction from
Nonce *big.Int // Nonce to use for the transaction execution (nil = use pending state)
Signer SignerFn // Method to use for signing the transaction (mandatory)
Value *big.Int // Funds to transfer along the transaction (nil = 0 = no funds)
GasPrice *big.Int // Gas price to use for the transaction execution (nil = gas price oracle)
GasFeeCap *big.Int // Gas fee cap to use for the 1559 transaction execution (nil = gas price oracle)
GasTipCap *big.Int // Gas priority fee cap to use for the 1559 transaction execution (nil = gas price oracle)
GasLimit uint64 // Gas limit to set for the transaction execution (0 = estimate)
GasMargin uint64 // Arbitrum: adjusts gas estimate by this many basis points (0 = no adjustment)
Context context.Context // Network context to support cancellation and timeouts (nil = no timeout)
NoSend bool // Do all transact steps but do not send the transaction
}
我们可以看到,这里包含用户信息的签名数据
对于链下的开发者,我们需要构造这个结构,来调用方法
client
我们有链合约的调用参数,那么就需要有一个client来为我们发送交易,(虽然构造交易的时候也用到链client,但这都是已经被工具封装好的,开发者没必要细究它是怎么构建的)
实际上client是在我们构建合约对象时构建的
// NewChallengeManager creates a new instance of ChallengeManager, bound to a specific deployed contract.
func NewChallengeManager(address common.Address, backend bind.ContractBackend) (*ChallengeManager, error) {
contract, err := bindChallengeManager(address, backend, backend, backend)
if err != nil {
return nil, err
}
return &ChallengeManager{ChallengeManagerCaller: ChallengeManagerCaller{contract: contract}, ChallengeManagerTransactor: ChallengeManagerTransactor{contract: contract}, ChallengeManagerFilterer: ChallengeManagerFilterer{contract: contract}}, nil
}
在构建ChallengeManger这个合约对象时,我们需要给他一个backend,这里的backend就是链的client,地址也就是链上的合约地址
可以看到backend也是bind这个包里面的,实际上它也是以太坊源码里面的包
type ContractBackend interface {
ContractCaller
ContractTransactor
ContractFilterer
}
type ContractCaller interface {
// CodeAt returns the code of the given account. This is needed to differentiate
// between contract internal errors and the local chain being out of sync.
CodeAt(ctx context.Context, contract common.Address, blockNumber *big.Int) ([]byte, error)
// CallContract executes an Ethereum contract call with the specified data as the
// input.
CallContract(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) ([]byte, error)
}
type ContractTransactor interface {
ethereum.GasEstimator
ethereum.GasPricer
ethereum.GasPricer1559
ethereum.TransactionSender
// HeaderByNumber returns a block header from the current canonical chain. If
// number is nil, the latest known header is returned.
HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error)
// PendingCodeAt returns the code of the given account in the pending state.
PendingCodeAt(ctx context.Context, account common.Address) ([]byte, error)
// PendingNonceAt retrieves the current pending nonce associated with an account.
PendingNonceAt(ctx context.Context, account common.Address) (uint64, error)
}
type ContractFilterer interface {
ethereum.LogFilterer
}
这个client看起来构造很麻烦,实际上也是有迹可循的,这里面都是以太坊里的数据结构,所以理论上以太坊里面已经有对象实现了这些接口
type Client struct {
c rpc.ClientInterface
}
type ClientInterface interface {
CallContext(ctx_in context.Context, result interface{}, method string, args ...interface{}) error
EthSubscribe(ctx context.Context, channel interface{}, args ...interface{}) (*ClientSubscription, error)
BatchCallContext(ctx context.Context, b []BatchElem) error
Close()
}
// Client represents a connection to an RPC server.
type Client struct {
idgen func() ID // for subscriptions
isHTTP bool // connection type: http, ws or ipc
services *serviceRegistry
idCounter atomic.Uint32
// This function, if non-nil, is called when the connection is lost.
reconnectFunc reconnectFunc
// config fields
batchItemLimit int
batchResponseMaxSize int
// writeConn is used for writing to the connection on the caller's goroutine. It should
// only be accessed outside of dispatch, with the write lock held. The write lock is
// taken by sending on reqInit and released by sending on reqSent.
writeConn jsonWriter
// for dispatch
close chan struct{}
closing chan struct{} // closed when client is quitting
didClose chan struct{} // closed when client quits
reconnected chan ServerCodec // where write/reconnect sends the new connection
readOp chan readOp // read messages
readErr chan error // errors from read
reqInit chan *requestOp // register response IDs, takes write lock
reqSent chan error // signals write completion, releases write lock
reqTimeout chan *requestOp // removes response IDs when call timeout expires
}