以太坊私有链搭建

用Go-Ethereum搭建私有链并部署智能合约

Posted by Nino Lau on November 4, 2018

此文是区块链项目的期末 🔥 热身报告,用来帮助我们熟悉以太坊开发环境。实验主要包括:

  • 以太坊的安装、私有链创世区块的搭建;
  • block 各个字段和日志的输出解释;
  • 在私有链部署简单的智能合约;
  • 对交易字段进行解释。

以太坊的安装

我是在 Mac 上搭建的 Ethereum 环境,搭建的过程很简单:

brew tap ethereum/ethereum
brew install ethereum
geth --help 

如果能成功显示输出帮助,则表示已经成功安装。

私有链环境搭建

配置文件

以太坊支持自定义创世区块,要运行私有链,我们就需要定义自己的创世区块,创世区块信息写在一个 json 格式的配置文件中。首先将下面的内容保存到一个 json 文件中,例如 genesis.json。(直接从官网复制,“chainId” 为 0, 但是会在合约部署出现问题,所以在这里改为 666

{
  "config": {
        "chainId": 666,
        "homesteadBlock": 0,
        "eip155Block": 0,
        "eip158Block": 0
    },
  "alloc"      : {},
  "coinbase"   : "0x0000000000000000000000000000000000000000",
  "difficulty" : "0x00000001",
  "extraData"  : "",
  "gasLimit"   : "0xffffff",
  "nonce"      : "0x0000000000000042",
  "mixhash"    : "0x0000000000000000000000000000000000000000000000000000000000000000",
  "parentHash" : "0x0000000000000000000000000000000000000000000000000000000000000000",
  "timestamp"  : "0x00"
}

初始化私有链

准备好创世区块配置文件后,需要初始化区块链,将上面的创世区块信息写入到区块链中。首先要新建一个目录用来存放区块链数据,假设新建的数据目录为~/nino/shuju,genesis.json 保存在~/nino中,此时目录结构应该是这样的:

nino
├── shuju
└── genesis.json

进入 ~/nino 目录下,执行命令:

 geth -datadir shuju init genesis.json

上面的命令的主体是 geth init,表示初始化区块链,命令可以带有选项和参数,其中--datadir选项后面跟一个目录名,这里为 shuju,表示指定数据存放目录为 shujugenesis.jsoninit命令的参数。运行上面的命令,会读取 genesis.json 文件,根据其中的内容,将创世区块写入到区块链中。如果看到以下的输出内容,说明初始化成功了。

WARN [11-04|10:34:14] No etherbase set and no accounts found as default 
INFO [11-04|10:34:14] Allocated cache and file handles         database=/Users/nino/shuju/geth/chaindata cache=16 handles=16
INFO [11-04|10:34:14] Writing custom genesis block 
INFO [11-04|10:34:14] Successfully wrote genesis state         database=chaindata                                           hash=5e1fc7d790e0
INFO [11-04|10:34:14] Allocated cache and file handles         database=/Users/nino/shuju/geth/lightchaindata cache=16 handles=16
INFO [11-04|10:34:14] Writing custom genesis block 
INFO [11-04|10:34:14] Successfully wrote genesis state         database=lightchaindata                                           hash=5e1fc7d790e0

初始化成功后,会在数据目录~/nino/shuju 中生成 geth 和 keystore 两个文件夹。(其中geth/chaindata中存放的是区块数据,keystore中存放的是账户数据)

nino
├── shuju
│   ├── geth
│   │   └── chaindata
│   │       ├── 000002.ldb
│   │       ├── 000003.log
│   │       ├── CURRENT
│   │       ├── LOCK
│   │       ├── LOG
│   │       └── MANIFEST-000004
│   └── keystore
└── genesis.json

启动私有链

初始化完成后,就有了一条自己的私有链,之后就可以启动自己的私有链节点并做一些操作,在终端中输入以下命令即可启动节点:

geth -datadir shuju -networkid 2018 -rpc -rpcaddr 你的IP -rpccorsdomain "*" console

上面命令的主体是geth console,表示启动节点并进入交互式控制台,--datadir选项指定使用data0作为数据目录,--networkid选项后面跟一个数字 2018,表示指定这个私有链的网络id为2018。网络id在连接到其他节点的时候会用到,以太坊公网的网络 id 是1,为了不与公有链网络冲突,运行私有链节点的时候要指定自己的网络 id。运行上面的命令后,就启动了区块链节点并进入了Javascript Console:

INFO [11-04|16:51:13.102] Starting P2P networking 
INFO [11-04|16:51:15.224] UDP listener up                          self=enode://1d464c3eeff748edaf49ff582c4face5a42be81ec6be37d2044c408935dbb76d95a2bb66b38fcf874cb59a124238cd00c86ce0fad96499490ef24f573272cee5@[::]:30303
INFO [11-04|16:51:15.225] RLPx listener up                         self=enode://1d464c3eeff748edaf49ff582c4face5a42be81ec6be37d2044c408935dbb76d95a2bb66b38fcf874cb59a124238cd00c86ce0fad96499490ef24f573272cee5@[::]:30303
INFO [11-04|16:51:15.227] IPC endpoint opened                      url=/Users/nino/shuju/geth.ipc
INFO [11-04|16:51:15.227] HTTP endpoint opened                     url=http://172.19.93.219:8545  cors=* vhosts=localhost
Welcome to the Geth JavaScript console!

instance: Geth/v1.8.14-stable/darwin-amd64/go1.11
INFO [11-04|16:51:15.314] Etherbase automatically configured       address=0x78d2E183cb87bEb22fb8d09ef34675F8bbAc8e15
coinbase: 0x78d2e183cb87beb22fb8d09ef34675f8bbac8e15
at block: 95 (Sun, 04 Nov 2018 16:27:03 CST)
 datadir: /Users/nino/shuju
 modules: admin:1.0 debug:1.0 eth:1.0 ethash:1.0 miner:1.0 net:1.0 personal:1.0 rpc:1.0 txpool:1.0 web3:1.0

这是一个交互式的 Javascript 执行环境,在这里面可以执行 Javascript 代码,其中 > 是命令提示符。在这个环境里也内置了一些用来操作以太坊的 Javascript 对象,可以直接使用这些对象。这些对象主要包括:

对象 用途
eth 包含一些跟操作区块链相关的方法
net 包含以下查看 P2P 网络状态的方法
admin 包含一些与管理节点相关的方法
miner 包含启动、停止挖矿的一些方法
personal 主要包含一些管理账户的方法
txpool 包含一些查看交易内存池的方法
web3 包含了以上对象,还包含一些单位换算的方法

私有链节点操作

创建账户

刚搭建好的私有链是没有账户的,输入:

> eth.accounts

发现账户为空,接下来使用personal对象来创建一个账户:

> personal.newAccount("账户密码")

我们通过这种方法再创建账户:

> eth.accounts
["0x78d2e183cb87beb22fb8d09ef34675f8bbac8e15", "0x6f1f96c9131ad12f1ddcd9d195fdce6d0790b063"]

账户默认会保存在数据目录的 keystore 文件夹中。查看目录结构,发现shuju/keystore中多了两个文件(分别对应于两个账户),这两个文件就对应刚才创建的两个账户,这是 json 格式的文本文件,可以打开查看,里面存的是私钥经过密码加密后的信息。比如 “…8e15” :

{"address":"78d2e183cb87beb22fb8d09ef34675f8bbac8e15","crypto":{"cipher":"aes-128-ctr","ciphertext":"c1a6c65452655416e0dde61ac1e45c7a98c36d224d9a6ce2b688bb64d44c00f0","cipherparams":{"iv":"57cc60fb258e5e2bfc0adef32ed18324"},"kdf":"scrypt","kdfparams":{"dklen":32,"n":262144,"p":1,"r":8,"salt":"fb65ff0c0dc5bf41f27897b12ce617c5cc971ea3ab809bb0105a33c374d7f3bc"},"mac":"aee378c2c16e566eb569f8da2ea2d2edc75f6d772e9dd276d6e1507e402494da"},"id":"515b2084-8f92-4053-9e5b-15ce917a66a3","version":3}

此时的目录结构如下:

shuju
├── geth
│   ├── chaindata
│   ├── LOCK
│   ├── nodekey
│   └── nodes
├── geth.ipc
├── history
└── keystore
    ├── UTC--2018-11-04T08-23-15.866675000Z--78d2e183cb87beb22fb8d09ef34675f8bbac8e15
    └── UTC--2018-11-04T09-03-50.634292000Z--6f1f96c9131ad12f1ddcd9d195fdce6d0790b063

查看余额

查看账户余额:

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

目前两个账户的以太币余额都是0,要使账户有余额,可以从其他账户转账过来,或者通过挖矿来获得以太币奖励。

节点挖矿

启动挖矿:

> miner.start(1)

其中start的参数表示挖矿使用的线程数。第一次启动挖矿会先生成挖矿所需的 DAG 文件,这个过程有点慢,等进度达到100%后,就会开始挖矿,此时屏幕会被挖矿信息刷屏。

如果想停止挖矿,在 js console中输入:

> miner.stop()

输入的字符会被挖矿刷屏信息冲掉,没有关系,只要输入完整的miner.stop()之后回车,即可停止挖矿。

挖到一个区块会奖励5个以太币,挖矿所得的奖励会进入矿工的账户,这个账户叫做coinbase,默认情况下 coinbase 是本地账户中的第一个账户:

> eth.coinbase
"0x78d2e183cb87beb22fb8d09ef34675f8bbac8e15"

挖到区块以后,账户 0 里面应该就有余额了:

> eth.getBalance(eth.accounts[0])
475000000000000000000

getBalance()返回值的单位是weiwei是以太币的最小单位,1个以太币=10的18次方个wei。要查看有多少个以太币,可以用web3.fromWei()将返回值换算成以太币:

> web3.fromWei(eth.getBalance(eth.accounts[0]),'ether')
475

发起交易

账户每隔一段时间就会被锁住,要发送交易,必须先解锁账户。由于我们要从账户 0 发送交易,所以要解锁账户 0:

> personal.unlockAccount(eth.accounts[0])
Unlock account 0x78d2e183cb87beb22fb8d09ef34675f8bbac8e15
Passphrase: 
true

可以通过发送一笔交易,从账户 0 转移 5 个以太币到账户 1(⚠️ 最后显示的交易 ID 十分重要,我们将会用这个 ID 来查找交易):

> amount = web3.toWei(5,'ether')
"5000000000000000000"
> eth.sendTransaction({from:eth.accounts[0],to:eth.accounts[1],value:amount})
INFO [11-04|17:28:06.737] Setting new local account                address=0x78d2E183cb87bEb22fb8d09ef34675F8bbAc8e15
INFO [11-04|17:28:06.738] Submitted transaction                    fullhash=0x67ef39ca8d8c8bd3dec2e96342a3ccffaaa3385140a0d8ded4c24b20b3be854d recipient=0x6F1f96c9131aD12f1dDcd9D195Fdce6d0790b063
"0x67ef39ca8d8c8bd3dec2e96342a3ccffaaa3385140a0d8ded4c24b20b3be854d"

此时交易已经提交到区块链,返回了交易的 hash,但还未被处理,这可以通过查看 txpool 来验证:

> txpool.status
{
  pending: 1,
  queued: 0
}

其中有一条 pending 的交易,pending 表示已提交但还未被处理的交易。

确认交易

要使交易被处理,必须要挖矿。这里我们启动挖矿,然后等待挖到一个区块之后就停止挖矿:

> miner.start(1); admin.sleepBlocks(1); miner.stop();

如下显示表示挖矿成功:

INFO [11-04|17:31:12.566] Updated mining threads                   threads=1
INFO [11-04|17:31:12.566] Transaction pool price threshold updated price=18000000000
INFO [11-04|17:31:12.572] Commit new mining work                   number=96 uncles=0 txs=0 gas=0 fees=0 elapsed=6.319ms
INFO [11-04|17:31:12.573] Commit new mining work                   number=96 uncles=0 txs=1 gas=21000 fees=0.000378 elapsed=6.867ms
INFO [11-04|17:31:16.781] Successfully sealed new block            number=96 hash=ddc2bb…215191 elapsed=4.208s
INFO [11-04|17:31:16.782] 🔨 mined potential block                  number=96 hash=ddc2bb…215191
INFO [11-04|17:31:16.782] Commit new mining work                   number=97 uncles=0 txs=0 gas=0     fees=0        elapsed=171.356µs
true

txpool中pending的交易数量应该为0了,说明交易已经被处理了:

> txpool.status
{
  pending: 0,
  queued: 0
}

此时,交易已经生效,账户一应该已经收到了5个以太币了:

> web3.fromWei(eth.getBalance(eth.accounts[1]),'ether')
5

区块数量

查看当前区块总数:

> eth.blockNumber
96

所以说目前我们的私有链上有 96 个区块。

查看交易

通过交易hash查看交易:

> eth.getTransaction("0x67ef39ca8d8c8bd3dec2e96342a3ccffaaa3385140a0d8ded4c24b20b3be854d")
{
  blockHash: "0xddc2bbbeaa686cc5c9c6b2d3d8a9f942a53a9051bc06e9d649e4de1e3a215191",
  blockNumber: 96,
  from: "0x78d2e183cb87beb22fb8d09ef34675f8bbac8e15",
  gas: 90000,
  gasPrice: 18000000000,
  hash: "0x67ef39ca8d8c8bd3dec2e96342a3ccffaaa3385140a0d8ded4c24b20b3be854d",
  input: "0x",
  nonce: 0,
  r: "0xb852d78a0483ba05a21e3701c74bd86ed2dd52867b59258170e7feb8ba3f0414",
  s: "0x3c752d3d485e1f7ed16bec78629be07bf377c4de9b041b11388263d943a66fd3",
  to: "0x6f1f96c9131ad12f1ddcd9d195fdce6d0790b063",
  transactionIndex: 0,
  v: "0x557",
  value: 5000000000000000000
}

交易中各个字段的含义

  • hash是交易的哈希值,nonce用来区别同一用户发出的不同交易的标记;
  • blockHash是封装事务(交易)的区块哈希值,blockNumber是该区块在我们构建的私有链的序号;
  • fromto 字段规定了交易的双方,value字段规定了交易的数额;
  • gas 表示这个交易允许消耗的最大 Gas 数量,gasPrice表示发送者愿意支付给矿工的 Gas 价格;
  • rsV 为交易签名的三个部分,由发送者的私钥对交易哈希进行签名生成。

查看区块

通过区块号查看区块:

> eth.getBlock(96) 
> eth.getBlock("0xddc2bbbeaa686cc5c9c6b2d3d8a9f942a53a9051bc06e9d649e4de1e3a215191")
{
  difficulty: 131072,
  extraData: "0xd78301080e846765746886676f312e31318664617277696e",
  gasLimit: 15275265,
  gasUsed: 21000,
  hash: "0xddc2bbbeaa686cc5c9c6b2d3d8a9f942a53a9051bc06e9d649e4de1e3a215191",
  logsBloom: "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
  miner: "0x78d2e183cb87beb22fb8d09ef34675f8bbac8e15",
  mixHash: "0x4b72e2f521d9a45d53920d0af22c575eeb2ad489bab41d0b509584b86bd096ff",
  nonce: "0x0dc5a55c24b91e34",
  number: 96,
  parentHash: "0xff22f6f888c08e290b292ad105b9c72b4ff206c57e78a8393d38a94b4ea6ddd3",
  receiptsRoot: "0xa45d92ea3058f83814c424a8d4bdf5c3cd65742545569c3f3ede75b2ca2e6be1",
  sha3Uncles: "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347",
  size: 651,
  stateRoot: "0x346e3b94dfaebfb891f5d9d41204b83ed173230fa9d6e62016e9f3453f7cbcaf",
  timestamp: 1541323872,
  totalDifficulty: 12844361,
  transactions: ["0x67ef39ca8d8c8bd3dec2e96342a3ccffaaa3385140a0d8ded4c24b20b3be854d"],
  transactionsRoot: "0x9fae89d7136c768294f0eb495f140308ee858f2e54de9b537f89970a4e917fee",
  uncles: []
}

区块中各个字段的含义

  • difficulty 是挖出该区块的难度,totalDifficulty是目前挖矿的难度积累, nonce 是用来 PoW 的无意义6位哈希数;
  • hash 是区块的哈希值,mixHash 256位 Hash 值,当与 nonce 组合时,证明此区块已经执行了足够的计算;
  • size为区块的大小,miner 挖掘出这个区块的作者地址;
  • number 区块的序号,等于其父区块的 number +1,timestamp 区块应被创建的时间戳,由共识算法确定;
  • gasLimit 区块的 gas 消耗的理论上限,gasUsed 是实际消耗的 gas 总和;

  • parentHash父区块的指针,uncles 叔区块 是用来对孤立区块进行奖励的一个 Header 数组,sha3Uncles叔区块 们的安全散列;
  • receiptsRoot Block 中的 “Receipt Trie”的根节点的 RLP 哈希值,Block 的所有 Transaction 执行完后会生成一个 Receipt 数组,这个数组中的所有 Receipt 被逐个插入一个 MPT 结构中,形成”Receipt Trie”;
  • stateRoot StateDB 中的“state Trie”的根节点的 RLP 哈希值,每个账户以 stateObject 对象表示,账户以 Address 为唯一标示,其信息在相关交易的执行中被修改,所有账户对象可以逐个插入一个Merkle Patrica Trie(MPT)结构里,形成“state Trie”;
  • transactionRoot Block 中 “tx Trie”的根节点的 RLP 哈希值,Block 的成员变量 transactions 中所有的 tx 对象,被逐个插入一个 MPT 结构,形成“tx Trie”;
  • logsBloom Bloom 过滤器,用来快速判断一个参数 Log 对象是否存在于一组已知的 Log 集合中;
  • extraData 区块创建者可以记录一些与该区块有关的信息,长度小于等于32字节即可。

智能合约

合约定义

用 Solidity 定义一个智能合约 Nino.propose()

pragma solidity ^0.4.18;

contract Nino {
    string word;
    
    function Nino(string _word) public {
        word = _word;
    }

    function propose() constant public returns (string) {
        return word;
    }
}

Remix 文本框复制这段代码,进行调试,编译无误后点击 Detail,

可以从弹出窗口找到 WEB3DEPLOY,把红线部分的话换成 Nino 的 propose:

部署合约

复制以上内容,因为部署合约本质上也是一种交易,在部署前需要把部署者 account[1] 解锁(Unlock)

> personal.unlockAccount(eth.accounts[1])
Unlock account 0x6f1f96c9131ad12f1ddcd9d195fdce6d0790b063
Passphrase: 
true

并把其输入 JS Console:

var _word = "I'm 16340154, please give me a high score";
var ninoContract = web3.eth.contract([{"constant":true,"inputs":[],"name":"propose","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"inputs":[{"name":"_word","type":"string"}],"payable":false,"stateMutability":"nonpayable","type":"constructor"}]);
var nino = ninoContract.new(
   _word,
   {
     from: web3.eth.accounts[1], 
     data: '0x608060405234801561001057600080fd5b506040516102a83803806102a8833981018060405281019080805182019291905050508060009080519060200190610049929190610050565b50506100f5565b828054600181600116156101000203166002900490600052602060002090601f016020900481019282601f1061009157805160ff19168380011785556100bf565b828001600101855582156100bf579182015b828111156100be5782518255916020019190600101906100a3565b5b5090506100cc91906100d0565b5090565b6100f291905b808211156100ee5760008160009055506001016100d6565b5090565b90565b6101a4806101046000396000f300608060405260043610610041576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff168063c198f8ba14610046575b600080fd5b34801561005257600080fd5b5061005b6100d6565b6040518080602001828103825283818151815260200191508051906020019080838360005b8381101561009b578082015181840152602081019050610080565b50505050905090810190601f1680156100c85780820380516001836020036101000a031916815260200191505b509250505060405180910390f35b606060008054600181600116156101000203166002900480601f01602080910402602001604051908101604052809291908181526020018280546001816001161561010002031660029004801561016e5780601f106101435761010080835404028352916020019161016e565b820191906000526020600020905b81548152906001019060200180831161015157829003601f168201915b50505050509050905600a165627a7a7230582001962db1111855ac0f746def769de3d5475e132807426af7c33f7abe7c417a060029', 
     gas: '4700000'
   }, function (e, contract){
    console.log(e, contract);
    if (typeof contract.address !== 'undefined') {
         console.log('Contract mined! address: ' + contract.address + ' transactionHash: ' + contract.transactionHash);
    }
 })

即使复制上去了之后,我们还不能着急运行,我们还要进行挖矿把 pending 的交易封装成块miner.start()让交易得到确认才能运行合约

> miner.start(1); admin.sleepBlocks(1); miner.stop();
INFO [11-04|20:06:17.040] Updated mining threads                   threads=1
INFO [11-04|20:06:17.040] Transaction pool price threshold updated price=18000000000
INFO [11-04|20:06:17.040] Commit new mining work                   number=162 uncles=0 txs=0 gas=0       fees=0          elapsed=124.139µs
INFO [11-04|20:06:17.041] Commit new mining work                   number=162 uncles=0 txs=1 gas=242793  fees=0.004370274 elapsed=517.207µs
INFO [11-04|20:06:18.184] Successfully sealed new block            number=162 hash=3bae749bb332 elapsed=1.143s
INFO [11-04|20:06:18.192] 🔗 block reached canonical chain          number=157 hash=787e989ebcc5
INFO [11-04|20:06:18.192] 🔨 mined potential block                  number=162 hash=3bae749bb332
INFO [11-04|20:06:18.192] Commit new mining work                   number=163 uncles=0 txs=0 gas=0       fees=0           elapsed=161.24µs
true
> null [object Object]

终于成功了 ✌️:

Contract mined! address: 0x0ea4925971b634f91310d8d1d61fad8734b9a09f transactionHash: 0xb2ac1920b1fddd2d6b032ac9204ac963a8eaf0e793a0b8803ce5433e8c85ae68

那么 Nino 的 proposal 到底是什么呢???🤔️

> nino.propose()
"I'm 16340154, please give me a high score"

*希望它能够实现,因为它为了部署这个合约可是花了钱呢 *💰:

> eth.getBalance(eth.accounts[1])
4979037452000000000 					// < 5000000000000000000

参考&鸣谢

“喝水不忘挖井人”,这里特别感谢一下几个博主的优秀博文: