Ethereum Smart Contract Development

Table of Contents

1. 智能合约开发

开发 Ethereum 智能合约 的基本流程如下:

  1. 启动一个以太坊节点 (例如 geth 或者 testrpc),或者直接使用第三方(如 Infura)提供的服务。
  2. 编写智能合约,以太坊的智能合约一般使用 Solidity 语言(语法类似 JavaScript 的高级语言)来编写。
  3. 使用 solc 编译智能合约,可以获得部署合约时所要使用的 Ethereum Virtual Machine (EVM) bytecode 和 abi。
  4. 将编译好的合约部署到测试网络。
  5. 调用和测试合约。

后面将分别介绍使用 web3.js、Truffle、OpenZeppelin、Golang 等进行 Ethereum 智能合约开发的基本过程。

1.1. Ganache 测试链

在开发合约时,我们可以部署代码到以太坊的测试网上。我们还有更好的办法,直接在本地搭建完整的测试以太链。Ganache 就是这样一个项目,使用它可以非常地容易搭建一个测试链。

第一步,安装 ganache 命令行工具:

$ npm install --save-dev ganache-cli

第二步,启动 ganache 测试链:

$ npx ganache-cli --deterministic
Ganache CLI v6.9.1 (ganache-core: 2.10.2)

Available Accounts
==================
(0) 0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1 (100 ETH)
(1) 0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0 (100 ETH)
(2) 0x22d491Bde2303f2f43325b2108D26f1eAbA1e32b (100 ETH)
(3) 0xE11BA2b4D45Eaed5996Cd0823791E0C93114882d (100 ETH)
(4) 0xd03ea8624C8C5987235048901fB614fDcA89b117 (100 ETH)
(5) 0x95cED938F7991cd0dFcb48F0a06a40FA1aF46EBC (100 ETH)
(6) 0x3E5e9111Ae8eB78Fe1CC3bb8915d5D461F3Ef9A9 (100 ETH)
(7) 0x28a8746e75304c0780E011BEd21C72cD78cd535E (100 ETH)
(8) 0xACa94ef8bD5ffEE41947b4585a84BdA5a3d3DA6E (100 ETH)
(9) 0x1dF62f291b2E969fB0849d99D9Ce41e2F137006e (100 ETH)

Private Keys
==================
(0) 0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d
(1) 0x6cbed15c793ce57650b9877cf6fa156fbef513c4e6134f022a85b1ffdd59b2a1
(2) 0x6370fd033278c143179d81c5526140625662b8daa446c22ee2d73db3707e620c
(3) 0x646f1ce2fdad0e6deeeb5c7e8e5543bdde65e86029e2fd9fc169899c440a7913
(4) 0xadd53f9a7e588d003326d1cbf9e4a43c061aadd9bc938c843a79e7b4fd2ad743
(5) 0x395df67f0c2d2d9fe1ad08d1bc8b6627011959b79c53d7dd6a3536a33ab8a4fd
(6) 0xe485d098507f54e7733a205420dfddbe58db035fa577fc294ebd14db90767a52
(7) 0xa453611d9419d0e56f499079478fd72c37b251a94bfde4d19872c44cf65386e3
(8) 0x829e924fdf021ba3dbbc4225edfece9aca04b929d6e75613329ca6f1d31c0bb4
(9) 0xb0057716d5917badaf911b193b12b910811c1497b5bada8d7711f758981c3773

HD Wallet
==================
Mnemonic:      myth like bonus scare over problem client lizard pioneer submit female collect
Base HD Path:  m/44'/60'/0'/0/{account_index}

Gas Price
==================
20000000000

Gas Limit
==================
6721975

Call Gas Limit
==================
9007199254740991

Listening on 127.0.0.1:8545

启动后,服务默认监听在“127.0.0.1:8545”(通过选项 -h-p 可以修改监听了地址和端口)。默认创建了十个帐户,且每个帐户里有 100 个以太。

2. web3.js

web3.js 是一个 Javascript 库,使用它可以和以太节点进行交互。

参考:https://web3js.readthedocs.io/en/latest/

2.1. 编写合约

下面是以太坊智能合约的一个例子(假设文件名为 test.sol):

pragma solidity >=0.4.0 <0.6.0;

contract SimpleStorage {
    uint storedData;

    function set(uint x) public {
        storedData = x;
    }

    function get() public view returns (uint) {
        return storedData;
    }
}

参考:Solidity in Depth

2.2. 编译合约

使用 web3 来部署智能合约时,需要先获得合约的 EVM bytecode 以及合约的 abi。可以直接在网络 https://ethereum.github.io/browser-solidity/ 上进行 online 编译;也可以安装 solc 后自己编译,安装 solc 的步骤:

$ npm install -g solc                 # 成功安装后,会得到可执行程序solcjs

下面是使用 solcjs 编译上节例子中的 test.sol,得到其 EVM bytecode 以及 abi 的例子:

$ solcjs --bin test.sol          #### 获得EVM bytecode,保存在文件test_sol_SimpleStorage.bin中
$ cat test_sol_SimpleStorage.bin
608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a72305820fc40facace75159ce9071fb0b9c2ef08afa7650019cf3276f911c3a1cc29eb390029

$ solcjs --abi test.sol          #### 获的abi,保存在文件test_sol_SimpleStorage.abi中
$ cat test_sol_SimpleStorage.abi
[{"constant":false,"inputs":[{"name":"x","type":"uint256"}],"name":"set","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"get","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}]

