1.概述

智能合约(Smart Contract)是时下非常热门的概念,但是它在 20 多年前就已经被非著名计算机科学家 Nick Szabo 提出了,它将智能合约描述为一种以信息化方式传播、验证或者执行合约的计算机协议,能够允许在没有第三方的情况下进行可信的交易,并且这些交易是无法被追踪、同时也是不可逆的。

A smart contract is a computer protocol intended to digitally facilitate, verify, or enforce the negotiation or performance of a contract. Smart contracts allow the performance of credible transactions without third parties. These transactions are trackable and irreversible.

浅入浅出智能合约-深研未来

作为区块链鼻祖的 Bitcoin 虽然通过 POW 实现了 分布式一致性,同时使用 UTXO 模型 存储和管理底层数据结构,实现了去中心化的分布式账本,并且在一定程度上实现了『可编程』这一特点,但是它的脚本机制非常简单,只是一个基于堆栈式的脚本语言,它不仅没有函数的功能,同时也不是图灵完备的,无法实现复杂的逻辑。

Ethereum 在今天一般被视为区块链 2.0 项目,与 Bitcoin 不同,Ethereum 平台实现了图灵完备的编程语言,这样我们就能够在 Ethereum 上编写和部署智能合约,利用 Ethereum 支持的编程语言 Solidity 以及 API 实现一些复杂的功能。

Solidity

在 Ethereum 上提到编写智能合约时,很难离开 Solidity 这门编程语言。想要系统、详细地学习 Solidity 建议直接阅读相关的 官方文档,文章并不会对详细展开介绍如何编写智能合约,而是会重点介绍这门编程语言是如何设计的。

浅入浅出智能合约-深研未来

虽然目前很多的基础链都使用 Solidity 作为平台支持的编程语言,但是也有一些基础链,例如 EOS 提供了 C++ 的 API 用于编写智能合约,这只是不同的平台基于不同目的之后做出的选择和权衡。不过在这篇文章中,我们会以 Ethereum 上的 Solidity 为例展开介绍。

组成

Solidity 是一门面向合约并且图灵完备的编程语言,这门编程语言总共包含四种不同的重要元素,ContractVariableFunctionEvent

浅入浅出智能合约-深研未来

其中合约(Contract)是 Solidity 中的核心概念,我们都知道 Ethereum 遵循 账户余额模型 实现底层的逻辑和设计数据结构,其中的账户总共分为两种,一种是被私钥控制的外部账户,这与 Bitcoin 中的地址非常相似,另一种就是被合约代码控制的账户,这些部署在 Ethereum 合约中的代码都使用 Solidity 进行编写,最终部署到整个网络中。

每一个合约账户中的代码都是一个 Contract,它与面向对象编程中类的概念非常类似,无论是合约还是类都可以有变量和函数,但是类是可以实例化的,合约并没有实例化这一功能,它的变量和函数可以直接在合约本身上访问或者调用。

浅入浅出智能合约-深研未来

变量、函数和事件其实都是属于某一个合约的,这些元素共同组成了一个合约的全部内容,其中变量和函数的功能相信也不需要过多解释,稍微有一些编程经验的人都会知道它们的作用,而事件就是面向合约编程语言中比较特殊的元素了。

事件

在区块链应用出现之前,绝大多数的代码都是运行在一个实例或者节点中的,如果一个服务想要在发生某些事件时对外界发出通知,往往都需要通过向消息队列中发送消息,订阅者可以订阅对应的主题并进行处理。

浅入浅出智能合约-深研未来

Ethereum 中对 Pub/Sub 的设计就是围绕事件进行的,事件是面向合约编程范式中语言层面就支持的元素,在我们使用其他编程语言,例如 C++、Golang 时,我们也可以在语言内部通过已有的元素定义一些事件,并向一些事件的相关方发出通知,由于合约需要保持逻辑尽可能简单、减少计算量消耗的特点,很多操作其实并不适合在链上直接执行,所以合约就在语言层面支持了事件,能够在期望的事件发生时直接通知相关方进行处理,不需要合约的开发者重复实现相同的逻辑。

