理解以太坊合约数据读取过程 | 函数式与区块链编程(二)
关于函数式编程:
函数式编程是有别于传统的面对对象范式的编程范式,函数式方向是目前编程语言发展的大方向,所有新设计的编程语言都或多或少的引入了函数式编程功能。
笔者认为,「下一代计算机学科体系」将基于函数式编程语言。因此,打好函数式编程基础对于具备「长期主义」思维的程序员是必要的。
关于本专栏:
本专栏将通过实战代码分析与经典著作解读,分享作者关于函数式编程与区块链的思考与实践。就目前而言,本专栏将基于两种函数式语言:Rust 和 Elixir,有时候会提及其它语言,作为辅助性参考。
关于太上:
太上是笔者团队近期实践的一个函数式+区块链的项目。
太上炼金炉在不改变原有 NFT 合约的基础上,通过附加「存证合约」,赋予 NFT 组合、拆解、生命周期、权益绑定等能力,锻造 NFT +,创造无限创新玩法与想象空间。
愿景0x01:助力所有 NFT 及其相关项目,让其具备无限商业想象空间与无限玩法。
愿景0x02:成为下一代区块链基础设施
太上是本系列用以探讨函数式编程的第一个项目。
本篇是系列的第二篇,通过描述基于 Elixir 与 Ethereum 的交互方法,描述以太坊数据读取过程。需要注意的是,虽然名字是以太坊,但是这个过程对任何支持 EVM 的区块链都是适用的,例如:FISCO BCOS、Moobeam。
Ethereumex 与 ExABI
在 Elixir 中,使用到的两个 Repo,一个是 Ethereumex:
https://github.com/mana-ethereum/ethereumex
Elixir JSON-RPC client for the Ethereum blockchain.
另一个是 ExABI:
https://github.com/poanetwork/ex_abi
The Application Binary Interface[1] (ABI) of Solidity describes how to transform binary data to types which the Solidity programming language understands.
小 Tips:
ABI 是与 EVM 上的合约进行交互的标准方法,
.abi
文件中包含了函数接口描述与事件描述,呈现方式为json
。Hello World 合约的 ABI 如下:
[{
"constant": true,
"inputs": [],
"name": "get",
"outputs": [{
"name": "",
"type": "string"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}]
需要注意的是,我们在引入 Ethereumex 的时候,除了要常规的写入mix.exs
的deps
中以外,还需要在applications中挂载:
# mix.exs:
def application do
[
mod: {TaiShang.Application, []},
extra_applications: [:logger, :runtime_tools, :ethereumex]
]
end
……
defp deps do
[
{:ethereumex, "~> 0.7.0"}
]
end
还需要在config.exs
中设置访问的区块链节点url:
# config.exs
config :ethereumex,
url: "http://localhost:8545"
交易结构
在 Elixir 中,我们可以通过代码简单清晰地理解结构体(Struct)。
我们可以把 Ethereum 中的交易用如下的 Elixir Struct 表示:
%Transaction{
nonce: nonce, # 确保交易顺序的累加器
gas_price: @gas.price, # gas 费用
gas_limit: @gas.limit, # gas 上限
to: bin_to, # Binary 形式的地址
value: 0, # 要发送的以太币
init: << , # 机器码
data: data # 要发送给to地址的数据
}
需要注意的是,我们现在只做数据的读取,因此 nonce 这个参数是不需要的,nonce 参数只有在写操作时才会需要,也才会发生改变。
eth_call
Executes a new message call immediately without creating a transaction on the block chain.
Parameters
1.
Object
- The transaction call object•
from
:DATA
, 20 Bytes - (optional) The address the transaction is sent from.•to
:DATA
, 20 Bytes - The address the transaction is directed to.•gas
:QUANTITY
- (optional) Integer of the gas provided for the transaction execution. eth_call consumes zero gas, but this parameter may be needed by some executions.•gasPrice
:QUANTITY
- (optional) Integer of the gasPrice used for each paid gas•value
:QUANTITY
- (optional) Integer of the value sent with this transaction•data
:DATA
- (optional) Hash of the method signature and encoded parameters. For details see Ethereum Contract ABI in the Solidity documentation[2]1.
QUANTITY|TAG
- integer block number, or the string"latest"
,"earliest"
or"pending"
, see the default block parameter[3]Returns
DATA
- the return value of executed contract.Example
——https://eth.wiki/json-rpc/API
// Request
curl -X POST --data '{"jsonrpc":"2.0","method":"eth_call","params":[{see above}],"id":1}'
// Result
{
"id":1,
"jsonrpc": "2.0",
"result": "0x"
}
在 Rust 中我们也有相似的结构:
from: https://kauri.io/#collections/A%20Hackathon%20Survival%20Guide/sending-ethereum-transactions-with-rust/
let tx = TransactionRequest {
from: accounts[0],
to: Some(accounts[1]),
gas: None, // 即 gas limit
gas_price: None,
value: Some(U256::from(10000)),
data: None,
nonce: None,
condition: None
};
我们现在只是要把流程跑通,所以可以先不用去管 gas_price 和 gas_limit,写死即可:
@gas %{price: 0, limit: 300_000}
那么,现在只要搞定 2 个参数即可:to 和 data。
地址的 Binary 转换
@spec addr_to_bin(String.t()) :: Binary.t()
def addr_to_bin(addr_str) do
addr_str
|> String.replace("0x", "")
|> Base.decode16!(case: :mixed)
end
从智能合约函数到 Data
通过「函数字符串标识」与参数列表(params list)生成 data:
@spec get_data(String.t(), List.t()) :: String.t()
def get_data(func_str, params) do
payload =
func_str
|> ABI.encode(params)
|> Base.encode16(case: :lower)
"0x" <> payload
end
函数字符串标识的例子:
@func %{
balance_of: "balanceOf(address)",
token_of_owner_by_index: "tokenOfOwnerByIndex(address, uint256)",
token_uri: "tokenURI(uint256)",
get_evidence_by_key: "getEvidenceByKey(string)",
new_evidence_by_key: "newEvidenceByKey(string, string)",
mint_nft: "mintNft(address, string)",
owner_of: "ownerOf(uint256)"
}
简单来说就是「函数名(参数1类型, 参数2类型, …)」。
我们可以跳转过去,查看 encode
函数的实现:
def encode(function_signature, data, data_type \\ :input)
# 在这一步会把 string 格式的 function 解析为 function_selector
# 然后再次调用 encode 方法,传入 function_selector
def encode(function_signature, data, data_type) when is_binary(function_signature) do
function_signature
|> Parser.parse!()
|> encode(data, data_type)
end
def encode(%FunctionSelector{} = function_selector, data, data_type) do
TypeEncoder.encode(data, function_selector, data_type)
end
FunctionSelector 结构体:
iex(5)> ABI.Parser.parse!("baz(uint8)")
%ABI.FunctionSelector{
function: "baz",
input_names: [],
inputs_indexed: nil,
method_id: nil,
returns: [],
type: nil,
types: [uint: 8]
}
TypeEncoder.encode 最终负责把 data, function_selector 和 data_type 编译为 data_type,详见:
https://github.com/poanetwork/ex_abi/blob/57ba7eb1703d8b0cd0353a0a588feef139b7edf3/lib/abi/type_encoder.ex
返回数据的转换
调用合约时返回的数据需要从hex
形态的data
转换为对应的格式,所以我们要写个 TypeTransalator:
defmodule Utils.TypeTranslator do
……
def data_to_int(raw) do
raw
|> hex_to_bin()
|> ABI.TypeDecoder.decode_raw([{:uint, 256}])
|> List.first()
end
def data_to_str(raw) do
raw
|> hex_to_bin()
|> ABI.TypeDecoder.decode_raw([:string])
|> List.first()
end
def data_to_addr(raw) do
addr_bin =
raw
|> hex_to_bin()
|> ABI.TypeDecoder.decode_raw([:address])
|> List.first()
"0x" <> Base.encode16(addr_bin, case: :lower)
end
……
end
具体采用哪种方式视返回值的类型而定,我们可以通过 ABI 判定返回值:
{
"constant": true,
"inputs": [],
"name": "get",
"outputs": [{
"name": "",
"type": "string" # 返回值是string
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
}
合约调用函数—Elixir
现在只差最后一步了!我们只要将如上几个函数放在一个调用函数中,区块链数据读取就大功告成。
以get_balance
函数为例:
@spec balance_of(String.t(), String.t()) :: Integer.t()
def balance_of(contract_addr, addr_str) do
{:ok, addr_bytes} = TypeTranslator.hex_to_bytes(addr_str)
data = get_data("balanceOf(address)", [addr_bytes])
{:ok, balance_hex} =
Ethereumex.HttpClient.eth_call(%{ # 交易结构被Ethereumex 封装过了!
data: data,
to: contract_addr
})
TypeTranslator.data_to_int(balance_hex)
end
合约调用函数—Rust
最后是一个用rust-web3
去调用合约的例子:
extern crate hex;
use hex_literal::hex;
use web3::{
contract::{Contract, Options},
types::{U256, H160, Bytes},
};
#[tokio::main]
async fn main() -> web3::contract::Result<()> {
let _ = env_logger::try_init();
let http = web3::transports::Http::new("https://ropsten.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161")?;
let web3 = web3::Web3::new(http);
let addr_u8 = hex::decode("7Ad11de6d4C3DA366BC929377EE2CaFEcC412A10").expect("Decoding failed");
let addr_h160 = H160::from_slice(&addr_u8);
let contra = Contract::from_json(
web3.eth(),
addr_h160,
include_bytes!("../contracts/hello_world.json"),
)?;
// let acct:[u8; 20] = hex!("f24ff3a9cf04c71dbc94d0b566f7a27b94566cac").into();
let result = contra.query::<String, _, _,_>("get", (), None, Options::default(), None).await?;
println!("{}", result);
Ok(())
}
这个例子的完整项目见:
https://github.com/leeduckgo/eth-interactor-rs
References
[1]
Application Binary Interface: https://solidity.readthedocs.io/en/develop/abi-spec.html[2]
Ethereum Contract ABI in the Solidity documentation: https://solidity.readthedocs.io/en/latest/abi-spec.html[3]
default block parameter: https://eth.wiki/json-rpc/API#the-default-block-parameter