2.3. 部署合约

在 web3.js 1.0 前,调用合约的 new()方法就可以部署合约;在 web3.js 1.0 中,部署合约分为两步:
1、创建合约(新建一个 web3.eth.Contract 类的实例);
2、发布合约。

在发布合约时要调用 myContract.deploy().send()。对于 deploy() 而言,如果你的合约的构造函数是带参数的,则调用 deploy() 函数也要把参数带上。调用 send() 的时候需要指定从哪个地址扣费(from 选项),以及燃料多少(gas 选项)。 send() 函数比较有意思的是,它返回的不光是一个 Promise,还是一个 EventEmiter,因而可以监听发布过程中的一些事件:

  • transactionHash - 生成了交易 hash 的事件,一般只会触发一次;
  • receipt - 合约已经打包到区块上了,一般只触发一次,这时就已经可以调用这个合约了;
  • confirmation - 合约已经打包到区块链上并收到了确认,每次确认都会触发一次,最多到第 24 次确认;
  • error - 发布合约失败。

下面是部署前面合约的例子:

var Web3 = require("web3");
var web3 = new Web3();

// 连接到以太坊节点
web3.setProvider(new Web3.providers.HttpProvider("http://localhost:8545"));
console.log('web3 version=' + web3.version);

// 下面contractAbi是通过solcjs编译得到的
contractAbi = [{"constant":false,"inputs":[{"name":"x","type":"uint256"}],"name":"set","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"get","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}]

// 下面contractBytecode是通过solcjs编译得到的
contractBytecode = '0x' + '608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b114604e5780636d4ce63c146078575b600080fd5b348015605957600080fd5b5060766004803603810190808035906020019092919050505060a0565b005b348015608357600080fd5b50608a60aa565b6040518082815260200191505060405180910390f35b8060008190555050565b600080549050905600a165627a7a72305820fc40facace75159ce9071fb0b9c2ef08afa7650019cf3276f911c3a1cc29eb390029'

// 创建合约
const myContract = new web3.eth.Contract(contractAbi, {data: contractBytecode})

// 部署合约的账号(部署合约时需要消耗gas,会从这个账号中扣除)
const deployAddr = '0xe4F394093E0Dc30d28A216dAEce79A6D0D537042'
// 上面账户的密码(这个密码是生成或导入帐号时指定的)
const password = '12345678'

// 解锁账号
try {
    web3.eth.personal.unlockAccount(deployAddr, password, 600);
} catch(e) {
    console.log(e);
    return;
}

// 发布合约
myContract.deploy()
    .send({from: deployAddr,      // 指定部署合约的帐号
           gasPrice: 6000000000,  // 指定gas price
           gas: 112149            // 指定gas值
          })    // send() returns a Promise & EventEmit
    .on('transactionHash', function(transactionHash){
        console.log("deploy transaction hash: ", transactionHash)
    })
    .on('receipt', function(receipt){
        console.log("deploy receipt: ", receipt)
    })
    .on('confirmation', function(confirmationNum, receipt){
        console.log("got confirmations number: ", confirmationNum)
    })
    .then(function(myContractInstance){
        console.log("deployed successfully.")
        console.log("myContractInstance.options.address = ", myContractInstance.options.address)
    })
    .catch(err => {
        console.log("Error: failed to deploy, detail:", err)
    })

运行部署合约的程序,可能得到下面输出:

web3 version=1.0.0-beta.36
deploy transaction hash:  0xad3b569955ce8ca4be421adb2c5cba366791d2ba9944f4e0966fd54f708f4ebe
got confirmations number:  0
deploy receipt:  { blockHash: '0x95875d8749353dd22ddd51ee005e23a8bce75503272615c286c520547b2dc852',
  blockNumber: 4241261,
  contractAddress: '0x92021f848472af2fD85c0b8Ef6Ea89CeDc6294B0',
  cumulativeGasUsed: 225499,
  from: '0xe4f394093e0dc30d28a216daece79a6d0d537042',
  gasUsed: 112149,
  logsBloom: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
  status: true,
  to: null,
  transactionHash: '0xad3b569955ce8ca4be421adb2c5cba366791d2ba9944f4e0966fd54f708f4ebe',
  transactionIndex: 4,
  events: {} }
deployed successfully.
myContactInstance.options.address =  0x92021f848472af2fD85c0b8Ef6Ea89CeDc6294B0
got confirmations number:  1
got confirmations number:  2
got confirmations number:  3
got confirmations number:  4
got confirmations number:  5
got confirmations number:  6
got confirmations number:  7
got confirmations number:  8
got confirmations number:  9
got confirmations number:  10
got confirmations number:  11
got confirmations number:  12
got confirmations number:  13
got confirmations number:  14
got confirmations number:  15
got confirmations number:  16
got confirmations number:  17
got confirmations number:  18
got confirmations number:  19
got confirmations number:  20
got confirmations number:  21
got confirmations number:  22
got confirmations number:  23
got confirmations number:  24

在上面输出中,我们可以看到新合约的地址为: 0x92021f848472af2fD85c0b8Ef6Ea89CeDc6294B0 ,后面会用到这个地址。

注意:前面代码中,设置了 gasPrice 为 6000000000,gas 为 112149;但这两个值并不是固定的,可以通过下面代码获得:

web3.eth.getGasPrice().
    then((averageGasPrice) => {
        console.log("Average gas price: " + averageGasPrice);   // 输出实例:6000000000
    }).
    catch(function(err) {
        console.log(err);
    });