在如下的合约中,我们定义了一个新的事件 Deposit

pragma solidity ^0.4.0;

contract ClientReceipt {
    event Deposit(
        address indexed _from,
        bytes32 indexed _id,
        uint _value
    );

    function deposit(bytes32 _id) public payable {
        emit Deposit(msg.sender, _id, msg.value);
    }
}

当交易调用合约 ClientReceipt 中的 deposit 函数时,合约就会发出 Deposit 事件,这些事件就可以被 Javascript 的 API 检测到:

var abi = /* abi as generated by the compiler */;
var ClientReceipt = web3.eth.contract(abi);
var clientReceipt = ClientReceipt.at("0x1234...ab67" /* address */);

var event = clientReceipt.Deposit(function(error, result) {
    if (!error)
        console.log(result);
});

当事件被合约中的函数触发时,事件中的参数就会作为一种特殊的数据结构存储在交易日志中,但是这些日志是无法在智能合约内部访问的。在 Ethereum 由事件组成的 Pub/Sub 模型中,合约本身就像是消息队列,所有调用合约并触发事件的交易(或消息)就是生产者,监听事件的 Javascript API 可以理解为消费者。

浅入浅出智能合约-深研未来

ERC20 合约

Ethereum 中最常见的合约应该就是遵循 ERC20 接口的合约了,ERC20 合约的实现现在也非常成熟,在 Ethereum 发行一个新的 Token 的成本可能不到几十块钱。

浅入浅出智能合约-深研未来

在这里,我们可以简单看一下 ERC20 协议的一个简单实现:


contract EIP20 is EIP20Interface {
    mapping (address => uint256) public balances;
    string public name;
    uint8 public decimals;
    string public symbol;

    function EIP20(uint256 _initialAmount, string, uint8 _decimalUnits, string _tokenSymbol) public {
        balances[msg.sender] = _initialAmount;
        totalSupply = _initialAmount;
        name = _tokenName;
        decimals = _decimalUnits;
        symbol = _tokenSymbol;
    }

    function transfer(address _to, uint256 _value) public returns (bool success) {
        require(balances[msg.sender] >= _value);
        balances[msg.sender] -= _value;
        balances[_to] += _value;
        Transfer(msg.sender, _to, _value);
        return true;
    }

    function balanceOf(address _owner) public view returns (uint256 balance) {
        return balances[_owner];
    }

    // ...
}

在这里省略了非常多的 ERC20 接口中规定的函数,我们就来看一下两个最简单、常用的函数 balanceOf 接受一个 address 参数就会从合约里存储余额的变量 balances 地址对应的余额,转账函数的实现也非常简单,通过 require 保证当前交易的发出者有足够的余额,然后在 balances 减去发出者的 value 增加接受者的 value,最后发出 Transfer 事件并返回。

小结

作为面向合约的编程语言 Solidity 的功能非常简单,但是其中的很多概念都是按照区块链网络的特点设计的,对于有经验的开发者来说,学习和编写 Solidity 并不会是一件特别困难和复杂的事情。

需要注意的是作为区块链上合约或者说 DApp,它一旦部署就无法像其他应用一样更新和升级,所以合约一旦出现 bug 就是非常严重的问题,在编写合约期间一定要认真考虑其中的漏洞,避免编写复杂的代码和逻辑,重要的合约一定要通过形式验证保证程序中不存在缺陷才可以部署发布。

2.部署

上一小节介绍了智能合约中的一些基本概念以及面向合约的编程语言 Solidity,本节我们将要介绍智能合约在编写之后是如何部署到 Ethereum 网络的。

浅入浅出智能合约-深研未来

部署一个新的智能合约或者说 DApp 其实总共只需要两个步骤,首先要将已经编写好的合约代码编译成二进制代码,然后将二进制数据和构造参数打包成交易发送到网络中,等待当前交易被矿工追加到区块链就可以了。

