【精解】开发一个智能合约

智能合约

这两天被老大搞去搬砖,学习计划有变但无大碍,这篇文章将仔细分析智能合约相关内容。

关键字:智能合约,remix,Solidity,truffle,geth,leveldb,datadir,ganache,web3j

合约

合约也称合同、协议,是甲乙双方参与的,制定一系列条目规范双方权利与义务的文件。智能合约是电子化的,自动执行的,去中心化的,具有不可抵赖性,本质上它是一段代码,依托于区块链技术,它可以做很多事情,基于以太坊的智能合约可以让你的区块链扩展出任何你想要的功能。

我相信,智能合约是区块链的未来,因为基于它能做的商业模型太多样了,远远不仅是数字货币一种。

Solidity

智能合约的编程语言是Solidity,扩展名为.sol,它是基于C++、JavaScript、Python创造而来的,这里是官方文档

Solidity是静态类型的,支持继承,有自己的函数库,它同样支持面向对象语言的自定义类型等其他功能。

Solidity编写的智能合约代码运行在EVM,即以太坊虚拟机,正如java编写的代码运行在JVM一样,在同一个区块链中每一个结点的EVM都是相同的运行环境。通过智能合约,可以开发匿名投票、匿名拍卖、众筹以及多重签名的钱包等,以太坊每一个结点可以有多个账户,所以每个结点都可以称作钱包,可以管理名下的账户,以及转账、挖矿等操作。

官方推荐IDE:Remix

其实Solidity智能合约开发的IDE有很多,官方推荐的Remix是基于浏览器的,运行环境可以切换:

  • 挂在自己的JavaScript EVM
  • 也可以使用web3 provider
  • 还可以使用注入的web3连接到本机调试环境

我使用以后,觉得浏览器的方式还是不习惯,尤其保存的文件无故消失,让我始终心有余悸,经过调研,下面我们将采用goLand,安装Intellij-Solidity-2.0.4插件的方式开发智能合约,然后使用Remix环境进行智能合约的部署。当然我们也可以使用Remix进行运行、测试以及调试工作,下面酌情展示。

gas

区块链中比较有意思的命名,相当于手续费但又有些不同。gas为天然气,用来代表我们程序运行所有的能耗,当发生交易等操作时会消耗相应的gas,gas的计算方式是

gas 单价 × gas 数量

其中gas单价是由用户,像我们这样的发起者愿意为此次操作付出多少以太币而定的(相当于你开车上路前愿意给你的油箱加多少油,假设你的油箱是无限大的)。gas数量是程序根据你操作的复杂度自动定义的。

智能合约也是一样的,当一个发起者部署运行一段智能合约时,以太坊会收取gas费用,就像汽车行驶需要烧油一样,直到你的智能合约运行完毕,“油箱”中剩余的gas会退还给你,如果你的代码死循环了,耗尽了你“油箱”中的gas,那么以太坊会自动报出异常停止你的智能合约。我们在学习智能合约阶段,可以使用testnet环境来避免真的花费以太币。

Dapp

Dapp为Solidity提供了源码构建工具,包管理工具,单元测试以及智能合约部署,一会儿我们看看是否必须要用它。有时它也被称作去中心化的应用程序(Decentralized App)。这种应用程序除了有一段代码的智能合约以外,还需要UI,UE设计等,正如apple的app开发,我们未来的目标之一可以是开发自己的Dapp。

准备工作

首先要开启一个本地的EVM,前面的文章对Geth做了详细的介绍,这里直接启动一个本地开发模式的结点。

geth --datadir testNet --dev console 2>>Documents/someLogs/testGeth.log

简介一下geth的参数选项:

dev

Ephemeral proof-of-authority network with a pre-funded developer account, mining enabled

短暂的认证证明网络,同时创建一个预存款很多钱的一个开发者账户,并自动开始挖矿。

datadir

datadir,指定结点文件目录,如果没有会自动创建一个,该目录包含:

  • geth
    • chaindata 区块数据、状态数据的目录,数据库是leveldb(一个键值对数据库)
      • 000001.log
      • CURRENT 指向MANIFEST
      • LOCK 区块数据锁定标识文件
      • LOG 数据库(区块和状态)操作日志
      • *.ldb 块数据文件
      • MANIFEST-000000 (TODO,我也不知道是什么,谁能告诉我一下)
    • LOCK 结点锁定标识文件
    • nodekey 结点身份公钥,用于p2p网络寻找结点使用
    • transactions.rlp
  • geth.ipc Mist是以太坊钱包,该文件是Mist用来内部过程通信的socket文件。
  • keystore 存储私钥
    • UTC--2018-02-06T03-46-35.626115529Z--740b9c48d67cf333c8b1c0e609b6b90b40d3cdea

以上目录中元素精解:

① nodekey

结点之间相互寻找是通过一个发现协议:一个基于S/Kademlia的网络协议。这个协议会把包含IP地址的公钥联系起来。实际上在结点之间的peer连接使用的是一个完全不同的,加密的协议(RLPX)。RLPX加密的工作方式需要远程终端连接发起者的公钥作为身份识别。本质上来说,这个key链接了发现协议和RLPX。

你可以随时删除这个nodekey,重启的时候会自动生成一个新的。

② keystore/UTC--2018-02-06T03-46-35.626115529Z--740b9c48d67cf333c8b1c0e609b6b90b40d3cdea

这是存储结点私钥的位置,文件名为时间戳加上本地账户拼成的字符串。打开文件,内容为一个json,格式化以后为:

{
    "address": "740b9c48d67cf333c8b1c0e609b6b90b40d3cdea", "comment":"本地账户地址", 
    "crypto": {
        "cipher": "aes-128-ctr", "comment":"加密协议采用的是AES-128",
        "ciphertext": "b331a3dbdde9abd14991116ac0bb1b742f22edda162b567974f8fbf1d694daef", "comment":"密文",
        "cipherparams": {
            "iv": "06d0df7a5b7160da852fbb01339149ae", "comment":"加密参数"
        }, 
        "kdf": "scrypt", "comment":"Key Derivation Function, 将短密码加盐hash成长密码,防彩虹表、防暴力破解",
        "kdfparams": {
            "dklen": 32, "comment":"KDF加密参数",
            "n": 262144, 
            "p": 1, 
            "r": 8, 
            "salt": "6ffbd23fac4ed386aac703bc180f50be02690bef5239057a34dde4dd4de2416b", "comment":"盐值,加盐加密"
        }, 
        "mac": "06b7d92b98a3b732dc1e63e7e09b8e3d79a9e8e1d43ee7a1b40482db295ea367", "comment":"message authentication code,消息认证码"
    }, 
    "id": "ff7e243a-150e-45f6-ac64-06b0ed2e68ec", "comment":"文件主键",
    "version": 3
}

这部分范畴属于密码学方面了,可以参考《应用密码学初探》

③ transactions.rlp

RLP(Recursive Length Prefix),递归长度前缀。是以太坊中用于序列号对象的主要编码方法。根据文件名可以猜出,这是所有交易的序列化对象文件。

④ chaindata

数据库采用leveldb,存储了区块数据以及状态数据。该目录下打包存储以.ldb为扩展名的每个区块的数据文件。每个块文件有容量的最大值,目前我本机默认的是2.1M,我们设想一下目前以太坊的区块高度为5039768,如果一个块是2.1M的话,那么整个区块链的数据大小为10TB。