myContract.deploy().estimateGas().
    then((estimatedGas) => {
        console.log("Estimated gas: " + estimatedGas);          // 输出实例:112149
    }).
    catch(function(err) {
        console.log(err);
    });

2.4. 调用合约

要调用合约可以使用 call()send()

myContract.methods.myMethod([param1[, param2[, ...]]]).call(options[, callback])
myContract.methods.myMethod([param1[, param2[, ...]]]).send(options[, callback])

其中 call() 适用于 constant 类型的方法(如前面例子中的 get 方法),而后者 send() 适用于其他方法(有交易或会修改 storage 的方法,如前面例子中的 set 方法)。

call() 的返回值(Promise 解决后)就是合约中方法的返回值,然而 send() 的返回值(Promise 解决后)是这次交易的信息,包括 transactionHash 和 blockHash 等,即使合约中方法有返回值也将被忽略。所以,非 constant 类型的方法建议不要有返回值,出错了就直接抛异常挂掉;如果真的想返回什么,建议通过事件的方式来通知调用者。

2.4.1. send(适用于非 constant 类型的方法)

下面是调用前面合约中 set 方法(它不是 constant 类型的方法,需要使用 send() 来调用)的实例:

var Web3 = require("web3");
var web3 = new Web3();

// 连接到以太坊节点
web3.setProvider(new Web3.providers.HttpProvider("http://localhost:8545"));

contractAbi = [{"constant":false,"inputs":[{"name":"x","type":"uint256"}],"name":"set","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"get","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}]

// 合约地址
const contractAddress = '0x92021f848472af2fD85c0b8Ef6Ea89CeDc6294B0'

// 创建合约(使用已经存在的合约,需要指定abi和合约地址)
const myContract = new web3.eth.Contract(contractAbi, contractAddress)

// 调用合约的账号(调用合约时需要消耗gas,会从这个账号中扣除)
const userAddr = '0xe4F394093E0Dc30d28A216dAEce79A6D0D537042'
const password = '12345678'

// 解锁账号
try {
    web3.eth.personal.unlockAccount(userAddr, password, 600);
} catch(e) {
    console.log(e);
    return;
}

// 调用合约myContract的set方法
myContract.methods.set(20)
    .send({from: userAddr,
           gasPrice: 6000000000,  // 指定gas price
           gas: 112149            // 指定gas值
          })    // send() returns a Promise & EventEmit
    .on('transactionHash', function(transactionHash){
        console.log("transaction hash: ", transactionHash)
    })
    .on('receipt', function(receipt){
        console.log("receipt: ", receipt)
    })
    .on('confirmation', function(confirmationNum, receipt){
        console.log("got confirmations number: ", confirmationNum)
    })
    .then(function(myContractInstance) {
        console.log(myContractInstance)
    })
    .catch(err => {
        console.log("Error: failed to call set method, detail:", err)
    })

2.4.2. call(适用于 constant 类型的方法)

下面是调用前面合约中 get 方法(它是 constant 类型的方法,可以使用 call() 来调用)的实例:

var Web3 = require("web3");
var web3 = new Web3();

// 连接到以太坊节点
web3.setProvider(new Web3.providers.HttpProvider("http://localhost:8545"));