编译

合约代码的编译过程非常简单,我们使用如下的合约代码为例,简单介绍合约的编译过程:

pragma solidity ^0.4.22;

contract Contract {
    constructor() public {
    }
}

编译 Solidity 代码需要 solidity 编译器参与工作,编译器的使用也非常简单,我们可以直接使用如下的命令将上述合约编译成二进制:

$ solc --bin contract.sol

======= contract.sol:Contract =======
Binary:
6080604052348015600f57600080fd5b50603580601d6000396000f3006080604052600080fd00a165627a7a72305820d9b24bc33db482b29de2352889cc2dfeb66029c28b0daf251aad5a5c4788774a0029

如果我们使用了 Ethereum Wallet 等客户端,就可以将上述二进制数据添加到如下图所示的 DATA 中:

浅入浅出智能合约-深研未来

用于创建合约的交易不需要填写目标地址需要在 DATA 中填写合约的二进制数据和编码后的构造器二进制参数,由于这个合约的构造器并不包含任何参数,所以我们只需要添加合约的二进制数据,点击发送后会生成如下的交易 e74c796a041bad60469f2ee023c87e08

{
    "jsonrpc": "2.0",
    "id": 1,
    "result": {
        "blockHash": "0xfb508342b89066fe2efa45d7dbb9a3ae241486eee66103c03049e2228a159ee8",
        "blockNumber": "0x208c0a",
        "from": "0xe118559d65f87aaa8caa4383b112ff679a21223a",
        "gas": "0x2935a",
        "gasPrice": "0x9502f9000",
        "hash": "0xe74c796a041bad60469f2ee023c87e087847a6603b27972839d0c0de2e852315",
        "input": "0x6080604052348015600f57600080fd5b50603580601d6000396000f3006080604052600080fd00a165627a7a72305820d9b24bc33db482b29de2352889cc2dfeb66029c28b0daf251aad5a5c4788774a0029",
        "nonce": "0x2",
        "to": null,
        "transactionIndex": "0x5",
        "value": "0x0",
        "v": "0x2c",
        "r": "0xa5516d78a7d486d111f818b6b16eef19989ccf46f44981ed119f12d5578022db",
        "s": "0x7125e271468e256c1577b1d7a40d26e2841ff6f0ebcc4da073610ab8d76c19d5"
    }
}

在这个用于创建合约的特殊交易中,我们可以看到目标地址 to 的值为空,input 的值就是我们在 Ethereum Wallet 中发送交易时填写的 DATA,即合约的二进制代码。这笔交易被纳入区块链之后,我们就能在 Etherscan 上看到这笔交易成功的创建了一个合约 0xa6a158a131476d4e071f4a3a0d9af2d88769b25a

发送

从上面的测试已经可以看到合约都是由交易(Transaction)创建的,每一个创建合约的交易的 to 字段都是 null,而 input 是合约代码编译之后的二进制;经历了编译这一过程,剩下的就是交易的打包、签名和发送了。

在这里,我们可以通过阅读一个经典的 Ethereum 实现 parity 的源代码来研究交易是如何打包、签名和发送的;从源代码中,我们可以找到签名交易的入口 sign_transaction函数:

// parity/rpc/src/v1/traits/eth_signing.rs
#[rpc(meta, name = "eth_signTransaction")]
fn sign_transaction(&self, Self::Metadata, TransactionRequest) -> BoxFuture<RichRawTransaction>;

// parity/rpc/src/v1/impls/signing.rs
fn sign_transaction(&self, meta: Metadata, request: RpcTransactionRequest) -> BoxFuture<RpcRichRawTransaction> {
  let res = self.dispatch(
    RpcConfirmationPayload::SignTransaction(request),
    meta.dapp_id().into(),
    meta.origin,
  );

  Box::new(res.flatten().and_then(move |response| {
    match response {
      RpcConfirmationResponse::SignTransaction(tx) => Ok(tx),
      e => Err(errors::internal("Unexpected result.", e)),
    }
  }))
}