⑤ leveldb

Google出品的另一利器,使用C++编写,基于LSM(Log-Structured-Merge Tree)日志结构化合并树,是一个高效的键值对存储系统,是没有Sql语句的非关系型数据库。键值对均采用字符串类型,按照key排序。

特点包括:

  1. 键和值都是当作简单的字节数组,所以内容可以从ASCII字符串到二进制文件。
  2. 数据按照key排序存储。
  3. 调用者可以自定义一个比较方法来复写排序。
  4. 基本操作有插入、获取和删除:Put(key,value), Get(key), Delete(key).
  5. 一次原子批量操作可以执行多重变更操作。
  6. 用户能够创建一个瞬时快照来获取一个统一的数据视图。
  7. 数据可以向前亦或是向后迭代。
  8. 数据采用Snappy(也是Google的一个压缩库)自动被压缩。
  9. 用户可以通过一个虚拟接口自定义操作交互系统来实现一些额外的操作。

局限性包括:

  1. 无SQL,无索引,非关系型数据库
  2. 同时只允许一个进程访问(但支持多线程)
  3. 无客户端-服务端内置库支持,一个应用程序必须要包装自己的服务器到库才能获得这样的支持。

console

console命令在EVM启动的同时开启了一个交互控制台,后面的一串命令是将输出的log转存到文件testGeth.log中去,启动时的日志文件:

WARN [02-06|11:46:35] No etherbase set and no accounts found as default 
INFO [02-06|11:46:37] Using developer account                  address=0x740b9C48D67Cf333C8b1c0E609b6b90b40D3CdeA
INFO [02-06|11:46:37] Starting peer-to-peer node               instance=Geth/v1.7.3-stable-4706005b/linux-amd64/go1.9.2
INFO [02-06|11:46:37] Allocated cache and file handles         database=/home/liuwenbin/testNet/geth/chaindata cache=128 handles=1024
INFO [02-06|11:46:37] Writing custom genesis block 
INFO [02-06|11:46:37] Initialised chain configuration          config="{ChainID: 1337 Homestead: 0 DAO: <nil> DAOSupport: false EIP150: 0 EIP155: 0 EIP158: 0 Byzantium: 0 Engine: clique}"
INFO [02-06|11:46:37] Initialising Ethereum protocol           versions="[63 62]" network=1
INFO [02-06|11:46:37] Loaded most recent local header          number=0 hash=593c0e…256b90 td=1
INFO [02-06|11:46:37] Loaded most recent local full block      number=0 hash=593c0e…256b90 td=1
INFO [02-06|11:46:37] Loaded most recent local fast block      number=0 hash=593c0e…256b90 td=1
INFO [02-06|11:46:37] Regenerated local transaction journal    transactions=0 accounts=0
INFO [02-06|11:46:37] Starting P2P networking 
INFO [02-06|11:46:37] started whisper v.5.0 
INFO [02-06|11:46:37] RLPx listener up                         self="enode://ede08b763001ed3642e0b3860d57e694489bcc1f47dde8563f2577bdec48e6949748826d9b88f55f456af2ae1e75ce2ea04a59eb0ef1c2c53330be92e44e6515@[::]:46591?discport=0"
INFO [02-06|11:46:37] Transaction pool price threshold updated price=18000000000
INFO [02-06|11:46:37] IPC endpoint opened: /home/liuwenbin/testNet/geth.ipc 
INFO [02-06|11:46:37] Starting mining operation 
INFO [02-06|11:46:37] Commit new mining work                   number=1 txs=0 uncles=0 elapsed=53.048µs

我们逐行分析,

  1. 启动时第一行并未找到以太坊base的设置以及默认账户。
  2. 说明使用了开发者账户,后面给出了账户地址。
  3. 开始p2p网络结点,实例采用的是基于go1.9.2版本的geth实例。
  4. 分配缓存和文件句柄(打开文件的唯一标识,给一个文件、设备、socket或管道一个名字,隐藏关联细节),数据库位置在/home/liuwenbin/testNet/geth/chaindata,缓存大小为128M, 文件句柄数为1024。
  5. 写入当前创世块。
  6. 初始化链配置,展示配置信息。
  7. 初始化以太坊协议。
  8. 载入大部分最近的本地数据头
  9. 载入大部分最近的本地完整块数据
  10. 载入大部分最近的本地最高块数据
  11. 重新生成本地交易账本
  12. 开始p2p网络
  13. 开始whisper
  14. RLPx开始监控,并打印出当前enode信息
  15. 交易池价格阀值更新,价格为=18000000000
  16. IPC端点开启:/home/liuwenbin/testNet/geth.ipc
  17. 开始挖矿操作
  18. 提交新的挖矿工作

helloworld

下面在console中查看一下当前账户的余额,发现开发环境默认给分配的余额太大,并不好测试,那么我们自己再创建一个用户,余额为0,然后用第一个“大款”账户转账给新创建用户1个以太币。

> eth.sendTransaction({from: '0x740b9c48d67cf333c8b1c0e609b6b90b40d3cdea',to:'0x1d863371462223910a1f05329b6dea0b0f9c49f8',value:web3.toWei(1,"ether")})
"0xb456244e4fb25b74108f05afe53670b5f1a857f5671e7d3fa2e221419d04382c"
> eth.getBalance(eth.accounts[1])

333333333333333333