contractAbi = [{"constant":false,"inputs":[{"name":"x","type":"uint256"}],"name":"set","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"get","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"}]

// 合约地址
const contractAddress = '0x92021f848472af2fD85c0b8Ef6Ea89CeDc6294B0'

// 创建合约(使用已经存在的合约,需要指定abi和合约地址)
const myContract = new web3.eth.Contract(contractAbi, contractAddress)

// 调用合约myContract的get方法
myContract.methods.get()
    .call()
    .then(console.log)     // 输出get方法的返回值,如20
    .catch(err => {
        console.log("Error: failed to call method get, detail:", err)
    })

3. Truffle

Truffle 是以太坊合约开发环境,帮助用户快速开发、部署、测试合约。

安装 Truffle:

$ npm install -g truffle

3.1. 初始化工程

执行 truffle init 可以初始化 truffle 工程:

$ truffle init

命令成功执行完后,会生成下面目录:

contracts/: Directory for Solidity contracts
migrations/: Directory for scriptable deployment files
test/: Directory for test files for testing your application and contracts
truffle-config.js: Truffle configuration file

3.2. 编写合约

按照 truffle 工程的规范,合约文件需要保存在 contracts 子目录中。

假设有合约 contracts/Counter.sol,代码如下:

pragma solidity >=0.4.21 <0.7.0;

contract Counter {
  uint256 public value;

  function increase(uint256 amount) public {
    value += amount;
  }
}

3.3. 编译合约

执行 truffle compile 可以编译合约。默认是增量编译,增加选项 --all 可以进行全新编译,如:

$ truffle compile --all

Compiling your contracts...
===========================
> Compiling ./contracts/Counter.sol
> Compiling ./contracts/Migrations.sol
> Artifacts written to /Users/cig01/proj/dapp/build/contracts
> Compiled successfully using:
   - solc: 0.5.16+commit.9c3226ce.Emscripten.clang

3.4. 部署合约

在部署合约前,首先需要有个以太测试节点。可以使用 Ganache(参考节 1.1)在本地搭建,或者使用 Infura 的服务连接到 Ropsten 等测试网。

执行 Truffle 的 migrate (或者它的别名 deploy )子命令可以依次执行子目录 migrations 中的脚本:

$ truffle migrate                    # 连接 127.0.0.1:7545,请检查 truffle-config.js 中的配置
$ truffle deploy                     # 同上。注 deploy 是 migrate 的别名
$ truffle deploy --network ropsten   # 需要在 truffle-config.js 中配置网络

下面是在 truffle-config.js 中增加 ropsten 网络配置的例子:

const HDWalletProvider = require('@truffle/hdwallet-provider');

module.exports = {

  networks: {
    ......
    ropsten: {
      provider: () => new HDWalletProvider(mnemonic or privateKey, `https://ropsten.infura.io/v3/YOUR-PROJECT-ID`),
      network_id: 3,       // Ropsten's id
      gas: 5500000,        // Ropsten has a lower block limit than mainnet
      confirmations: 2,    // # of confs to wait between deployments. (default: 0)
      timeoutBlocks: 200,  // # of blocks before a deployment times out  (minimum/default: 50)
      skipDryRun: true     // Skip dry run before migrations? (default: false for public nets )
    }
  },
  ......
}

3.4.1. 为新合约编写部署脚本

truffle deploy 只是执行子目录 migrations 中的脚本来完成部署。所以对于新增加的合约(如 Counter.sol),需要编写相应的脚本。

在初始化工程时,会自动生成 contracts/Migrations.sol 和 migrations/1_initial_migration.js。

const Migrations = artifacts.require("Migrations");

module.exports = function(deployer) {
  deployer.deploy(Migrations);
};

以 migrations/1_initial_migration.js 为模板,我们创建文件 migrations/2_initial_counter.js,内容如下:

const Counter = artifacts.require("Counter");

module.exports = async function(deployer, network, accounts) {
  const deployAccount = accounts[0];
  console.log("Deploying from account:" + deployAccount, 'network:', network);

  await deployer.deploy(Counter);
  console.log('The contract address:', Counter.address);
};

这样,在执行 truffle deploy 时,就可以完成对新合约 contracts/Counter.sol 的部署了。

参考:https://www.trufflesuite.com/docs/truffle/getting-started/running-migrations

3.4.2. 指定执行某个部署脚本

目录 migrations 中的部署脚本都以数字为前缀命名的,通过指定 --f <num>--to <num> 可以分别指定开始和结束脚本的数字前缀。如,仅执行以 3 为前缀的脚本:

$ truffle deploy --f 3 --to 3 --network ropsten

3.5. 调用合约

使用 truffle consoletruffle develop 都可以启动一个交互式控制台,它们的区别在于 truffle console 是连接一个已存在的网络,而 truffle develop 会自己启动一个测试网络(即它可以代替 Ganache 等)。

打开交互式控制台:

$ truffle console
$ truffle console --network ropsten   # 需要在 truffle-config.js 中配置网络

在控制台中得到 Counter 合约的实例:

$ truffle(ganache)> const instance = await Counter.deployed()

在控制台中调用 Counter 合约的 increase() 方法,如:

$ truffle(ganache)> instance.increase(2)
{ tx:
   '0xb21f4745b6daaef1af7d4f2e9df57f5863127a025958bce0d0fd028e114de202',
  receipt:
   { transactionHash:
      '0xb21f4745b6daaef1af7d4f2e9df57f5863127a025958bce0d0fd028e114de202',
     transactionIndex: 0,
     blockHash:
      '0x4a055728a3060db29ad0712555d010a6b3be825b2ab4563f475343b977e83ef8',
     blockNumber: 5,
     from: '0x90f8bf6a479f320ead074411a4b0e7944ea8c9c1',
     to: '0xcfeb869f69431e42cdb54a4f4f105c19c080a601',
     gasUsed: 42243,
     cumulativeGasUsed: 42243,
     contractAddress: null,
     logs: [],
     status: true,
     logsBloom:
      '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
     rawLogs: [] },
  logs: [] }

在控制台中调用 Counter 合约的 value() 方法,如:

$ truffle(ganache)> instance.value()
<BN: 2>

参考:https://www.trufflesuite.com/docs/truffle/getting-started/using-truffle-develop-and-the-console

3.6. 调试合约(truffle debug)

执行 truffle debug 可以调试一个已经执行过的 tx,如:

$ truffle debug 0xb21f4745b6daaef1af7d4f2e9df57f5863127a025958bce0d0fd028e114de202
Starting Truffle Debugger...
✓ Gathering information about your project and the transaction...

Addresses called: (not created)
 0xCfEB869F69431e42cdB54A4F4f105C19C080A601 - Counter

Commands:
(enter) last command entered (step next)
(o) step over, (i) step into, (u) step out, (n) step next
(;) step instruction (include number to step multiple)
(p) print instruction & state (can specify locations, e.g. p mem; see docs)
(l) print additional source context, (s) print stacktrace, (h) print this help
(q) quit, (r) reset, (t) load new transaction, (T) unload transaction
(b) add breakpoint, (B) remove breakpoint, (c) continue until breakpoint
(+) add watch expression (`+:<expr>`), (-) remove watch expression (-:<expr>)
(?) list existing watch expressions and breakpoints
(v) print variables and values, (:) evaluate expression - see `v`


Counter.sol:

1: pragma solidity >=0.4.21 <0.7.0;
2:
3: contract Counter {
   ^^^^^^^^^^^^^^^^^^

在上面输出中显示了调试器支持的命令。常用的 debug 命令如表 1 所示。

Table 1: Truffle Debug Command
Command 含义
n 执行下一条语句
v 查看变量和对应的值
r 重新执行 tx
h 查询帮助
q 退出调试器

下面演示了基本的调试过程:

debug(development:0xb21f4745...)> n                 # n 执行下一条语句

Counter.sol:

4:   uint256 public value;
5:
6:   function increase(uint256 amount) public {
     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^     # ^ 表示正在执行的语句

.......                                             # 这里省略好几个 n 命令
debug(development:0xb21f4745...)> v                 # v 查看变量和对应的值

  amount: 2
   value: 2
     msg: { data:
             hex'30f3f0db0000000000000000000000000000000000000000000000000000000000000002',
            sig: 0x30f3f0db,
            sender:
             0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1,
            value: 0 }
      tx: { origin:
             0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1,
            gasprice: 20000000000 }
   block: { coinbase:
             0x0000000000000000000000000000000000000000,
            difficulty: 0,
            gaslimit: 6721975,
            number: 5,
            timestamp: 1595384174 }
    this: 0xCfEB869F69431e42cdB54A4F4f105C19C080A601 (Counter)
     now: 1595384174

参考:https://www.trufflesuite.com/docs/truffle/getting-started/debugging-your-contracts

4. OpenZeppelin

OpenZeppelin 提供了一些安全的智能合约库(如 Safemath )及命令行工具,帮助开发者更快速地开发合约。

参考:https://docs.openzeppelin.com/openzeppelin/

4.1. 安装命令行工具

OpenZeppelin 提供命令行工具帮助开发者快速地编译部署合约。

安装 OpenZeppelin 命令行工具:

$ npm install @openzeppelin/cli

参考:https://docs.openzeppelin.com/cli/2.7/getting-started

4.2. 初始化工程

执行 npx oz init 可以初始化工程:

$ npx openzeppelin init          # 或者简写为 npx oz init

交互式地输入完工程名称和版本后,会创建 .openzeppelin 目录,以及 networks.js 文件。

networks.js 保存着网络的配置,其默认内容如下:

module.exports = {
  networks: {
    development: {
      protocol: 'http',
      host: 'localhost',
      port: 8545,
      gas: 5000000,
      gasPrice: 5e9,
      networkId: '*',
    },
  },
};

上面定义了一个名为 development 的网络,默认配置(“localhost:8545”)适用于 ganache 命令行启动的测试链。

4.2.1. 连接公开测试网

除了使用 ganache 构造的本地测试链外,我们也可以使用公开的以太测试链。把测试链相关信息增加到 networks.js 文件中即可,如增加 rinkeby 和 kovan 测试链:

const { projectId, mnemonic } = require('./secrets.json');
const HDWalletProvider = require('@truffle/hdwallet-provider');

module.exports = {
  networks: {
    development: {
      protocol: 'http',
      host: 'localhost',
      port: 8545,
      gas: 5000000,
      gasPrice: 5e9,
      networkId: '*',
    },
    rinkeby: {
      provider: () => new HDWalletProvider(
        mnemonic, `https://rinkeby.infura.io/v3/${projectId}`
      ),
      networkId: 4, // for rinkeby
      gasPrice: 10e9
    },
    kovan: {
      provider: () => new HDWalletProvider(
        mnemonic, `https://kovan.infura.io/v3/${projectId}`
      ),
      networkId: 42, // for kovan
      gasPrice: 10e9
    },
  },
};

其中,文件 secrets.json 保存着帐户助记词和 Infura 的 projectId,其例子如下:

{
  "mnemonic": "mistake pelican mention moment ...",
  "projectId": "305c13705054a8d918ad77549e402c72"
}

参考:https://docs.openzeppelin.com/learn/connecting-to-public-test-networks

4.3. 编写合约

项目中创建文件 contracts/Counter.sol,内容如下:

// contracts/Counter.sol
pragma solidity ^0.6.0;

contract Counter {
    uint256 public value;

    function increase() public {
        value++;
    }
}

4.4. 编译合约

执行 npx oz compile 可以编译合约,如:

$ npx oz compile                 # 编译合约
✓ Compiled contracts with solc 0.6.3 (commit.8dda9521)

4.5. 部署合约

执行 npx oz create 可以部署合约,执行时会询问你部署哪个合约,以及部署到哪个网络(networks.js 中配置的网络都可以选择)中,如:

$ npx oz create                  # 部署合约
Nothing to compile, all contracts are up to date.
? Pick a contract to instantiate Counter
? Pick a network development
✓ Added contract Counter
✓ Contract Counter deployed
All implementations have been deployed
? Call a function to initialize the instance after creating it? No
✓ Setting everything up to create contract instances
✓ Instance created at 0xCfEB869F69431e42cdB54A4F4f105C19C080A601
0xCfEB869F69431e42cdB54A4F4f105C19C080A601

注:OpenZeppelin 支持“升级”合约,上面部署过程中包含了三个合约,一个是用户的业务合约(也就是 Counter.sol),其它两个则是 OpenZeppelin 的辅助合约。部署成功后显示的合约地址是 OpenZeppelin 辅助合约的地址(这个地址在业务合约升级后也是不变的)。

4.6. 调用合约

执行 npx oz send-tx 可以发送交易,调用合约中的函数,下面例子将调用合约 Counter 中的函数 increase()

$ npx oz send-tx
? Pick a network development
? Pick an instance Counter at 0xCfEB869F69431e42cdB54A4F4f105C19C080A601
? Select which function increase()
✓ Transaction successful. Transaction hash: 0xd4defba8d14018c00a484473552f3b9f5ba290c1c2d57759f6c99527816f3f57

执行 npx oz call 可以调用合约中的查询函数,下面例子将调用合约 Counter 中的函数 value()

$ npx oz call
? Pick a network development
? Pick an instance Counter at 0xCfEB869F69431e42cdB54A4F4f105C19C080A601
? Select which function value()
✓ Method 'value()' returned: 1
1

4.7. 修改合约

下面我们把合约的 increase() 函数“增强”一下,让其可以接收一个参数,如下:

// contracts/Counter.sol
pragma solidity ^0.6.0;

contract Counter {
  uint256 public value;

  function increase(uint256 amount) public {
    value += amount;
  }
}

4.8. 升级合约

执行 npx oz upgrade 可以升级合约,如:

$ npx oz upgrade
? Pick a network: development
✓ Compiled contracts with solc 0.6.3 (commit.8dda9521)
✓ Contract Counter deployed
? Which proxies would you like to upgrade?: All proxies
Instance upgraded at 0xCfEB869F69431e42cdB54A4F4f105C19C080A601.

下面调用 increase(10)value() 以验证其升级成功:

$ npx oz send-tx
? Pick a network: development
? Pick an instance: Counter at 0xCfEB869F69431e42cdB54A4F4f105C19C080A601
? Select which function: increase(amount: uint256)
? amount (uint256): 10
Transaction successful: 0x9c84faf32a87a33f517b424518712f1dc5ba0bdac4eae3a67ca80a393c555ece

$ npx oz call
? Pick a network: development
? Pick an instance: Counter at 0xCfEB869F69431e42cdB54A4F4f105C19C080A601
? Select which function: value()
Returned "11"

5. Golang

这里简单介绍下用 Go 进行以太坊开发的基本过程。

参考:Ethereum Development with Go

5.1. 编写合约

项目中创建文件 contracts/Counter.sol,内容如下:

pragma solidity >=0.4.21 <0.7.0;

contract Counter {
  uint256 public value;

  function increase(uint256 amount) public {
    value += amount;
  }
}

5.2. 编译合约

提前准备两个工具:

$ npm install -g solc                    # 安装 solidity 编译器 solcjs
$ go install 'github.com/ethereum/go-ethereum/cmd/abigen@latest'  # 安装 abigen

第一步,使用 solidity 编译器 solc(solcjs)编译合约,同时得到对应的 abi 文件:

$ cd contracts
$ solcjs --abi --bin --optimize Counter.sol
$ ls
Counter.sol  Counter_sol_Counter.abi  Counter_sol_Counter.bin

第二步,利用 abigen,根据上一步生成的 Counter_sol_Counter.abi 和 Counter_sol_Counter.bin 生成 golang 辅助文件 counter.go:

$ abigen --abi=Counter_sol_Counter.abi --bin=Counter_sol_Counter.bin --pkg=contracts --type=Counter --out=counter.go

文件 counter.go 中包含了开发所要用到的方法,后面介绍的部署合约、加载合约、调用合约都会用到 counter.go 中生成的方法。

5.3. 部署合约

生成的 Go 合约文件 counter.go 中提供了部署方法。 部署方法名称始终以单词 Deploy 开头,后跟合约名称,在这个例子中部署合约的方法为 DeployCounter。

下面是部署 Counter 合约的关键代码:

client, err := ethclient.Dial("https://rinkeby.infura.io/v3/your-project-id")
auth := bind.NewKeyedTransactor(privateKey)

address, tx, instance, err := contracts.DeployCounter(auth, client)

5.4. 加载合约

当我们要调用合约方法前,先要得到合约实例,这个过程称为加载合约。

生成的 Go 合约文件 counter.go 中提供了加载合约的方法。 总是以单词 New 开头,后跟合约名称,这个例子中为 NewCounter。

加载合约时,需要提供合约的部署地址,这样调用合约时才知道调用的是哪个地址下的合约。

下面是加载 Counter 合约的关键代码,假设合约的部署地址为 0x147B8eb97fD247D06C4006D269c90C1908Fb5D54:

client, err := ethclient.Dial("https://rinkeby.infura.io/v3/your-project-id")
address := common.HexToAddress("0x147B8eb97fD247D06C4006D269c90C1908Fb5D54")

instance, err := contracts.NewCounter(address, client)

5.5. 调用合约

加载合约后,有了合约实例 instance,直接调用 instance 上的方法即可调用合约中的方法。

下面是调用 Counter 合约方法的关键代码:

// 调用合约中的 Value 方法(这个方法不会改变合约的状态)
value, err := instance.Value(nil)

// 调用合约中的 Increase 方法(这个方法变改变合约的状态)
auth := bind.NewKeyedTransactor(privateKey)
tx, err := instance.Increase(auth, big.NewInt(10))

5.6. 完整代码

整个项目结构如下:

example
├── contracts
│   ├── Counter.sol
│   ├── Counter_sol_Counter.abi
│   ├── Counter_sol_Counter.bin
│   └── counter.go
├── go.mod
├── go.sum
└── main.go

文件 go.mod 为:

$ cat go.mod
module example

go 1.13

require github.com/ethereum/go-ethereum v1.9.6

前面介绍的部署、加载、调用合约的完整 golang 代码(main.go)如下:

package main

import (
	"context"
	"crypto/ecdsa"
	"fmt"
	"github.com/ethereum/go-ethereum"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/core/types"
	"log"
	"math/big"
	"time"

	"github.com/ethereum/go-ethereum/accounts/abi/bind"
	"github.com/ethereum/go-ethereum/crypto"
	"github.com/ethereum/go-ethereum/ethclient"

	"example/contracts" // example 是当前 go module 的名称,导入 example 的 contracts 子目录
)

func checkErr(err error) {
	if err != nil {
		panic(err)
	}
}

func deployCounter(client *ethclient.Client, privateKey *ecdsa.PrivateKey) string {
	publicKey := privateKey.Public()
	publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey)
	if !ok {
		log.Fatal("cannot assert type: publicKey is not of type *ecdsa.PublicKey")
	}

	fromAddress := crypto.PubkeyToAddress(*publicKeyECDSA)
	nonce, err := client.PendingNonceAt(context.Background(), fromAddress)
	checkErr(err)
	gasPrice, err := client.SuggestGasPrice(context.Background())
	checkErr(err)

	auth := bind.NewKeyedTransactor(privateKey)
	auth.Nonce = big.NewInt(int64(nonce))
	auth.Value = big.NewInt(0)     // in wei
	auth.GasLimit = uint64(300000) // in units
	auth.GasPrice = gasPrice

	// 部署合约
	address, tx, _, err := contracts.DeployCounter(auth, client)
	checkErr(err)

	log.Printf("deploy at address %v", address.Hex()) // 0x147B8eb97fD247D06C4006D269c90C1908Fb5D54
	log.Printf("tx (deploy): %v", tx.Hash().Hex())

	// 检测是否已经被打包
	err = checkTx(client, tx)
	checkErr(err)

	return address.String()
}

func loadCounter(client *ethclient.Client, address common.Address) *contracts.Counter {
	instance, err := contracts.NewCounter(address, client)
	checkErr(err)
	return instance
}

func callValue(instance *contracts.Counter) {
	// 查询合约
	// 调用合约中的 Value 方法(这个方法不会改变合约的状态)
	value, err := instance.Value(nil)
	checkErr(err)
	log.Printf("value: %v", value)
}

func callIncrease(client *ethclient.Client, privateKey *ecdsa.PrivateKey, instance *contracts.Counter) {
	auth := bind.NewKeyedTransactor(privateKey)

	// 写入合约(发送交易)
	// 调用合约中的 Increase 方法(这个方法变改变合约的状态)
	tx, err := instance.Increase(auth, big.NewInt(10))
	checkErr(err)
	log.Printf("tx (call increase): %s", tx.Hash().Hex())


	// 检测是否已经被打包
	err = checkTx(client, tx)
	checkErr(err)
}

// checkTx 查询交易是否已经被节点打包
func checkTx(client *ethclient.Client, tx *types.Transaction) error {
retry:
	rp, err := client.TransactionReceipt(context.Background(), tx.Hash())
	if err != nil {
		if err == ethereum.NotFound {
			// 可能交易暂时未被节点打包(pending),这里需要重新查询
			log.Printf("tx %v not found, check it later", tx.Hash().String())
			time.Sleep(1 * time.Second)
			goto retry
		} else {
			log.Fatalf("TransactionReceipt fail: %s", err)
			return err
		}
	}

	if rp.Status != types.ReceiptStatusSuccessful {
		log.Fatalf("TransactionReceipt fail, status is not successful")
		return fmt.Errorf("TransactionReceipt fail, status is not successful")
	}
	log.Printf("tx %v confirmed by node", tx.Hash().String())
	return nil
}

func main() {
	client, err := ethclient.Dial("https://rinkeby.infura.io/v3/your-project-id") // change it !
	checkErr(err)

	privateKey, err := crypto.HexToECDSA("fad9c8855b740a0b7ed4c221dbad0f33a83a49cad6b3fe8d5817ac83d38b6a19") // change it !
	checkErr(err)

	// 部署 Counter 合约
	addressHexStr := deployCounter(client, privateKey)

	// 加载合约
	address := common.HexToAddress(addressHexStr)
	instance := loadCounter(client, address)

	// 写入合约,调用 Counter 合约中的方法 Increase
	callIncrease(client, privateKey, instance)

	// 查询合约,调用 Counter 合约中的方法 Value
	callValue(instance)
}

运行上面代码前,先需要正确地生成 contracts/counter.go 文件,参考节 5.2

6. Foundry

Foundry 是一个 Ethereum 智能合约开发框架,可用于构建、测试、调试和部署 Solidity 智能合约。Foundry 的优势是把 Solidity 当作第一公民,使用 Solidity 可直接编写测试用例。

Foundry 有 4 个组件:

  1. forge:合约开发框架,相当于 Truffle/Hardhat/DappTools;
  2. cast:链上交互工具;
  3. anvil:本地节点;
  4. chisel:交互式的 Solidity 环境。

安装 Foundry 开发工具:

$ curl -L https://foundry.paradigm.xyz | bash       # 安装 foundryup
$ foundryup                                         # 通过 foundryup 安装工具 forge, cast, anvil, and chisel

常用 forge 命令如表 2 所示。

Table 2: forge 常用命令
命令 作用
forge init hello_foundry 创建名为 hello_foundry 的项目
forge build 编译项目
forge test 运行项目测试用例
forge test --match-test testFunc1 运行项目中的指定测试函数

参考:https://book.getfoundry.sh/

7. Hardhat

Hardhat 是一个和 Truffle 类似的 Ethereum 智能合约开发环境。

$ npm install --save-dev hardhat      # 安装 hardhat 依赖
$ npx hardhat                         # 当前目录中没有 hardhat 项目时,会提示你创建一个 hardhat 项目
$ npx hardhat --help                  # 查看 hardhat 帮助
Hardhat version 2.12.4

Usage: hardhat [GLOBAL OPTIONS] <TASK> [TASK OPTIONS]

GLOBAL OPTIONS:

  --config              A Hardhat config file.
  --emoji               Use emoji in messages.
  --flamegraph          Generate a flamegraph of your Hardhat tasks
  --help                Shows this message, or a task's help if its name is provided
  --max-memory          The maximum amount of memory that Hardhat can use.
  --network             The network to connect to.
  --show-stack-traces   Show stack traces (always enabled on CI servers).
  --tsconfig            A TypeScript config file.
  --typecheck           Enable TypeScript type-checking of your scripts/tests
  --verbose             Enables Hardhat verbose logging
  --version             Shows hardhat's version.


AVAILABLE TASKS:

  check                 Check whatever you need
  clean                 Clears the cache and deletes all artifacts
  compile               Compiles the entire project, building all artifacts
  console               Opens a hardhat console
  coverage              Generates a code coverage report for tests
  deploy                Deploy contracts
  etherscan-verify      submit contract source code to etherscan
  export                export contract deployment of the specified network into one file
  export-artifacts
  flatten               Flattens and prints contracts and their dependencies
  help                  Prints this message
  node                  Starts a JSON-RPC server on top of Hardhat EVM
  run                   Runs a user-defined script after compiling the project
  sourcify              submit contract source code to sourcify (https://sourcify.dev)
  test                  Runs mocha tests
  typechain             Generate Typechain typings for compiled contracts
  verify                Verifies contract on Etherscan

To get help for a specific task run: npx hardhat help [task]

运行 npx hardhat 命令时默认会创建一个示例合约 contracts/Greeter.sol,其内容如下:

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.7.0;

import "hardhat/console.sol";


contract Greeter {
  string greeting;

  constructor(string memory _greeting) {
    console.log("Deploying a Greeter with greeting:", _greeting);
    greeting = _greeting;
  }

  function greet() public view returns (string memory) {
    return greeting;
  }

  function setGreeting(string memory _greeting) public {
    console.log("Changing greeting from '%s' to '%s'", greeting, _greeting);
    greeting = _greeting;
  }
}

这个示例合约中使用了 console.log ,用它可以很方便地进行代码调试。

运行 npx hardhat 命令时,默认也会创建一个测试用例 test/sample-test.js,其内容如下:

const { expect } = require("chai");

describe("Greeter", function() {
  it("Should return the new greeting once it's changed", async function() {
    const Greeter = await ethers.getContractFactory("Greeter");
    const greeter = await Greeter.deploy("Hello, world!");

    await greeter.deployed();
    expect(await greeter.greet()).to.equal("Hello, world!");

    await greeter.setGreeting("Hola, mundo!");
    expect(await greeter.greet()).to.equal("Hola, mundo!");
  });
});

下面是使用 hardhat 编译合约和运行测试用例:

$ npx hardhat compile                 # 编译合约
$ npx hardhat test                    # 运行测试用例


  Greeter
Deploying a Greeter with greeting: Hello, world!                  <----- 合约中的 console.log 输出
Changing greeting from 'Hello, world!' to 'Hola, mundo!'          <----- 合约中的 console.log 输出
    ✓ Should return the new greeting once it's changed (1225ms)


  1 passing (1s)

可见,合约中的 console.log 输出到控制台了,非常方便。

下面是启动 hardhat 本地节点:

$ npx hardhat node
Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/

Accounts
========
Account #0: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH)
Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

Account #1: 0x70997970c51812dc3a010c7d01b50e0d17dc79c8 (10000 ETH)
Private Key: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

......

web3_clientVersion
eth_chainId
eth_accounts
eth_chainId
eth_estimateGas
eth_gasPrice
eth_sendTransaction
  Contract deployment: Greeter
  Contract address:    0x5fbdb2315678afecb367f032d93f642f64180aa3
  Transaction:         0x28101d672a659feb8e5105b61a97b9c01b9ae4a5a01c20c1a39a0bad10a53144
  From:                0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
  Value:               0 ETH
  Gas used:            496037 of 496037
  Block #1:            0xf171828ebbcac72873ee528e2778365f63a9c6f2c11af43045d64ab937ffedd4

  console.log:
    Deploying a Greeter with greeting: Hello, Hardhat!

这个控制台中也会输出合约中 console.log 的输出,非常方便。

参考:https://hardhat.org/getting-started/

7.1. 部署时去掉 console.log

使用插件 hardhat-preprocessor ,可以在真正部署时去掉 console.log 相关代码。

安装插件:

$ npm install hardhat-preprocessor

在 hardhat.config.js 中增加下面内容即可:

import {removeConsoleLog} from 'hardhat-preprocessor';

export default {
  preprocess: {
    eachLine: removeConsoleLog((bre) => bre.network.name !== 'hardhat' && bre.network.name !== 'localhost'),
  },
};

8. Remix

Remix 是一个基于浏览器的 Ethereum 智能合约开发环境,支持合约的编译、部署、调试等等。可以在本地搭建 Remix 环境,也可以通过网站 https://remix.ethereum.org/ 直接使用。

Author: cig01

Created: <2019-02-18 Mon>

Last updated: <2022-07-11 Mon>

Creator: Emacs 27.1 (Org mode 9.4)