上述函数初始化了一个 RpcConfirmationPayload::SignTransaction 结构体并在最后调用 fill_optional_fields将请求中的参数 fromtononcegas_pricegasvaluedata 以及 condition 等数据填入到最终被签名的交易中:

// rpc/src/v1/helpers/dispatch.rs
fn fill_optional_fields(&self, request: TransactionRequest, default_sender: Address, force_nonce: bool)
                        -> BoxFuture<FilledTransactionRequest>
{
  let request = request;
  let from = request.from.unwrap_or(default_sender);
  let nonce = if force_nonce {
    request.nonce.or_else(|| Some(self.state_nonce(&from)))
  } else {
    request.nonce
  };

  Box::new(future::ok(FilledTransactionRequest {
    from,
    used_default_from: request.from.is_none(),
    to: request.to,
    nonce,
    gas_price: request.gas_price.unwrap_or_else(|| {
      default_gas_price(&*self.client, &*self.miner, self.gas_price_percentile)
    }),
    gas: request.gas.unwrap_or_else(|| self.miner.sensible_gas_limit()),
    value: request.value.unwrap_or_else(|| 0.into()),
    data: request.data.unwrap_or_else(Vec::new),
    condition: request.condition,
  }))
}

经过内部的两次 RPC 请求 SignTransactionSignMessage 最后会由 signature.rs 文件中的 sign 函数使用 secp256k1 完成对传入数据的签名:

// ethkey/src/signature.rs
pub fn sign(secret: &Secret, message: &Message) -> Result<Signature, Error> {
    let context = &SECP256K1;
    let sec = SecretKey::from_slice(context, &secret)?;
    let s = context.sign_recoverable(&SecpMessage::from_slice(&message[..])?, &sec)?;
    let (rec_id, data) = s.serialize_compact(context);
    let mut data_arr = [0; 65];

    // no need to check if s is low, it always is
    data_arr[0..64].copy_from_slice(&data[0..64]);
    data_arr[64] = rec_id.to_i32() as u8;
    Ok(Signature(data_arr))
}

eth_signTransaction 的作用其实只有两部分,一部分是将传入的参数组合成一个交易,另一部分是通过 secp256k1 对交易进行签名:

浅入浅出智能合约-深研未来

签名好的二进制交易可以通过 eth_sendRawTransaction 广播到整个 Ethereum 网络,而 JSON 格式的交易可以通过 eth_sendTransaction 发送:

// parity/rpc/src/v1/traits/eth.rs
#[rpc(name = "eth_sendRawTransaction")]
fn send_raw_transaction(&self, Bytes) -> Result<H256>;

// parity/rpc/src/v1/impls/eth.rs
fn send_raw_transaction(&self, raw: Bytes) -> Result<RpcH256> {
    Rlp::new(&raw.into_vec()).as_val()
        .map_err(errors::rlp)
        .and_then(|tx| SignedTransaction::new(tx).map_err(errors::transaction))
        .and_then(|signed_transaction| {
            FullDispatcher::dispatch_transaction(
                &*self.client,
                &*self.miner,
                signed_transaction.into(),
            )
        })
        .map(Into::into)
}

send_raw_transaction 最终会将签名好的交易加入到当前节点的交易队列等待处理,交易队列中的交易随后会广播到整个网络中并被整个网络确认并纳入新的区块中。

fn import_own_transaction<C: miner::BlockChainClient>(
    &self,
    chain: &C,
    pending: PendingTransaction,
) -> Result<(), transaction::Error> {
    let client = self.pool_client(chain);
    let imported = self.transaction_queue.import(
        client,
        vec![pool::verifier::Transaction::Local(pending)]
    ).pop().expect("one result returned per added transaction; one added => one result; qed");

    if imported.is_ok() && self.options.reseal_on_own_tx && self.sealing.lock().reseal_allowed() {
        if self.engine.seals_internally().unwrap_or(false) || !self.prepare_pending_block(chain) {
            self.update_sealing(chain);
        }
    }

    imported
}