我发现一个事,之前乘三那个geth还存在呢(捂脸笑出泪),让我改一下吧。改后我重新部署了geth命令,然后将新建用户的3个以太转回大款账户,由于gas的存在(实际上即使转账时你自己指定,也是基于一个最小值,往多了给,如果低于这个最小值,就会报错:“你加的油太少啦,我根本跑不过去”。所以最终费了大力,让新账户保留下了

> eth.getBalance(eth.accounts[1])
79000

这79000wei的以太币是无法转出去了,因为我的余额付不起油钱。实际上79000这个数字可读性还行,所以拿这个测试也可以。

IDE编码

上面说道了我们采用goLand安装Solidity插件的方式来开发智能合约。JetBrain系列IDE插件的安装我就不介绍了,网上随便查。下面我们开始编码:

pragma solidity ^0.4.0;

contract helloworld {
    string content;

    function helloworld(string _str) public {
        content = _str;
    }

    function getContent() constant public returns (string){
        return content;
    }
}

代码编写很简单,我们逐行解读:

  1. 通过关键字pragma标识Solidity的版本为0.4.0,我们下面的代码都会采用该版本来编译。
  2. contract关键字定义一个合约,它可以有自己的方法,自己的属性(智能合约里面更愿意称为状态),将会存储在区块链中特定的地址。
  3. 声明了一个字符串类型(注意首字母小写的类型关键字string)的content状态(叫做属性、成员变量都可以)
  4. 通过关键字function定义一个构造方法,需要传入一个字符串数据,注意该方法的权限public被标识在了参数列表的后面。
  5. 通过该方法赋值给状态content(注意不用使用this),方法的参数变量名采用了下划线开头的方式用来代表该变量的作用域很小,是私有变量,这是编程语言中的一种约定俗成的命名规则。
  6. 通过关键字function定义一个打印方法,返回状态content的值,注意除了public权限以外,public的前侧还有一个constant关键字,后侧还通过关键字returns定义了返回值类型。

部署

上面我们使用了goLand的Solidity插件进行了合约代码的开发,然而该插件的功能仅包括:

  1. 语法高亮,代码提示
  2. 代码完整性检查
  3. 文件模板
  4. goto声明
  5. Find usages
  6. 代码格式化

可以说都是针对编码辅助的操作,然而若我们要部署智能合约,还得回到Remix,我们新建一个sol文件,粘贴进去上面写好的helloworld代码,然后点击右侧Details,弹出的界面包含了名字、字节码、元数据等内容,我们只要其中的WEB3DEPLOY,复制出其中内容,将第一行传入参数“hello world”:

var string_str = "hello world" ;
var helloworldContract = web3.eth.contract([{"constant":true,"inputs":[],"name":"getContent","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"inputs":[{"name":"string_str","type":"string"}],"payable":false,"stateMutability":"nonpayable","type":"constructor"}]);
var helloworld = helloworldContract.new(
   string_str,
   {
     from: web3.eth.accounts[0], 
     data: '0x6060604052341561000f57600080fd5b6040516102b83803806102b8833981016040528080518201919050508060009080519060200190610041929190610048565b50506100ed565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282601f1061008957805160ff19168380011785556100b7565b828001600101855582156100b7579182015b828111156100b657825182559160200191906001019061009b565b5b5090506100c491906100c8565b5090565b6100ea91905b808211156100e65760008160009055506001016100ce565b5090565b90565b6101bc806100fc6000396000f300606060405260043610610041576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806359016c7914610046575b600080fd5b341561005157600080fd5b6100596100d4565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561009957808201518184015260208101905061007e565b50505050905090810190601f1680156100c65780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b6100dc61017c565b60008054600181600116156101000203166002900480601f0160208091040260200160405190810160405280929190818152602001828054600181600116156101000203166002900480156101725780601f1061014757610100808354040283529160200191610172565b820191906000526020600020905b81548152906001019060200180831161015557829003601f168201915b5050505050905090565b6020604051908101604052806000815250905600a165627a7a72305820f4bd9a6659a8625f89177c604c901764cf9cca4fa8aa2e792525da3647ca7a510029', 
     gas: '4700000'
   }, function (e, contract){
    console.log(e, contract);
    if (typeof contract.address !== 'undefined') {
         console.log('Contract mined! address: ' + contract.address + ' transactionHash: ' + contract.transactionHash);
    }
 })

仔细观察上面的代码,Remix帮我们将代码转成了EVM可识别的样子,也就是将Solidity代码编译成web3的版本,其中也帮我们估算好了gas的金额,当我们执行这段合约时会自动扣掉我们余额中相应的数值作为gas费用。

接着,我们回到console,先解锁智能合约发布者的账号,我们选择刚才新建的

> personal.unlockAccount(eth.accounts[1],"lwb")
true

然后将上面的web3版的代码复制过来,回车,输出:

Contract mined! address: 0x71db931bdb2f9516cf892aa0c620bd686d1095e5 transactionHash: 0x6e39a97dd2f260517bedeb9934cf88430526b46a379d5680cc092d8ea3f44602

合约被挖出,打印出来了合约地址,交易hash(这在以太坊中也被认定为是一笔交易,我们付费gas给以太坊)。
然后继续在console中输入

> helloworld.getContent()
"hello world"

由于我们余额是79000,上面gas给预估的是4700000,所以预想结果是您的余额不足,合约无法运行,然而合约部署运行成功了。

我们从大款那再转账一个以太币过来。然后关闭重启geth console,重复上面的操作。

TODO: 余额仍旧未减少。不知道gas扣到哪去了。

同步查看日志输出:

INFO [02-06|17:36:34] Submitted contract creation              fullhash=0x6e39a97dd2f260517bedeb9934cf88430526b46a379d5680cc092d8ea3f44602 contract=0x71DB931bdb2f9516Cf892aA0c620bD686D1095E5
INFO [02-06|17:36:34] Commit new mining work                   number=18 txs=1 uncles=0 elapsed=313.823µs
INFO [02-06|17:36:34] Successfully sealed new block            number=18 hash=37913b…f101af
INFO [02-06|17:36:34] 🔨 mined potential block                  number=18 hash=37913b…f101af

每当我们提交了一个合约,

  • 第一行打印出了上面合约部署成功时的交易hash和合约地址。
  • 然后开始挖矿记账,目前已经记到第18个块了
  • 第三行第四行显示成功密封了一个新的区块。

Solidity语法

上面使用Solidity编写了一个helloworld智能合约,稍显力不从心,下面我们专门来学习一下Solidity语法,为未来我们编写复杂的智能合约工程打下基础。

类型

学习一门新的编程语言,首先要看它的类型,Solidity是静态类型语言,跟java一样,也就是说在编译之前都要指定好每个变量的具体类型。类型可以分为值类型和引用类型,与java类似。

1.值类型

值类型作为参数时永远传的是值,每一次入参出参都是内存中值的副本。包括:

  • 布尔类型bool,true\false,与非门操作不多说。
  • 整型int,与go语言相同,有符无符int/uint,从长度8到256(int8, int16, int32... int256),数学运算包括位运算不多讲,有不明的地方请转到掌握一门语言Go
  • 定长浮点型fixed,有符无符fixed/ufixed,还未完全被Solidity支持,可声明不可赋值,多说无益。
  • 定长字节数组(byte/bytes1, bytes2, bytes3, ..., bytes32),没什么好说的,有个length属性可以取出长度。
  • 变长字节数组(bytes, string),与以上定长类型不同的是,变长类型不必预先指定长度,但bytes和string都属于引用类型,下面会具体介绍。
  • 地址类型address,根据以太坊结点、账户、合约等address的概念设计,长度限制为20字节。神奇的是,address封装好了一个balance属性,可以查看账户余额,以及transfer方法,可以直接转账,非常方便。此外它还有send、call等很多常用方法,这是Solidity封装好的一个基本类型,适用于智能合约开发,以后用到了再详细探究细节。
  • 枚举类型enum,例如“enum ColorEnums {Red, White, Black}”,注意返回的都是下标值,Red会返回0,White返回1,Black返回2。
  • 函数类function,变量可以作为其他function的参数,也可以作为其他function的返回值。方法在参数后面可以声明函数可见性,除了public(任意合约)和private(当前合约)以外,还有internal(当前合约以及继承合约)和external(仅外部访问)。external是由address和function签名组成,可作为外部调用函数的参数或者返回值,默认情况无显式声明时就是internal。function还需要声明返回值类型,returns (type),但若方法无返回值时要省略这个部分。另外还有特殊的部分是在internal和returns中间还可以加入一个配置属性,[pure|constant|view|payable]。constant标识了一个常量不会被更改,只读不可写入。view是查看区块链上的数据的意思,比constant更加准确地表达了只是看看,不做修改的意图 。pure是纯函数的意思,就是保证不读取和写入到区块链内存的函数。payable是声明了该函数设计支付操作,需要虚拟机提供事务支持。 protected,

下面是针对以上类型的字面量类型:

字面量是一种针对某种值的表示法,简单来说,就是变量赋值时必须是等号右边的部分。

  • Address字面量,十六进制字面量的一种特殊情况:长度在为40个十六进制数(一个字节8位可存储两个十六进制数,一个4位),且通过了address checksum 校验。
  • 有理数整型字面量,整数,小数,科学计数法2e10,最广泛的字面量类型。
  • 字符串字面量,单引号、双引号均可的字符串。
  • 十六进制字面量,hex开头,例如hex"001122FF",必须是一个字符串,内容必须是十六进制。

2.引用类型

  • 数据位置类型,分为memory(内存-临时)和storage(区块链-永久),通过在变量名前声明memory还是storage来定义该变量的数据位置。一般来讲,函数参数默认为memory,局部复杂类型(作用域为局部)以及状态变量(作用域为全局)属于storage类型。还有一个calldata与memory差不多,专门用于存储函数参数的,也不是永久存储。额外提一点,EVM的memory是基于stack的,stack可以临时存储一些小的局部变量。这些变量存储消耗的gas是不同的,storage最大,memory较小,stack几乎免费,calldata与memory差不多。
  • 数组类型Arrays,长度可定可变,可以存储于storage和memory,元素类型可以是任何类型,但memory时不能是映射类型(就是键值对类型)。
  • 结构体struct,与Go语言相同的设定,自定义类型,使用方式也与Go极为相似。

mapping类型

mapping类型就是键值对,现在最新语言都会给自身增加键值对数据结构的封装支持。mapping的声明方式为:

mapping(_KeyType => _ValueType)

键值对中间通过一个“=>”连接。元素内容,Solidity类型均可,与其他键值对使用差不多,遇到问题再深入研究。

其他

关于Solidity其他语法这里暂不过多介绍,掌握以上Solidity的类型知识,我想其他语法可以在实战中解决掉。下面会以“Solidit语法补充说明”的形式对新遇到的语法问题进行补充研究。

Truffle MetaCoin环境搭建实例

上面我们开发部署运行智能合约helloworld时,编码是在goLand,编译是在Remix,部署运行是在geth console,感觉好混乱,也不适合大规模工程开发,是否有一种工具可以集成这一切?

Truffle!

准备工作

由于truffle是依赖于nodejs,可能会有版本不兼容的问题,因此要先完全删除你机器上的nodejs和npm,然后再安装纯净版的nodejs,npm,truffle,请按照以下命令进行。

sudo apt-get remove nodejs
sudo apt-get remove npm
sudo apt-get update
which node
wget https://nodejs.org/dist/v8.8.0/node-v8.8.0-linux-x64.tar.gz
sudo tar -xf node-v8.8.0-linux-x64.tar.gz --directory /usr/local --strip-components 1
node --version
npm --version
sudo npm install -g truffle

MetaCoin初始化

此时应该可以直接使用命令truffle了,下面我们建立一个工作间truffle-workspace,然后在工作间执行:

mkdir MetaCoin
cd MetaCoin
truffle unbox metacoin

原来使用truffle init,但现在它存在于unbox。

unbox

Truffle 的盒子Boxs装有很多非常实用的项目样板,可以让你忽略一些环境配置问题,从而可以集中与开发你自己的DApp的业务唯一性。除此之外,Truffle Boxes能够容纳其他有用的组件、Solidity合约或者库,前后端视图等等。所有这些都是一个完整的实例Dapp程序。都可以下载下来逐一研究,寻找适合自己公司目前业务模型的组件。

Truffle的官方Boxes地址

可以看到,现在官方盒子还不多,总共7个,有三个是关于react的,两个是truffle自己的项目,可以下载体验,剩下两个是我们比较关心的,一个是metacoin,非常好的入门示例,另一个是webpack,顾名思义,它是一套比起metacoin更加完整的模板的存在。既然我们是初学,下面我们就从metacoin入手学习。

目录结构

进入metacoin目录,当前目录已经被初始化成一个新的空的以太坊工程,目录结构如下:

  • contracts
    • ConvertLib.sol
    • MetaCoin.sol
    • Migrations.sol
    • .placeholder
  • migrations
    • 1_initial_migration.js
    • 2_deploy_contracts.js
  • test
    • metacoin.js
    • TestMetacoin.sol
    • .placeholder
  • truffle-config.js
  • truffle.js

初始化文件解释1:Migrations.sol

pragma solidity ^0.4.17;

contract Migrations {
  address public owner;
  uint public last_completed_migration;

  modifier restricted() {
    if (msg.sender == owner) _;
  }

  function Migrations() public {
    owner = msg.sender;
  }

  function setCompleted(uint completed) public restricted {
    last_completed_migration = completed;
  }

  function upgrade(address new_address) public restricted {
    Migrations upgraded = Migrations(new_address);
    upgraded.setCompleted(last_completed_migration);
  }
}

上面我们学习了Solidity具体的类型语法,我们来分析一下这个文件:

  • 它定义了一个名字为“迁移”的合约
  • 有一个任意访问的全局变量,存储于storage的地址类型变量owner
  • 有一个可任意访问的全局变量,存储于storage的无符号整型类型的变量last_completed_migration
  • modifier下面细说,此处略过
  • msg.sender下面细说,此处略过
  • 构造函数,初始化将发送方赋值给owner保存
  • 一个setCompleted赋值方法,赋值给last_completed_migration,其中该方法被声明为restricted,下面细说,此处略过
  • upgrade方法,调用当前合约自己的方法,得到合约的实例upgraded,然后通过该是咧调用setCompleted赋值方法。

Solidity语法补充说明1:function modifier

modifier的使用方法,就看上面的Migrations合约的例子即可,它可以自动改变函数的行为,例如你可以给他预设一个条件,他会不断检查,一旦符合条件即可走预设分支。它可以影响当前合约以及派生合约。

pragma solidity ^0.4.11;

contract owned {
    function owned() public { owner = msg.sender; }
    address owner;
    // 这里仅定义了一个modifier但是没有使用,它将被子类使用,方法体在这里“_;”,这意味着如果owner调用了这个函数,函数会被执行,其他人调用会抛出一个异常。
    modifier onlyOwner {
        require(msg.sender == owner);
        _;
    }
}

// 通过is关键字来继承一个合约类,mortal是owned的子类,也叫派生类。
contract mortal is owned {
    // 当前合约派生了owned,此方法使用了父类的onlyOwner的modifier
    // public onlyOwner, 这种写法挺让人困惑,下面给出了我的思考,暂理解为派生类要使用基类的modifier。
    function close() public onlyOwner {
        selfdestruct(owner);
    }
}

contract priced {
    // Modifiers可以接收参数
    modifier costs(uint price) {
        // 这里modifier方法体是通过条件判断,是否满足,满足则执行“_;”分支。
        if (msg.value >= price) {
            _;
        }
    }
}

contract Register is priced, owned {
    mapping (address => bool) registeredAddresses;
    uint price;
    
    // 构造函数给全局变量price赋值。
    function Register(uint initialPrice) public { price = initialPrice; }

    // payable关键字重申,如果不声明的话,函数关于以太币交易的操作都会被拒回。
    function register() public payable costs(price) {
        registeredAddresses[msg.sender] = true;
    }

    // 此派生类也要使用基类的modifier。
    function changePrice(uint _price) public onlyOwner {
        price = _price;
    }
}

contract Mutex {
    bool locked;
    modifier noReentrancy() {
        require(!locked);
        locked = true;
        _;
        locked = false;
    }

    function f() public noReentrancy returns (uint) {
        require(msg.sender.call());
        return 7;
    }
}

又延伸出来一个盲点:require关键字,它是错误判断,提到assert就懂了,官方文档的解释为:

require(bool condition):
throws if the condition is not met - to be used for errors in inputs or external components.

总结一下modifier:

  • 声明modifier时,特殊符号“_;”的意思有点像TODO,是一个“占位符”,指出了你要写的具体方法体内容的位置。
  • function close() public onlyOwner,派生类某方法想“如虎添翼”加入基类的某个modifier功能,就可以这样写,这行的具体意思就是:close方法也必须是owner本人执行,否则报错!

Solidity语法补充说明2:Restricting Access

限制访问一种针对合约的常见模式。但其实你永远不可能限制得了任何人或电脑读取你的交易内容或者你的合同状态。你可以使用加密增大困难,但你的合约就是用来读取数据的,那么其他人也会看到。所以,其实上面的modifier onlyOwner是一个特别好的可读性极高的限制访问的手段。

那么restricted关键字如何使用呢?

好吧,我刚刚带着modifier的知识重新看了上面的Migrations合约的内容发现,restricted并不是关键字,而是modifier的方法名,在其下的想增加该modifier功能的函数中,都使用了public restricted的方式来声明。

说到这里,我又明白了为什么要使用public onlyOwner这种写法,因为public是函数可见性修饰符,onlyOwner是自定义的限制访问的modifier方法,他们都是关于函数使用限制方面的,所以会写在一起,可以假想一个括号将它俩括起来,他们占一个位置,就是原来属于public|private|internal|external的那个位置。

Solidity语法补充说明3:Special Variables and Functions

这一点很重要了,我们研究一下Solidity自身携带的特殊变量以及函数:

  1. block.blockhash(uint blockNumber) returns (bytes32): 返回参数区块编号的hash值。(范围仅限于最近256块,还不包含当然块)
  2. block.coinbase (address): 当前区块矿工地址
  3. block.difficulty (uint): 当前区块难度
  4. block.gaslimit (uint): 当前区块的gaslimit
  5. block.number (uint): 当前区块编号
  6. block.timestamp (uint): 当前区块的timestamp,使用UNIX时间秒
  7. msg.data (bytes): 完整的calldata
  8. msg.gas (uint): 剩余的gas
  9. msg.sender (address): 信息的发送方 (当前调用)
  10. msg.sig (bytes4): calldata的前四个字节 (i.e. 函数标识符)
  11. msg.value (uint): 消息发送的wei的数量
  12. now (uint): 当前区块的timestamp (block.timestamp别名)
  13. tx.gasprice (uint): 交易的gas单价
  14. tx.origin (address): 交易发送方地址(完全的链调用)

msg有两个属性,一个是msg.sender,另一个是msg.value,这两个值可以被任何external函数调用,包含库里面的函数。

注意谨慎使用block.timestamp, now and block.blockhash,因为他们都是有可能被篡改的。

初始化文件解释2:MetaCoin.sol

pragma solidity ^0.4.18;

import "./ConvertLib.sol";

// 这是一个简单的仿币合约的例子。它并不是标准的可兼容其他币或token的合约,
// 如果你想创建一个标准兼容的token,请转到 https://github.com/ConsenSys/Tokens(TODO:一会儿我们再过去转)

contract MetaCoin {
        mapping (address => uint) balances;// 定义了一个映射类型变量balances,key为address类型,值为无符整型,应该是用来存储每个账户的余额,可以存多个。

        event Transfer(address indexed _from, address indexed _to, uint256 _value);// Solidity语法event,TODO:见下方详解。

        function MetaCoin() public {// 构造函数,tx.origin查查上面,找到它会返回交易发送方的地址,也就是说合约实例创建时会默认为当前交易发送方的余额塞10000,单位应该是你的仿币。
                balances[tx.origin] = 10000;
        }

        function sendCoin(address receiver, uint amount) public returns(bool sufficient) {// 函数声明部分没有盲点,方法名,参数列表,函数可见性,返回值类型定义。
                if (balances[msg.sender] < amount) return false;// 如果余额不足,则返回发送币失败
                balances[msg.sender] -= amount;// 否则从发送方余额中减去发送值,注意Solidity也有 “-=”,“+=” 的运算符哦
                balances[receiver] += amount;// 然后在接收方的余额中加入发送值数量。
                Transfer(msg.sender, receiver, amount);// 使用以上event关键字声明的方法
                return true;
        }

        function getBalanceInEth(address addr) public view returns(uint){// 获取以太币余额
                return ConvertLib.convert(getBalance(addr),2);// 调用了其他合约的方法,TODO:稍后介绍ConvertLib合约时说明。
        }

        function getBalance(address addr) public view returns(uint) {// 获取当前账户的仿币余额
                return balances[addr];
        }
}

Solidity语法补充说明4:Events

Events allow the convenient usage of the EVM logging facilities, which in turn can be used to “call” JavaScript callbacks in the user interface of a dapp, which listen for these events.
Events提供了日志支持,进而可用于在用户界面上“调用”dapp JavaScript回调,监听了这些事件。简单来说,我们的DApp是基于web服务器上的web3.js与EVM以太坊结点进行交互的,而智能合约是部署在EVM以太坊结点上的。举一个例子:

contract ExampleContract {
  // some state variables ...
  function foo(int256 _value) returns (int256) {
    // manipulate state ...
    return _value;
  }
}

合约ExampleContract有个方法foo被部署在EVM的一个结点上运行了,此时用户如果想在DApp上调用合约内部的这个foo方法,如何操作呢,有两种办法:

  1. var returnValue = exampleContract.foo.call(2);// 通过web3 的message的call来调用。
  2. 合约内部再声明一个event ReturnValue(address indexed _from, int256 _value);并在foo方法内使用该event用来返回方法执行结果。

第一种办法在方法本身比较耗时的情况下会阻塞,或者不会获取到准确的返回值。所以采用第二种办法:就是通过Solidity的关键字event。event在这里就是一个回调函数的概念,当函数运行结束以后(交易进块),会通过event返回给web3,也就是DApp用户界面相应的结果。这是以太坊一种客户端异步调用方法。关于这个回调,要在DApp使用web3时显示编写:

exampleEvent.watch(function(err, result) {
  if (err) {
    console.log(err)
    return;
  }
  console.log(result.args._value)
  // 检查合约方法是否反返回结果,若有则将结果显示在用户界面并且调用exampleEvent.stopWatching()方法停止异步回调监听。
})

写Solidity最大的不同在于,我们要随时计算好我们的gas消耗,方法的复杂度,变量类型的存储位置(memory,storage等等)都会决定gas的消耗量。

使用event可以获得比storage更便宜的gas消耗。

总结一下event,就是如果你的Dapp客户端web3.js想调用智能合约内部的函数,则使用event作为桥梁,它能方便执行异步调用同时又节约gas消耗。

初始化文件解释3:ConvertLib.sol

pragma solidity ^0.4.4;

library ConvertLib{
        function convert(uint amount,uint conversionRate) public pure returns (uint convertedAmount)
        {
                return amount * conversionRate;
        }
}

与MetaCoin智能合约不同的是,ConvertLib是由library声明的一个库,它只有一个方法,就是返回给定的两个无符整数值相乘的结果。返回到上面的MetaCoin中该库的使用位置去分析,即可知道,MetaCoin的仿币的价格是以太币的一倍,所以MetaCoin是以以太币为标杆,通过智能合约发布的一个token,仿币。

这似乎就可以很好地解决我在《以太坊RPC机制与API实例》文章中需要发布三倍以太币的token的需求了,而我们完全不必更改以太坊源码,但那篇文章通过这个需求的路线研究了以太坊的Go源码也算功不可没。

初始化文件解释4:1_initial_migration.js

var Migrations = artifacts.require("./Migrations.sol");

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

这个js文件是nodejs的写法,看上去它的作用就是部署了上面的Migrations智能合约文件。

初始化文件解释5:2_deploy_contracts.js

var ConvertLib = artifacts.require("./ConvertLib.sol");
var MetaCoin = artifacts.require("./MetaCoin.sol");

module.exports = function(deployer) {
  deployer.deploy(ConvertLib);
  deployer.link(ConvertLib, MetaCoin);
  deployer.deploy(MetaCoin);
};

这个文件是meatcoin智能合约的部署文件,里面约定了部署顺序,依赖关系。这里我们看到了MetaCoin智能合约是要依赖于库ConvertLib的,所以要先部署ConvertLib,然后link他们,再部署MetaCoin,这部分js的写法可以参照官方文档DEPLOYER API,主要就是介绍了一下deploy、link以及then三个方法的详细用法,不难这里不再赘述。

初始化文件解释6:truffle-config.js, truffle.js

module.exports = {
  // See <http://truffleframework.com/docs/advanced/configuration>
  // to customize your Truffle configuration!
};
module.exports = {
  // See <http://truffleframework.com/docs/advanced/configuration>
  // to customize your Truffle configuration!
};

这两个文件也都是nodejs,他们都是配置文件,可能作用域不同,目前它俩是完全相同的(因为啥也没有)。我们去它推荐的网站看一看。给出了一个例子:

module.exports = {
  networks: {
    development: {
      host: "127.0.0.1",
      port: 8545,
      network_id: "*" // Match any network id
    }
  }
};

这个例子展示了该配置文件可以配置网络环境,暂先到这,以后遇上了针对该配置文件进行研究。

初始化文件解释7:.placeholder

This is a placeholder file to ensure the parent directory in the git repository. Feel free to remove.

翻译过来就是:placeholder文件是用来保证在git库中父级目录的,可以删除。

初始化文件解释8:metacoin.js

和下面的文件一样,他们的功能都是用来做单元测试的,truffle在编译期间会自动执行这些测试脚本。当前文件为js版本,模拟用户在DApp客户端用户界面操作的情形。

var MetaCoin = artifacts.require("./MetaCoin.sol"); // 这与1_initial_migration.js文件的头是一样的,引入了一个智能合约文件。

contract('MetaCoin', function(accounts) {
  it("should put 10000 MetaCoin in the first account", function() {
    return MetaCoin.deployed().then(function(instance) {
      return instance.getBalance.call(accounts[0]);
    }).then(function(balance) {
      assert.equal(balance.valueOf(), 10000, "10000 wasn't in the first account");
    });
  });
  it("should call a function that depends on a linked library", function() {
    var meta;
    var metaCoinBalance;
    var metaCoinEthBalance;

    return MetaCoin.deployed().then(function(instance) {
      meta = instance;
      return meta.getBalance.call(accounts[0]);
    }).then(function(outCoinBalance) {
      metaCoinBalance = outCoinBalance.toNumber();
      return meta.getBalanceInEth.call(accounts[0]);
    }).then(function(outCoinBalanceEth) {
      metaCoinEthBalance = outCoinBalanceEth.toNumber();
    }).then(function() {
      assert.equal(metaCoinEthBalance, 2 * metaCoinBalance, "Library function returned unexpected function, linkage may be broken");
    });
  });
  it("should send coin correctly", function() {
    var meta;

    // Get initial balances of first and second account.
    var account_one = accounts[0];
    var account_two = accounts[1];

    var account_one_starting_balance;
    var account_two_starting_balance;
    var account_one_ending_balance;
    var account_two_ending_balance;

    var amount = 10;

    return MetaCoin.deployed().then(function(instance) {
      meta = instance;
      return meta.getBalance.call(account_one);
    }).then(function(balance) {
      account_one_starting_balance = balance.toNumber();
      return meta.getBalance.call(account_two);
    }).then(function(balance) {
      account_two_starting_balance = balance.toNumber();
      return meta.sendCoin(account_two, amount, {from: account_one});
    }).then(function() {
      return meta.getBalance.call(account_one);
    }).then(function(balance) {
      account_one_ending_balance = balance.toNumber();
      return meta.getBalance.call(account_two);
    }).then(function(balance) {
      account_two_ending_balance = balance.toNumber();

      assert.equal(account_one_ending_balance, account_one_starting_balance - amount, "Amount wasn't correctly taken from the sender");
      assert.equal(account_two_ending_balance, account_two_starting_balance + amount, "Amount wasn't correctly sent to the receiver");
    });
  });
});

我们来分析一波这个truffle metacoin js版本的单元测试:

  1. 直接函数contract走起,第一个参数为智能合约名字,第二个参数为匿名内部函数
  2. 匿名函数传入了当前账户地址,函数体是单元测试集
  3. 每个单元测试是由关键字it函数来做,第一个参数传入单元测试的comments,第二个参数传入一个无参匿名函数
  4. 进到无参匿名函数的函数体内,就是正式的单元测试内容,可以定义自己的成员属性,通过调用truffle内部组件自动部署合约逐一测试,使用成员属性接收返回值,最后使用关键字assert来判断是否符合预期。具体业务不详细展开,可根据自己业务内容随意更改。

这是官方文档,详细说明如何使用JS来编写智能合约的单元测试

初始化文件解释9:TestMetacoin.sol

好下面来看看Solidity智能合约版本的单元测试。一般来讲,这种文件的命名规则是Test加待测智能合约的名字拼串组成。

pragma solidity ^0.4.2;

import "truffle/Assert.sol";
import "truffle/DeployedAddresses.sol";
import "../contracts/MetaCoin.sol";

contract TestMetacoin {

  function testInitialBalanceUsingDeployedContract() public {
    MetaCoin meta = MetaCoin(DeployedAddresses.MetaCoin());

    uint expected = 10000;

    Assert.equal(meta.getBalance(tx.origin), expected, "Owner should have 10000 MetaCoin initially");
  }

  function testInitialBalanceWithNewMetaCoin() public {
    MetaCoin meta = new MetaCoin();

    uint expected = 10000;

    Assert.equal(meta.getBalance(tx.origin), expected, "Owner should have 10000 MetaCoin initially");
  }

}

继续分析:

  • 首先import了truffle的几个类库,用来支持我们接下来的测试内容。然后import了待测智能合约。
  • 建立单元测试智能合约,根据合约不同方法定义对应的test测试方法。
  • 方法体内部去调用待测智能合约的方法,传参接收返回值,然后使用关键字assert判断是否符合预期。

这是官方文档,详细说明如何使用Solidity来编写智能合约的单元测试

编译合约

键入

truffle compile

输出情况:

liuwenbin@liuwenbin-H81M-DS2:~/work/truffle-workspace/MetaCoin$ truffle compile
Compiling ./contracts/ConvertLib.sol...
Compiling ./contracts/MetaCoin.sol...
Compiling ./contracts/Migrations.sol...
Writing artifacts to ./build/contracts

根据编译输出的路径地址./build/contracts,我们去查看一下

liuwenbin@liuwenbin-H81M-DS2:~/work/truffle-workspace/build/contracts$ ls
ConvertLib.json  MetaCoin.json  Migrations.json

可以看到原来所在在contracts目录下的智能合约文件(有合约contract,有库library)均被编译成了json文件。

这些json文件就是truffle用来部署合约的编译文件,这与上面通过Remix编译的WEB3DEPLOY的js代码段不同。

部署合约

移植,对这里叫移植,但下面我们仍使用“部署”这个词,truffle中部署的命令为:

truffle migrate

这里遇到的问题较多,我来一一解决:

问题1:启动本地以太坊客户端结点

以太坊客户端有很多,truffle自己就有一个ganache,但我没安装成功,下面列举一下:

  1. Geth (go-ethereum)也就是我们之前到现在一直在介绍的。
  2. WebThree (cpp-ethereum): C++版本,我们一直在使用的是上面的Go版本。
  3. Parity
  4. More: https://www.ethereum.org/cli

当然了,我们还是继续使用geth,仍旧使用上面介绍过的启动命令启动

geth --datadir testNet --dev console 2>>Document/someLogs/testGeth.log

问题2:配置truffle.js

上文说到了,truffle.js是truffle的配置文件,启动好以太坊本地结点以后,我们需要让truffle去识别它并使用它,这就需要在truffle.js中配置相关属性:

module.exports = {
  networks: {
    development: {
      host: "127.0.0.1",
      port: 8545,
      network_id: "*" // Match any network id
    }
  }
};

问题3:修改geth启动命令与truffle.js配置文件

以上两个问题解决以后,我们使用truffle migrate来部署,terminal报错:

liuwenbin@liuwenbin-H81M-DS2:~/work/truffle-workspace/MetaCoin$ truffle migrate
Could not connect to your Ethereum client. Please check that your Ethereum client:
    - is running
    - is accepting RPC connections (i.e., "--rpc" option is used in geth)
    - is accessible over the network
    - is properly configured in your Truffle configuration file (truffle.js)

错误信息很清楚,直接增加一个参数--rpc,最终修改我们的启动命令为:

geth --datadir testNet --dev --rpc console 2>>Document/someLogs/testGeth.log

继续使用truffle migrate来部署,terminal及继续报错:

Error: exceeds block gas limit

去truffle github issues中查找,找到一行解决办法,粘贴如下:

Possibility: you're giving the transaction too high of a gasLimit. If the transaction has a limit of 2,000,000, it'd stop you since it could theoretically go over the block gas limit, even if in practice it won't. If this is the case, see if you can reduce the transaction's gasLimit while remaining above the amount it actually needs--that might do the trick.

好,我们再修改一下truffle.js如下:

module.exports = {
  networks: {
    development: {
      host: "127.0.0.1",
      port: 8545,
      network_id: "*", // Match any network id
      gas:500000
    }
  }
};

继续执行truffle migrate,执行成功。

liuwenbin@liuwenbin-H81M-DS2:~/work/truffle-workspace/MetaCoin$ truffle migrate
Using network 'development'.

Running migration: 1_initial_migration.js
  Deploying Migrations...
  ... 0x2adf8c421a2814ea4d5f1a211048ac64c47f6fcf64a1418dd4abc463d604d8fc


此时terminal处于监听状态,我们先不管他,下面请转到“IDE cooking steps”章节会给出解释。

去看一下Documents/someLogs/testGeth.log文件:

INFO [02-08|14:59:39] Submitted contract creation              fullhash=0x2adf8c421a2814ea4d5f1a211048ac64c47f6fcf64a1418dd4abc463d604d8fc contract=0xc8B95403276e5B4482718803C25A449743d59755
INFO [02-08|14:59:39] Commit new mining work                   number=23 txs=1 uncles=0 elapsed=351.917µs
INFO [02-08|14:59:39] Successfully sealed new block            number=23 hash=b97b83…b19548
INFO [02-08|14:59:39] 🔨 mined potential block                  number=23 hash=b97b83…b19548

我截取到了日志文件中以上的部分,可以看到,我们的智能合约已经被成功部署了,且日志中的hash值与上面监听状态的terminal中显式的是相同的,说明是一致的。下面我们就可以在终端使用该智能合约了。

测试合约

上面我们介绍了智能合约的单元测试的写法,包括js版本和Solidity版本,我们也知道在执行编译时会自动执行这些单元测试,如果有一个测试未通过则会中断编译过程。而在开发阶段,我们也可以自己使用命令来测试。

truffle test

没有报错就说明通过了,绿条,有报错就会打印在下方。

使用truffle开发智能合约

经过上面truffle metacoin环境模板的搭建,我们整个智能合约的开发、编译、部署以及运行环境就搭建好了。下面我们用这套环境来重现最初的helloworld智能合约。

创建工程

首先创建我们的工程Helloworld:

liuwenbin@liuwenbin-H81M-DS2:~/work/truffle-workspace$ mkdir helloworld && cd helloworld
liuwenbin@liuwenbin-H81M-DS2:~/work/truffle-workspace/helloworld$ truffle init
Downloading...
Unpacking...
Setting up...
Unbox successful. Sweet!

Commands:

  Compile:        truffle compile
  Migrate:        truffle migrate
  Test contracts: truffle test
liuwenbin@liuwenbin-H81M-DS2:~/work/truffle-workspace/helloworld$ ls
contracts  migrations  test  truffle-config.js  truffle.js

IDE cooking steps

  • 仍旧使用goLand + Solidity插件
  • *导入项目truffle-workspace/helloworld
  • 在contracts目录下新建一个智能合约文件Helloworld.sol(注意要与工程名相同,同时最好都首字母大写),输入与上文相同的内容。
  • *然后在IDE内部打开一个terminal,启动EVM
liuwenbin@liuwenbin-H81M-DS2:~$ geth --datadir testNet --dev --rpc console
  • *将上面的truffle.js配置内容粘贴到当前工程的truffle.js配置文件中
  • 新增一个helloworld的部署文件“2_deploy_helloworld.js”到migrations目录下,添加对helloworld智能合约的部署配置。
  • 再开启一个terminal,执行truffle compile, truffle migrate。

WARN: 这一步遇到问题,上面所谓监听状态实际上是卡住了,我们的智能合约并未部署成功,虽然在EVM中已经写入了块,但是无法识别该合约对象。理想状态下我们可以调用合约对象了,这个流程就全通了,但是没事,我去继续查一下解决方案。


采用客户端ganache代替geth

上文说明了这些原因,我也在官网下载了ganache,这是一个AppImage文件,这个文件在linux系统可以直接启动,首先我们需要将它的执行权限修改一下,然后启动即可。

chmod a+x exampleName.AppImage

启动以后,可以看到这个界面。
image

很丰满。

我想到一个事情,这里重申一下:我目前的测试开发环境,如果没有交易产生,挖矿不会自动进行。对于比特币和以太坊的正式环境来说,他们会限制出块时间,因为现在他们的交易量都很大,交易就会被拖慢,而不会产生没有交易,到了固定时间就要出个空块的情况。不过也有特例,因为共识算法加上对出块时间的限制,是有可能出现空块的。这很浪费,不过就我目前来看,算是留个思考题吧。

我们应该都可以直观的看懂,然后我们将它的网络配置到工程的truffle.js中去。

我们仍旧可以使用命令“geth attach http://localhost:7545” ,从geth命令行attach到这个ganache EVM网络中去。

truffle migrate

配置完成后,继续执行以上命令,可以看到不再发生以上被卡住的情况了,但是不识别我的Helloworld智能合约:

Error: Could not find artifacts for Helloworld.sol from any sources

继续探索...

解决方案:居然是我的contract 名字不匹配的原因,因为我当时想统一将工程名、合约文件名都改为首字母大写,但忘记该合约文件内部的contract后面的名字了,以及构造函数,这就像你改了java的类文件名,但没有该内部类名一样,可惜goland的Solidity插件并未报错啊,害的我找了半天,不过以后还是要靠自己多注意了。

但是,仍然有问题:

Error encountered, bailing. Network state unknown. Review successful transactions manually.

应该是truffle.js中网络配置的问题。

继续探索...

解决方案:哥们定睛一看,在上面这个表明看起来的error面前,不要先入为主,下面还有一行报错信息:

Error: Helloworld contract constructor expected 1 arguments, received 0

原来是我的合约内部有问题,我们通过truffle部署的时候不知道如何去给构造函数赋值,当时我们使用Remix的时候是手动修改的WEB3DEPLOY的js代码段,这里我就直接在合约代码中修改吧,最后是这样:

pragma solidity ^0.4.0;

contract Helloworld {
    string content;

    function Helloworld() public {
        content = "hello, world!";
    }

    function getContent() constant public returns (string){
        return content;
    }
}

多谢博友moqiang02的友情提示,这里可以在部署时进行构造函数的赋值,不必修改智能合约内容:在2_deploy_contracts.js中,修改deploy脚本,“deployer.deploy(Helloworld,"hello, world!");”即可。下面所有流程不影响,继续

truffle migrate!

liuwenbin@liuwenbin-H81M-DS2:~/work/truffle-workspace/Helloworld$ truffle migrate
Using network 'development'.

Running migration: 2_deploy_contracts.js
  Deploying Helloworld...
  ... 0x391f2c060b1f9cbe7b42493fc858ffa455d40f6e9af754a105092a9ac32e53c3
  Helloworld: 0x2c2b9c9a4a25e24b174f26114e8926a9f2128fe4
Saving successful migration to network...
  ... 0x0e8fab8924d93f0b17aa1c9dc58b976089a61e4debcd185dffa2c16e5cc539e9
Saving artifacts...
liuwenbin@liuwenbin-H81M-DS2:~/work/truffle-workspace/Helloworld$ 

成功!

对比ganache日志来看:

[5:24:49 PM]   Transaction: 0x391f2c060b1f9cbe7b42493fc858ffa455d40f6e9af754a105092a9ac32e53c3
[5:24:49 PM]   Contract created: 0x2c2b9c9a4a25e24b174f26114e8926a9f2128fe4
[5:24:49 PM]   Gas usage: 205611
[5:24:49 PM]   Block Number: 7
[5:24:49 PM]   Block Time: Thu Feb 08 2018 17:24:49 GMT+0800 (CST)
[5:24:49 PM]   Transaction: 0x0e8fab8924d93f0b17aa1c9dc58b976089a61e4debcd185dffa2c16e5cc539e9
[5:24:49 PM]   Gas usage: 26981
[5:24:49 PM]   Block Number: 8
[5:24:49 PM]   Block Time: Thu Feb 08 2018 17:24:49 GMT+0800 (CST)

可以看到我们通过truffle部署一个智能合约,要提交两个块,有两笔交易产生。为什么呢?

因为第一笔交易是来自与Helloworld.sol的创建,第二笔交易是来自于migration,每次部署一个新的合约都要执行这两步。

部署成功以后,我们可以得到合约的地址:0x2c2b9c9a4a25e24b174f26114e8926a9f2128fe4,后面会使用这个地址来实现与合约的交互。

与合约交互

geth 方式

此时如果我们直接geth attach到ganache本地环境中,无法与合约实现交互。因为目前虽然我们在EVM中创建了一个合约,但未在基于web3js的geth中注册合约对象,

geth 中是通过abi来注册合约对象的。

首先我们找到build/contracts/Helloworld.json中的abi的value,通过json压缩成一行,

abi = [{"inputs":[{"name":"con","type":"string"}],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":false,"name":"arg","type":"string"}],"name":"GetGreeting","type":"event"},{"constant":true,"inputs":[],"name":"getContent","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"}]

然后注册合约对象:

hello = eth.contract(abi).at('0x2c2b9c9a4a25e24b174f26114e8926a9f2128fe4')

对象注册成功以后,就可以像正常合约那样去调用了。

> hello.getContent()
"hello, world!"

truffle console

truffle框架没有直接使用abi,而是为我们封装提供了更加方便的调用方式。

  • truffle console,一个基本的交互控制台,可以连接任何EVM客户端。如果你已经有了自己的ganache或者geth等EVM的本地环境,那么就可以使用truffle console来交互,所以如果你已经有一个现成的小组共享的开发用EVM,那么使用这个没错。
  • truffle develop,一个交互控制台,启动时会自动生成一个开发用区块链环境(其实我认为它与ganache就是一个底层实现机制,都是默认生成10个账户)。如果你没有自己的EVM环境的话,直接使用truffle develop非常方便。

我虽然希望能够得到大一统的简单编写的开发测试环境,但是我并不愿意使用develop模式,下面我们使用console模式来与刚刚部署的Helloworld智能合约进行交互。

truffle console

执行以后,我们可以敲出Helloworld了,打印出一个json结构,展示了它的各种属性内容。它是一个TruffleContract,内容非常多。

tip: 上面提到过Solidity的event语法,里面展示了如果针对未使用event的智能合约,要通过var returnValue = exampleContract.foo.call(2);// 通过web3 的message的call来调用。

我们的Helloworld合约并未使用event方法,所以让我尝试一下这种方式来调取:

truffle(development)> Helloworld.at("0x2c2b9c9a4a25e24b174f26114e8926a9f2128fe4").getContent.call()
'hello, world!'

此刻的心情真是扬眉吐气,从来没有一次这么艰难的“helloworld”历程!

调试合约

truffle debug我还没来得及体验,先使用Remix吧,等我日后体验完觉得它不错我再来补充。Remix的debug其实还不错,不过很多人好像用不明白。我这里简单介绍一下吧,当你编写完一个智能合约以后,一般它会自动帮你编译,并且会在下方展示出你的属性,方法(如果没有的话,请尝试去交易的位置把交易和gas配置一下即可),然后点击其中你想调试的方法(注意入参),在控制台会打印出它的执行过程,同时右侧会有一个“debug”的小按钮,点击它(注意要预先在代码中设置断点),然后就可以按行调试了,随着一行行的运行,属性变量的值也会有所改变。

总结

今天是2017农历最后一个工作日,此时周围早已心飞扬的同事们呼呼啦啦地走光了,我刚刚完成了这篇文章,孤零零的我却满腹成就感。本篇文章仍旧采取我的以往习惯,采用主线分支的路线,详细介绍了如何开发一个智能合约,这里面把我这一条路线上遇到的所有的坑都趟过了,重点研究了Solidity的语法(当然并不是全面的,我只研究相关的了),智能合约的开发环境,各种新鲜工具的使用,最后着重介绍了智能合约的大杀器——truffle。希望能够对您有所帮助,一起努力!

参考资料

基本全部来自于各种官方文档,stackoverflow,askUbuntu,github issues等网站,没有实体书,这种新知识实体书永远是滞后太多的。

更多文章请转到醒者呆的博客园

posted @ 2018-02-08 18:32  一面千人  阅读(20477)  评论(11编辑  收藏  举报