当交易成为新区块的一部分之后,我们就能通过 Etherscan 或者其他方式查看被创建合约 0xa6a158a131476d4e071f4a3a0d9af2d88769b25a 的信息了。

小结

在 Ethereum 上部署合约的过程其实与交易发送的过程基本完全相似,唯一的区别就是用于创建合约的交易目前地址为空,并且 data 字段中的内容就是合约的二进制代码,也就是合约的部署由两部分组成:编译合约和发送消息。对于合约的部署这里差不多介绍完了,在下节文章中,我们将分析如何调用智能合约中声明的函数。

3.调用

当我们谈到 Ethereum 的智能合约时,很难不涉及 Solidity 的 ABI,这里的 ABI 就是一种与 Ethereum 生态系统中合约交互的标准方法。我们可以使用 ABI 从区块链外部调用合约(DApp)的提供的服务,也可以在合约中调用其他合约的函数。

浅入浅出智能合约-深研未来

在本节中,我们将简单介绍 Ethereum 智能合约中的应用程序二进制接口(ABI)以及如何使用 ABI 调用其他智能合约中的函数。

ABI

当我们使用 Ethereum ABI 调用智能合约中的某些方法时,我们其实只需要关注 ABI 中的两方面内容,一是函数的选择器,二是参数的编码。

浅入浅出智能合约-深研未来

前者能够帮助我们选择智能合约中的函数,后者会在解码后作为参数传到函数中;函数的选择加上参数的传递就能在启动智能合约中的分布式应用。

函数选择器

Ethereum ABI 中的函数选择器其实就是四个字节的数据,它可以通过如下的方式进行计算:

require 'digest/sha3'

Digest::SHA3.hexdigest("baz(uint32,bool)", 256)[0...8] # => "cdcd77c0"

其实就是函数签名的 SHA-3 哈希的前四个字节,也可以理解为最左侧的四个字节,函数的签名包含函数名以及所有参数类型用 , 连接后的字符串,从这里我们可以看到,ABI 中函数选择器的设计是非常简单的。

参数编码

函数选择器由于其本身的特点,只要让其本身遵循一定的约定并不会复杂太多,但是语言中的类型系统相比于函数选择器就是复杂的多。Solidity 作为一门图灵完备的编程语言,它包含两种数据类型,一些是占用固定大小的静态类型,另一些是占用动态大小的动态类型。

在这里我们简单介绍两几种动态类型的编码方式,数组、字节和字符串;由于变长数组的中元素个数的不确定,所以我们在编码时需要保留其长度信息:

encode(arr) = encode(len(arr) encode(arr[0],arr[1],...arr[len(arr)-1])

数组中的其他内容将按照元组的方式进行编码,字节和字符串与数组一样,由于长度的不固定,都需要保留长度信息:

encode(bytes) = encode(len(bytes)) pad_right(bytes)
encode(string) = encode(encode_utf8(string))

字节在编码时需要将内容补充到长度为 32 的倍数,而字符串其实就会按照 UTF8 编码后的字节进行编码;静态类型的编码其实没有太多好介绍的,因为它们的长度固定,所以编码的方式也非常简单,需要注意的是所有的参数在编码后的长度都是 32 的倍数。

想要了解更多与函数选择器与类型编码有关的内容,可以直接阅读官方的 ABI 文档。

调用合约

我们在部署那一节介绍了如何在 Ethereum 上部署合约以及它的实现原理,其实无论是部署合约还是调用合约都是 Ethereum 网络上的一笔交易

{
    "jsonrpc": "2.0",
    "id": 1,
    "result": {
        "blockHash": "0xfb508342b89066fe2efa45d7dbb9a3ae241486eee66103c03049e2228a159ee8",
        "blockNumber": "0x208c0a",
        "from": "0xe118559d65f87aaa8caa4383b112ff679a21223a",
        "gas": "0x2935a",
        "gasPrice": "0x9502f9000",
        "hash": "0xe74c796a041bad60469f2ee023c87e087847a6603b27972839d0c0de2e852315",
        "input": "0x6080604052348015600f57600080fd5b50603580601d6000396000f3006080604052600080fd00a165627a7a72305820d9b24bc33db482b29de2352889cc2dfeb66029c28b0daf251aad5a5c4788774a0029",
        "nonce": "0x2",
        "to": null,
        "transactionIndex": "0x5",
        "value": "0x0",
        "v": "0x2c",
        "r": "0xa5516d78a7d486d111f818b6b16eef19989ccf46f44981ed119f12d5578022db",
        "s": "0x7125e271468e256c1577b1d7a40d26e2841ff6f0ebcc4da073610ab8d76c19d5"
    }
}

如果我们想从自己的地址发送一笔交易,能够主动改变的值并不多,gasPricetoinput 是三个我们能够控制的字段。

其中 gasPrice 一般都是一个合理的数值,它仅仅用来表示当前交易愿意承担的『手续费』,无法携带太多的信息,to 也没有太多可以操作的空间,当它置空时就表示这是一笔用于创建合约的交易,当它为非空时,往往表示交易的接收方或者智能合约的地址。

最后的 input 是能够搞事情的地方了,从理论上来讲我们可以在这里存储任何数据,也就意味着拥有了无限可能;当我们调用合约时,我们通过 input 传递函数选择器和编码后的参数,这样节点在解码选择器和参数之后就可以执行合约中的某些函数了。

pragma solidity ^0.4.18;

contract Contract {
    uint number;

    function Contract() public { }

    function add(uint x) public returns (uint) {
        number += x;
        return number;
    }
}

当我们将上述合约部署到 Ethereum 网络上之后,就能找到如下的合约 ae9691592751a80f,在这时我们创建一个新的交易调用当前合约并改变其状态。

我们可以看到当前交易 1f7fda75602f7b71input 中包含了当前合约调用的函数选择器和编码后的参数。

{
    "jsonrpc": "2.0",
    "id": 1,
    "result": {
        "blockHash": "0x6cd8ec67ecd8d9cf2cb488521439d92703036f20402a812634c5f6946f0d1ca1",
        "blockNumber": "0x226335",
        "from": "0xe118559d65f87aaa8caa4383b112ff679a21223a",
        "gas": "0x22a2a",
        "gasPrice": "0xee6b2800",
        "hash": "0x1f7fda75602f7b71fe4fd79cb119d009aad2b89616fb98a93bd241f43f9165cd",
        "input": "0x1003e2d2000000000000000000000000000000000000000000000000000000000000000a",
        "nonce": "0x7",
        "to": "0xae9691592751a80feccaa87a8898ea39dfb2cd4f",
        "transactionIndex": "0x3",
        "value": "0x0",
        "v": "0x2b",
        "r": "0x57096309a868e74e06ce62e99667c228dd677ba0ced74f4eb2b3c488a309e420",
        "s": "0x2ee943a546d9dac3835a0c7bc99e5e25aa71e8a9cb5b7dba64941318d99f950"
    }
}

input 中的数据可以被分割成以下的几个部分,函数选择器和十六进制编码的 10

// 0x1003e2d2000000000000000000000000000000000000000000000000000000000000000a

Function: add(uint256 x) ***

MethodID: 0x1003e2d2
- [0]:  000000000000000000000000000000000000000000000000000000000000000a

你可以在 Ethereum 的测试网络 Rinkbey 中找到这笔用于调用合约函数的交易 1f7fda75602f7b71

小结

无论是部署合约还是调用合约中函数,我们都需要构建一笔交易并发送到整个网络中,合约的调用使用了 input字段传递函数选择器以及编码后的参数,但是除此之外,我们也可以使用 input 做任何意想不到的事情,因为对于 Ethereum 来说 input 可能是无意义的,但是对于链外的应用来讲却可以存储有意义的数据。

 

Source by:  Draveness