区块链源码研读
未经本人同意,禁止转载
EbookChain亿书链详解
在亿书的源码中有一个logic的文件夹,放置的代码是:
account.js
block.js
transaction.js
可见,account, block, transaction这三个概念可以被理解成区块链中的三个基本单位
我们从这样一个顺序来了解区块链的工作过程与代码架构:
- 地址,即account和contact,也就是用户/账户的别名,还包含联系人,修改时与发起交易一样
- 加密与签名:crypto与signature,发起交易时通过签名与多重签名来加密信息,验证身份,即怎么加密,怎么利用加密进行签名
- 交易:transaction,发起一笔交易要经过哪些过程,以及交易怎么被打包进入区块
- 区块与链:blocks,区块之间是怎么生成的,怎么解决分叉的
- DPOS共识:delegate,区块生成的时候选出一个区块作为所有节点的同步区块
- P2P网络:peer与transport,发起的交易和生成的区块怎么广播到其它节点
地址
Account & Accounts & Contacts
用户角色account
比特币里的用户角色仅仅就是一个比特币地址,该地址是通过Hash算法进行加密处理的字符串,因此我们叫它Hash地址。同时,基于真实网络的复杂性,对于IP地址的追踪也不容易,所以比特币的匿名性很好
亿书作为一款加密货币产品,自然也提供了类似的Hash地址。并基于该地址,扩展提供了其他功能,比如“别名地址”。
- 一个地址能够直接浏览的信息主要包括余额(balance)、公钥(publicKey)、用户名(username)及修改用户名需要花费的费用(fee),以及受托人及其费用等,可以产生公钥和添加用户名。
- 但是,没有删除和修改用户名的功能,所以一旦注册了用户名,想要修改,只能重新注册一个。
比特币地址是使用前缀来区分的,比如:1开头的地址就是我们实际使用的地址,3开头的地址是测试地址。而亿书,使用后缀来区分,通常以L结尾。
Accounts.prototype.generateAddressByPublicKey = function (publicKey) {
var publicKeyHash = crypto.createHash('sha256').update(publicKey, 'hex').digest();
var temp = new Buffer(8);
for (var i = 0; i < 8; i++) {
temp[i] = publicKeyHash[7 - i];
}
var address = bignum.fromBuffer(temp).toString() + 'L';
if (!address) {
throw Error("wrong publicKey " + publicKey);
}
return address;
};
向某个account发起交易
发起交易SEND类型,即资金转账的功能(后台编码的交易类型TransactionTypes.SEND,见764行),毫无疑问,必须提供三个参数"secret", "amount", "recipientId"。从702行可知,其中recipientId 可以是字符串地址,也可以是用户名。从764行以后的代码还可以了解到,交易时的recipientId和recipientUsername字段都保存在数据库里了。
var transaction = library.logic.transaction.create({
// 764行
type: TransactionTypes.SEND,
amount: body.amount,
sender: account,
// 接收转账者
recipientId: recipientId,
recipientUsername: recipientUsername,
keypair: keypair,
requester: keypair,
secondKeypair: secondKeypair
});
修改account的Username
发起交易USERNAME类型,即注册用户,同时,用户要提供明文密码(secret)和用户名(username)。
var transaction = library.logic.transaction.create({
type: TransactionTypes.USERNAME,
username: body.username,
sender: account,
keypair: keypair,
secondKeypair: secondKeypair,
requester: keypair
});
Contacts联系人(Follow操作)
亿书具备社交功能,维护了一个联系人列表。与传统中心化软件不同的是,加密货币系统里,处处是交易,用户关注其他用户的行为,也是一项交易(内部的交易类型为TransactionTypes.FOLLOW)。所以follow一个人即添加为联系人,意味着发起一笔交易来follow
var transaction = library.logic.transaction.create({
type: TransactionTypes.FOLLOW,
sender: account,
keypair: keypair,
secondKeypair: secondKeypair,
contactAddress: followingAddress, // 511行
requester: keypair
});
添加关注需要两个参数"secret"和"following",这里"following"其实就是用户的帐号地址或用户名(见448行)。然后,经过一系列验证之后,写入数据库的contactAddress字段(见511行),一个关注的操作过程就完成了。
同理,获取联系人列表getContacts操作与之类似,但只是查询,并非发起交易,所以输入参数不需要secret进行签名验证。
总结
一个是生成加密货币的Hash地址,另一个是通过交易模块扩展和关联其他功能。其中,Hash地址是基础,在整个亿书的开发设计中,无处不在,签名、验证和交易,以及区块链等需要它。
加密与签名验证
Signatures & Multisignature & Crypto
公钥与私钥
基于非对称加密的特性:
- 私钥好比银行卡密码,公钥好比银行卡账户,账户谁都可以知道,但只有掌握私钥密码的人才能操作。不过,私钥和公钥更为贴心与先进,用私钥签名的信息,公钥可以认证确认,相反也可以。这就为网络传输和加密提供了便利。这就是“非对称加密”。
- 一个比特币地址就是一个公钥,在交易中,比特币地址通常以收款人出现。如果把比特币交易必作一张支票,比特币地址就是收款人,也就是我们要写上收款人一栏的内容。
- 私钥就是一个随机选出的数字而已,在比特币交易中,私钥用于生成支付比特币所必需的签名以证明资金的所有权,即地址中的所有资金的控制取决于相应私钥的所有权和控制权。
- Ebookcoin也是如此,只不过更加直接的把生成的公钥地址作为用户的ID,用作网络中的身份证明。更加强调用户应该仔细保存最初设定的长长的密码串,代替单纯的私钥保存
非对称加密算法需要两个 密钥: 公开密钥(publickey)和私有密钥(privatekey)。
公开密钥与私有密钥是一对,如果用公开密钥对数据进行加密,只有用对应的私有密钥才能解密;如果用私有密钥对数据进行加密,那么只有用对应的公开密钥才能解密。因为加密和解密使用的是两个不同的密钥,所以这种算法叫作非对称加密算法。
加密过程
Node.js的Crypto模块,提供了一种封装安全凭证的方式,用于HTTPS网络或HTTP连接,也对OpenSSL的Hash,HMAC,加密,解密、签名和验证方法进行了封装。
Ebookcoin使用的是sha256Hash算法(除此之外,还有MD5,sha1,sha512等),这是经过很多人验证的有效安全的算法之一(请看参考)。通过Crypto模块,简单加密生成一个哈希值:
var hash = crypto.createHash('sha256').update(data).digest()
这个语句拆开来看,就是crypto.createHash('sha256')先用sha256算法创建一个Hash实例;接着使用.update(data)接受明文data数据,最后调用.digest()方法,获得加密字符串,即密文。
然后,使用Ed25519组件,简单直接地生成对应密钥对:
var keypair = ed.MakeKeypair(hash);
解密验证过程
加密技术的作用,重在传输和验证。所以,加密货币并不需要研究如何解密原文。而是,如何安全、快捷的验证。Ebookcoin使用了Ed25519第三方组件。
该组件是一个数字签名算法。签名过程不依赖随机数生成器,没有时间通道攻击的问题,签名和公钥都很小。签名和验证的性能都极高,一个4核2.4GHz 的 Westmere cpu,每秒可以验证 71000 个签名,安全性极高,等价于RSA约3000bit。一行代码:
var res = ed.Verify(hash, signatureBuffer || ' ', publicKeyBuffer || ' ');
产生公钥
在Ebookcoin世界里,Ebookcoin把用户设定的密码生成私钥和公钥,再将公钥经过16进制字符串转换产生帐号ID(类似于比特币地址)。付款的时候,只要输入这个帐号ID(或用户别名)就是了。
- 该ID,长度通常是160⽐特(20字节),加上末尾的L后缀,也就是21字节长度。
生成公钥:
shared.generatePublickey = function (req, cb) {
var body = req.body;
library.scheme.validate(body, {
...
required: ["secret"]
}, function (err) {
...
// 644行
privated.openAccount(body.secret, function (err, account) {
...
cb(err, {
publicKey: publicKey
});
});
});
};
公钥生成地址,将用户密码进行加密处理,然后直接生成了密钥对,接着将公钥继续处理,generateAddressByPublicKey方法对公钥再一次加密,然后做16进制处理,得到所要地址:
// 447行
privated.openAccount = function (secret, cb) {
var hash = crypto.createHash('sha256').update(secret, 'utf8').digest();
var keypair = ed.MakeKeypair(hash);
self.setAccountAndGet({publicKey: keypair.publicKey.toString('hex')}, cb);
};
// 482行
Accounts.prototype.setAccountAndGet = function (data, cb) {
var address = data.address || null;
if (address === null) {
if (data.publicKey) {
// 486行
address = self.generateAddressByPublicKey(data.publicKey);
...
}
}
...
// 494行
library.logic.account.set(address, data, function (err) {
...
});
};
Accounts.prototype.generateAddressByPublicKey = function (publicKey) {
var publicKeyHash = crypto.createHash('sha256').update(publicKey, 'hex').digest();
var temp = new Buffer(8);
for (var i = 0; i < 8; i++) {
temp[i] = publicKeyHash[7 - i];
}
var address = bignum.fromBuffer(temp).toString() + 'L';
if (!address) {
throw Error("wrong publicKey " + publicKey);
}
return address;
};
各模块与Crypto加密模块的关系:
class.png交易
Transaction & Transactions & Types
交易的过程
比特币交易的定义是:把比特币从一个地址转到另一个地址。更准确地说,一笔“交易”就是一个经过签名运算的,表达价值转移的数据结构。每一笔“交易”都经过比特币网络传输,由矿工节点收集并封包至区块中,永久保存在区块链某处。
加密货币交易是指人们通过加密货币网络,把加密货币进行有效转移, 并把交易数据保存到区块链的过程。
加密货币的整个系统,都是为了确保正确地生成交易、快速地传播和验证交易,并最终写入全球交易总账簿——区块链而设计。因此,从开发设计角度考虑,一笔交易必须包括下列过程:
- 生成一笔交易。这里是指一条包含交易双方加密货币地址、数量、时间戳和有效签名等信息,而且不含任何私密信息的合法交易数据;
- 广播到网络。几乎每个节点都会获得这笔交易数据。
- 验证交易合法性。生成交易的节点和其他节点都要验证,没有得到验证的交易,是不能进入加密货币网络的。
- 写入区块链。
亿书交易类型
// helpers/transaction-types.js
module.exports = {
SEND : 0,
SIGNATURE : 1,
DELEGATE : 2,
VOTE : 3,
USERNAME : 4,
FOLLOW : 5,
MULTI: 6,
DAPP: 7,
IN_TRANSFER: 8,
OUT_TRANSFER: 9,
ARTICALE : 10,
EBOOK: 11,
BUY: 12,
READ: 13
}
SEND是最基本的转账交易,SIGNATURE是上一篇提到的“签名”交易,DELEGATE是注册为受托人,VOTE是投票,USERNAME是注册用户别名地址,FOLLOW是添加联系人,MULTI是注册多重签名帐号,DAPP是侧链应用,IN_TRANSFER是转入Dapp资金,OUT_TRANSFER转出Dapp资金,ARTICALE是发布文章,EBOOK是发布电子书,BUY是购买(电子书或其他商品),READ是付费阅读(电子书等)
交易基本流程
生成交易数据
一笔交易必须包含如下字段:
- 交易类型。代码里表示为 type: TransactionTypes.SEND;
- 支付帐号。代码里指的是 sender: account;
- 接受帐号。代码里指的是 recipientId: recipientId, 如果用的是别名地址,就是 recipientUsername: recipientUsername,如果是功能性交易,这里就不需要了;
- 交易数量。代码里指的是 amount: body.amount。
这些数据有的要求用户输入,比如用户密钥,交易数量等
给合法交易签名
一笔合法交易
- 还要使用甲乙方的公钥签名,确保交易所属。
- 同时,还要准确记录它的交易时间戳,方便追溯。
- 还要生成交易ID,每个交易ID都包含了丰富的加密信息,需要复杂的生成过程,绝不像传统的网站系统,让数据库自动生成索引就可以充当ID
验证交易合法性
这里的交易合法性,除了基本信息正确之外,主要是指保证交易是未确认的交易,也不是用户重复提交的交易,即双花交易。双花交易是加密货币特有的现象,通俗的说,就是用户在交易确认之前(有一段时间,比特币时间更长),又一次提交了相同交易信息,导致一笔钱花两次,这种情况是必须要避免的。
一笔交易经过6-10个区块之后,这笔交易被认为是无法更改的,即已确认,因为这时候拒绝、变更的难度已经非常大,理论上已经不可能。
每笔交易在广播到网络之前必须验证合法性,不合法的交易没有机会广播到网络。节点收到新的交易信息时,要重新验证,即一旦交易被广播到网络,在其他节点,这里的验证和处理过程就会重复执行一次。
广播交易到点对点网络
没有中心服务器,必须借助点对点网络,把交易数据写入分布式公共账本——区块链,保证交易数据永远无法篡改,而且可以轻松查询追溯。这在中心化的服务器上,为了应对个别交易摩擦,保证交易记录可追溯,要采取更多的技术手段,记录更多的数据字段,意味着要保持大量数据冗余,付出更多资金成本。
因为交易数据不含私密信息,对网络没有苛刻要求,因此加密货币的网络可以覆盖很广,对网络的编程也变得灵活很多。理论上,只要能保证联通的便捷和快速,具体设计中不需要考虑更多复杂的因素。
生成交易到广播前的代码实现
/ 652行
shared.addTransactions = function (req, cb) {
var body = req.body;
library.scheme.validate(body, {
type: "object",
properties: {
secret: {
type: "string",
minLength: 1,
maxLength: 100
},
amount: {
type: "integer",
minimum: 1,
maximum: constants.totalAmount
},
recipientId: {
type: "string",
minLength: 1
},
publicKey: {
type: "string",
format: "publicKey"
},
secondSecret: {
type: "string",
minLength: 1,
maxLength: 100
},
multisigAccountPublicKey: {
type: "string",
format: "publicKey"
}
},
//
required: ["secret", "amount", "recipientId"]
}, function (err) {
// 验证数据格式
if (err) {
return cb(err[0].message);
}
// 验证密码信息
var hash = crypto.createHash('sha256').update(body.secret, 'utf8').digest();
var keypair = ed.MakeKeypair(hash);
if (body.publicKey) {
if (keypair.publicKey.toString('hex') != body.publicKey) {
return cb("Invalid passphrase");
}
}
var query = {};
// 乙方(接收方)地址转换,保证可以用户名转账
var isAddress = /^[0-9]+[L|l]$/g;
if (isAddress.test(body.recipientId)) {
query.address = body.recipientId;
} else {
query.username = body.recipientId;
}
library.balancesSequence.add(function (cb) {
// 验证乙方用户合法性
modules.accounts.getAccount(query, function (err, recipient) {
if (err) {
return cb(err.toString());
}
if (!recipient && query.username) {
return cb("Recipient not found");
}
var recipientId = recipient ? recipient.address : body.recipientId;
var recipientUsername = recipient ? recipient.username : null;
// 验证甲方(发送方)用户合法性
if (body.multisigAccountPublicKey && body.multisigAccountPublicKey != keypair.publicKey.toString('hex')) {
// 验证多重签名
modules.accounts.getAccount({publicKey: body.multisigAccountPublicKey}, function (err, account) {
if (err) {
return cb(err.toString());
}
// 多重签名帐号不存在
if (!account || !account.publicKey) {
return cb("Multisignature account not found");
}
// 多重签名帐号未激活
if (!account || !account.multisignatures) {
return cb("Account does not have multisignatures enabled");
}
// 帐号不属于该多重签名组
if (account.multisignatures.indexOf(keypair.publicKey.toString('hex')) < 0) {
return cb("Account does not belong to multisignature group");
}
// 接着验证甲方(发送方)用户合法性
modules.accounts.getAccount({publicKey: keypair.publicKey}, function (err, requester) {
if (err) {
return cb(err.toString());
}
// 甲方帐号不存在
if (!requester || !requester.publicKey) {
return cb("Invalid requester");
}
// 甲方支付密码(二次签名)不正确
if (requester.secondSignature && !body.secondSecret) {
return cb("Invalid second passphrase");
}
// 甲方帐号公钥与多重签名帐号公钥是不一样的(因为两个账户是不一样的)
if (requester.publicKey == account.publicKey) {
return cb("Invalid requester");
}
var secondKeypair = null;
if (requester.secondSignature) {
var secondHash = crypto.createHash('sha256').update(body.secondSecret, 'utf8').digest();
secondKeypair = ed.MakeKeypair(secondHash);
}
try {
// 763行 把上述数据整理成需要的交易数据结构,并给交易添加时间戳、签名、生成ID、计算交易费等
var transaction = library.logic.transaction.create({
type: TransactionTypes.SEND,
amount: body.amount,
sender: account,
recipientId: recipientId,
recipientUsername: recipientUsername,
keypair: keypair,
requester: keypair,
secondKeypair: secondKeypair
});
} catch (e) {
return cb(e.toString());
}
// 776行 处理交易
modules.transactions.receiveTransactions([transaction], cb);
});
});
} else {
// 直接验证甲方(发送方)用户合法性,这里的请求者requester就是发出交易者sender
...
});
}
节点收到新交易的处理
// modules/transactions.js文件
// 337行
Transactions.prototype.processUnconfirmedTransaction = function (transaction, broadcast, cb) {
modules.accounts.setAccountAndGet({publicKey: transaction.senderPublicKey}, function (err, sender) {
// 这是个闭包,在下面的程序运行结束的时候才调用,因此是验证完毕,才写入区块链、广播到网络
function done(err) {
if (err) {
return cb(err);
}
// 这里 加入区块链 操作
privated.addUnconfirmedTransaction(transaction, sender, function (err) {
if (err) {
return cb(err);
}
// 触发事件,广播到网络
library.bus.message('unconfirmedTransaction', transaction, broadcast);
cb();
});
}
if (err) {
return done(err);
}
if (transaction.requesterPublicKey && sender && sender.multisignatures && sender.multisignatures.length) {
modules.accounts.getAccount({publicKey: transaction.requesterPublicKey}, function (err, requester) {
if (err) {
return done(err);
}
if (!requester) {
return cb("Invalid requester");
}
// 开始执行一系列验证,包括交易是不是已经存在
library.logic.transaction.process(transaction, sender, requester, function (err, transaction) {
if (err) {
return done(err);
}
// 检查是否交易已经存在(包括双花交易)
if (privated.unconfirmedTransactionsIdIndex[transaction.id] !== undefined || privated.doubleSpendingTransactions[transaction.id]) {
return cb("Transaction already exists");
}
// 这里是 直接验证交易签名等信息,接着调用闭包 done(),把交易写入区块链并广播到网络
library.logic.transaction.verify(transaction, sender, done);
});
});
} else {
...
}
区块与链
Block & Blocks & Loader
保存创世区块
创世区块是硬编码到客户端程序里的,会在客户端运行的时候,直接写入数据库。这样做的好处是保证每个客户端都有一个安全、可信的区块链的根。
这是一个非常典型的区块创建过程,我们可以借机会看看一个区块(创世)的数据是什么样的:
// genesisBlock.json 文件
{
"version": 0,
// 3行 在创世区块设定初始代币总量,这里是1亿;
"totalAmount": 10000000000000000,
"totalFee": 0,
"reward": 0,
"payloadHash": "1cedb278bd64b910c2d4b91339bc3747960b9e0acf4a7cda8ec217c558f429ad",
"timestamp": 0,
"numberOfTransactions": 103,
"payloadLength": 20326,
"previousBlock": null,
"generatorPublicKey": "b7b46c08c24d0f91df5387f84b068ec67b8bfff8f7f4762631894fce4aff6c75",
// 1757行 创世区块高度为1;
"height": 1,
"blockSignature": "2985d896becdb91c283cc2366c4a387a257b7d4751f995a81eae3aa705bc24fdb950c3afbed833e7d37a0a18074da461d68d74a3a223bc5f8e9c1fed2f3fec0e",
"id": "8593810399212843182",
// 12行。区块必须包含交易,这里是3种类型的交易,之前分析过,它们分别是转账交易、受托人交易和投票交易。
// 为了方便阅读,这里把关联的交易信息排版在最后位置
"transactions": [
{
"type": 0,
// 15行
"amount": 10000000000000000,
"fee": 0,
"timestamp": 0,
"recipientId": "6722322622037743544L",
"senderId": "5231662701023218905L",
"senderPublicKey": "b7b46c08c24d0f91df5387f84b068ec67b8bfff8f7f4762631894fce4aff6c75",
"signature": "aa413208c32d00b89895049ff21797048fa41c1b2ffc866900ffd97570f8d87e852c87074ed77c6b914f47449ba3f9d6dca99874d9f235ee4c1c83d1d81b6e07",
"id": "5534571359943011068"
},
{
"type": 2,
...
},
...
{
"type": 3,
...
}
...
]
}
加载本地区块
任何节点,都需要先加载验证本地区块链,确保没有被篡改。这个加载过程是软件初始化过程中的一部分,开发中不需要与网络节点联网等其他问题纠缠在一起。因此,代码需要被放在入口文件中去执行。
触发了“bind”事件,会执行所有模块里的“onBind()”方法。该方法运行之前,各个模块仅仅被实例化,处于待命状态,所以“bind”事件是激活各模块的重要事件,是继各模块构造函数运行之后的关键方法
- 将调用logic/account.js文件的createTables()方法,创建与用户相关的帐号信息表,全部以“mem_”开头,主要包括“mem_accounts”,“mem_accounts2contacts”,“mem_accounts2u_contacts”,“mem_accounts2delegates”,“mem_accounts2u_delegates”,“mem_accounts2multisignatures”,“mem_accounts2u_multisignatures”,“mem_round”等7个表。这些信息与用户相关,不需要被其他节点同步。
- 如果是新客户端,数据库为初始创建,创世区块第一次写入,count == 1,会立刻调用闭包load()函数(428行),加载验证缺失的区块,区块链数据同步完毕,然后触发 “blockchainReady” 事件(398行)。
“Offset” 不是分页数据,而是一次加载的区块数量,这样做的好处是避免一次性加载全部区块链,导致数据请求量过大,影响计算机性能。这个值最大是 limit 的值(380行),limit 等于 “config.json” 文件设定的全局变量
验证本地区块:
-
追溯前一区块,无法追溯自然是不正确的。
-
验证块签名,防止块内容被篡改。建议认真阅读该行调用的签名验证方法“verifySignature()”(在“logic/block.js”文件的150行,请去源码库查看),这应该是对二进制数据(这里是区块数据)进行签名验证的典型用法。验证失败,就要终止整个循环,删除该块及其以后的块。
-
验证块时段(Slot),防止块位置被篡改。实际上是变相验证了区块的高度及其时间戳(相关技术请看开发实践部分《关于时间戳及相关问题》的讨论)。亿书网络按照一定的周期循环(具体请参看《DPOS机制》),每一个块都可以根据其高度计算出它的出块时段,这与它的时间戳是对应的,这就锁定了区块位置,不然就是有问题。为什么要选择验证块时段,而不是简单直接的高度或时间戳?这是因为区块链有分叉的情况,相同高度存在多个块和时间戳,但相同的块时段却只能是一个。
-
验证交易。
创建新区块:
把整理好的数据组合成块数据结构(具体形式,类似于前面的创世区块),这里因为是新建数据,重要的是keypair、timestamp、previousBlock、transactions等四个字段信息。
其他字段,诸如:totalFee、reward、payloadHash等,会在“processBlock()”执行时处理。
这里的keypair、timestamp字段是该方法的参数,需要在调用的地方传入。
var block = library.logic.block.create({
keypair: keypair,
timestamp: timestamp,
previousBlock: privated.lastBlock,
transactions: ready
分叉问题:
- 块高度相同,父块不同。可能是父块验证出现问题;
- 交易已经存在。可能用户重复提交了交易,典型的就是“双花”问题;
- 受托人时段不同。出现时段验证错误,而块时段是与时间戳相关的,所以可能是时间处理出现了问题;
- 高度和父块都相同,但块ID不同。这是接收来的块,高度比最新块大于1,父块是最新块时才会正常写入区块链,不然只能写入分叉。块的ID信息是对块进行sha256加密算法得出的结果,可能获得的是不同分支上的块。
解决分叉-同步
该方法通过随机选择节点,并调用这里提供的api获得远程节点的“height”数据,在确保本地区块链高度小于远程节点区块链高度的前提下,如果本地是创世区块就把远程节点整个数据库同步过来,调用“privated.loadFullDb()”方法,不然就调用“privated.findUpdate()”方法更新缺失的区块。前者很简单,属于后者的特殊情况
P2P网络
Peer & Transport & Router
不同模块是如何协作起作用的,区块生成之后是怎样进行广播的,通信是保持一致性的基本需求;这个用Nodejs开发P2P网络有如下架构:
- 路由,节点具备跨域访问能力,任何节点之间都可以自由访问;
- Peers表,产品提供初始节点列表,保障了初始化节点快速完成,不至于成为孤立节点;
- UpdatePeers,节点具备自我更新能力,定期查询和更新死掉的节点,保障网络始终畅通;
这个P2P网络一旦达到一定的节点数量,就会形成一个互联互通的不死网络
路由Router
modules文件夹下的各个模块文件,这些模块基本都是独立的Express微应用,在开发和设计上相互独立,各不冲突,逻辑清晰
路由扩展
任何应用,只要提供Web访问能力或第三方访问的Api,都需要提供从地址到逻辑的请求分发功能,这就是路由。Ebookcoin是基于http协议的Express应用,Express底层基于Node.js的connect模块,因此其路由设计简单而灵活
// 27行
var Router = function () {
var router = require('express').Router();
router.use(function (req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
router.map = map;
return router;
}
这段代码定义了一个Express路由器Router,并扩展了两个功能:
- 允许任何客户端调用。其实,就是设置了跨域请求,保证节点具有跨域访问能力,选项Access-Control-Allow-Origin设置为*,自然任何IP和端口的节点都可以访问和被访问。
- 添加了地址映射方法。
function map(root, config)
- root: 定义了所要开放Api的逻辑函数;
- config: 定义了路由和root定义的函数的对应关系。
相当于
router.get('/peers', function(req, res, next){
root.getPeers(...);
})
节点路由
在peer.js里找到map方法
privated.attachApi = function () {
...
// 34行
router.map(shared, {
"get /": "getPeers",
"get /version": "version",
"get /get": "getPeer"
});
...
// 44行
library.network.app.use('/api/peers', router);
library.network.app.use(function (err, req, res, next) {
if (!err) return next();
library.logger.error(req.url, err.toString());
res.status(500).send({success: false, error: err.toString()});
});
}
将定义好的路由放在/api/peers前缀之下,可以确信peer.js文件提供了下面3个公共Api地址:
可以直接这么调用,要看具体对应的函数是否还有其他的参数要求,比如:/api/peers/get,按照restful的api设计原则,可以理解为是获得具体某个节点信息,那么总该给个id之类的限定条件
// 455行 对请求参数query进行验证,
// 验证规则是:object类型,属性ip_str要求长度不小于1的字符串,属性port要求0~65535之间的整数,并且都不能空(必需)
library.scheme.validate(query, {
type: "object",
properties: {
ip_str: {
type: "string",
minLength: 1
},
port: {
type: "integer",
minimum: 0,
maximum: 65535
}
},
required: ['ip_str', 'port']
}, function (err) {
...
// 480行 从sqlite数据库里查询数据表peers
privated.getByFilter({
...
});
});
在具体运行过程中,library就是app.js里传过来的scope,该参数包含的scheme代表了一个z_schema实例,z_schema是一个第三方组件,
我们应该这样请求,不然会返回错误信息。
节点保存
1.初始化节点
在一个P2P网络中,一个孤立的节点,在没有其他任何节点信息的情况下,仅仅靠网络扫描去寻找其他节点,将是一件很难完成的事情,更别提高效和安全了
在运行软件之前,初始化一些节点供联网使用,是最简单直接的解决方案。这个在配置文件config.json里,有直接体现:
// config.json 15行
"peers": {
"list": [],
"blackList": [],
"options": {
"timeout": 4000
}
},
...
list的数据格式为:
[
{
ip: 0.0.0.0,
port: 7000
},
...
// 当然,也可以在启动的时候,通过参数--peers 1.2.3.4:70001, 2.1.2.3:7002提供(代码见app.js47行)。
]
2. 写入节点
写入节点,就是持久化,或者保存到数据库,或者保存到某个文件。这里保存到sqlite3数据库里的peers表了
节点表格peers表的字段信息:id,ip,port,state,os,sharePort,version,clock
代码如下:
// peer.js 347行
Peer.prototype.onBlockchainReady = function () {
async.eachSeries(library.config.peers.list, function (peer, cb) {
library.dbLite.query("INSERT OR IGNORE INTO peers(ip, port, state, sharePort) VALUES($ip, $port, $state, $sharePort)", {
ip: ip.toLong(peer.ip),
port: peer.port,
state: 2, //初始状态为2,都是健康的节点
sharePort: Number(true)
}, cb);
}, function (err) {
if (err) {
library.logger.error('onBlockchainReady', err);
}
privated.count(function (err, count) {
if (count) {
privated.updatePeerList(function (err) {
err && library.logger.error('updatePeerList', err);
// 364行
library.bus.message('peerReady');
})
library.logger.info('Peers ready, stored ' + count);
} else {
library.logger.warn('Peers list is empty');
}
});
});
}
当区块链(后面篇章分析)加载完毕的时候(触发事件),依次将配置的节点写入数据库,如果数据库已经存在相同的记录就忽略,然后更新节点列表,触发节点加载完毕事件。
这里对数据库Sqlite的插入操作,插入语句是library.dbLite.query("INSERT OR IGNORE INTO peers,有意思的是IGNORE操作字符串,是sqlite3支持的(见参考),当数据库有相同记录的时候,该记录被忽略,继续往下执行。
执行成功,就会调用library.bus.message('peerReady'),进而触发peerReady事件
3. 更新节点
事件onPeerReady函数,如下:
// peer.js 374行
Peer.prototype.onPeerReady = function () {
setImmediate(function nextUpdatePeerList() {
privated.updatePeerList(function (err) {
err && library.logger.error('updatePeerList timer', err);
setTimeout(nextUpdatePeerList, 60 * 1000);
})
});
setImmediate(function nextBanManager() {
privated.banManager(function (err) {
err && library.logger.error('banManager timer', err);
setTimeout(nextBanManager, 65 * 1000)
});
});
}
两个setImmediate函数的调用,一个循环更新节点列表,一个循环更新节点状态。
(1)循环更新节点状态很简单,循环更改state和clock字段,主要是将禁止的状态state=0,修改为1,看代码:
// 142行
privated.banManager = function (cb) {
library.dbLite.query("UPDATE peers SET state = 1, clock = null where (state = 0 and clock - $now < 0)", {now: Date.now()}, cb);
}
(2)那么怎么updatePeerList循环更新节点列表呢,就是通过网络的随机节点来获取
privated.updatePeerList = function (cb) {
// 53行 逐个随机的验证节点信息,并将其做删除和更新处理
modules.transport.getFromRandomPeer({
api: '/list',
method: 'GET'
}, function (err, data) {
...
library.scheme.validate(data.body, {
...
// 124行
self.update(peer, cb);
});
}, cb);
});
});
};
在getFromRandomPeer函数中是怎么运行的,看代码:
// transport.js 474行
Transport.prototype.getFromRandomPeer = function (config, options, cb) {
...
// 481行 async.retry该方法就是要重复调用第一个task函数20次,有正确返回结果就传给回调函数
async.retry(20, function (cb) {
modules.peer.list(config, function (err, peers) {
if (!err && peers.length) {
var peer = peers[0];
// 485行
self.getFromPeer(peer, options, cb);
} else {
return cb(err || "No peers in db");
}
});
...
};
在getFromPeer函数中获取到一个可行节点的操作是怎样的:
// transport.js 500行
Transport.prototype.getFromPeer = function (peer, options, cb) {
...
var req = {
// 519行: 获得节点地址
url: 'http://' + ip.fromLong(peer.ip) + ':' + peer.port + url,
...
};
// 532行: 使用`request`组件发送请求
return request(req, function (err, response, body) {
if (err || response.statusCode != 200) {
...
if (peer) {
if (err && (err.code == "ETIMEDOUT" || err.code == "ESOCKETTIMEDOUT" || err.code == "ECONNREFUSED")) {
// 542行: 对于无法请求的,自然要删除
modules.peer.remove(peer.ip, peer.port, function (err) {
...
});
} else {
if (!options.not_ban) {
// 549行: 对于状态码不是200的,比如304等禁止状态,就要更改其状态
modules.peer.state(peer.ip, peer.port, 0, 600, function (err) {
...
});
}
}
}
cb && cb(err || ('request status code' + response.statusCode));
return;
}
...
if (port > 0 && port <= 65535 && response.headers['version'] == library.config.version) {
// 595行: 一切问题都不存在
modules.peer.update({
ip: peer.ip,
port: port,
state: 2, // 598行: 看来健康的节点状态为2
...
});
}
涉及的第三方组件参考:
z_schema组件: https://github.com/Ebookcoin/z_schema
dblite组件: https://github.com/Ebookcoin/dblite
request组件: http://github.com/request/request
SQL As Understood By SQLite: https://www.sqlite.org/lang_conflict.html
广播新区块
在广播交易中与广播新区块中都会用到的广播Broadcast功能:
// Public methods
Transport.prototype.broadcast = function (config, options, cb) {
config.limit = config.limit || 1;
modules.peer.list(config, function (err, peers) {
if (!err) {
async.eachLimit(peers, 3, function (peer, cb) {
self.getFromPeer(peer, options);
setImmediate(cb);
}, function () {
cb && cb(null, {body: null, peer: peers});
})
} else {
cb && setImmediate(cb, err);
}
});
};
在签名、UTXO未确认交易、新区块、消息通知中,用到broadcast的情形:
Transport.prototype.onSignature = function (signature, broadcast) {
if (broadcast) {
self.broadcast({limit: 100}, {api: '/signatures', data: {signature: signature}, method: "POST"});
library.network.io.sockets.emit('signature/change', {});
}
}
Transport.prototype.onUnconfirmedTransaction = function (transaction, broadcast) {
if (broadcast) {
self.broadcast({limit: 100}, {api: '/transactions', data: {transaction: transaction}, method: "POST"});
library.network.io.sockets.emit('transactions/change', {});
}
}
Transport.prototype.onNewBlock = function (block, broadcast) {
if (broadcast) {
self.broadcast({limit: 100}, {api: '/blocks', data: {block: block}, method: "POST"});
library.network.io.sockets.emit('blocks/change', {});
}
}
Transport.prototype.onMessage = function (msg, broadcast) {
if (broadcast) {
self.broadcast({limit: 100, dappid: msg.dappid}, {api: '/dapp/message', data: msg, method: "POST"});
}
}
DPOS共识
Delegate & Round & Accounts & Slots | Milestones
DPOS过程
-
注册受托人,并接受投票
用户注册为受托人;
接受投票(得票数排行前101位); -
维持循环,调整受托人
- 块周期:也称为时段周期(Slot),每个块需要10秒,为一个时段(Slot);
- 受托人周期:或叫循环周期(Round),每101个区块为一个循环周期(Round)。这些块均由101个代表随机生成,每个代表生成1个块。一个完整循环周期大概需要1010秒(101x10),约16分钟;每个周期结束,前101名的代表都要重新调整一次;
- 奖励周期:根据区块链高度,设置里程碑时间(Milestone),在某个时间点调整区块奖励。
- 上述循环,块周期最小(10秒钟),受托人周期其次(16分钟),奖励周期最大(347天)。
-
循环产生新区块,广播
委托人生成区块的相关联代码段:(已省略vote投票相关代码,具体查看delegate.js)
privated.getBlockSlotData(currentSlot, lastBlock.height + 1, function (err, currentBlockData) {
if (err || currentBlockData === null) {
library.logger.log('Loop', 'skiping slot');
return setImmediate(cb);
}
library.sequence.add(function (cb) {
if (slots.getSlotNumber(currentBlockData.time) == slots.getSlotNumber()) {
// 由选举得到的委托人生成一个区块
modules.blocks.generateBlock(currentBlockData.keypair, currentBlockData.time, function (err) {
library.logger.log('Round ' + modules.round.calc(modules.blocks.getLastBlock().height) + ' new block id: ' + modules.blocks.getLastBlock().id + ' height: ' + modules.blocks.getLastBlock().height + ' slot: ' + slots.getSlotNumber(currentBlockData.time) + ' reward: ' + modules.blocks.getLastBlock().reward);
cb(err);
});
} else {
// library.logger.log('Loop', 'exit: ' + _activeDelegates[slots.getSlotNumber() % constants.delegates] + ' delegate slot');
setImmediate(cb);
}
}, function (err) {
if (err) {
library.logger.error("Failed to get block slot data", err);
}
setImmediate(cb);
});
});
Slot Round Milestone概念详解
-
块周期:也称为时段周期(Slot),每个块需要10秒,为一个时段(Slot);
- 时间处理
比特币的块周期是10分钟,由工作量证明机制来智能控制,亿书的为10秒钟,仅仅是时间上的设置而已,源码在helpers/slots.js里。这个文件非常简单,时间处理统一使用UTC标准时间(请参考开发实践部分《关于时间戳及相关问题》一章),创世时间beginEpochTime()和getEpochTime(time)两个私有方法定义了首尾两个时间点,其他的方法都是基于这两个方法计算出来的时间段,所以不会出现时间上不统一的错误。 - 编码风险
但是,唯一可能出现错误的地方,就是getEpochTime(time)方法,看下面代码的16行,new Date() 方法获得的是操作系统的时间,这个是可以人为改变的,一般情况下不会有什么影响,但个别情况也可能引起分叉行为(上一篇文章《区块链》分析过分叉的原因,委托人时间不一致,其中一个就发生在这里)
- 时间处理
-
受托人周期:或叫循环周期(Round),每101个区块为一个循环周期(Round)
- 亿书规定受托人每轮都要变更,确保那些不稳定或者做坏事的节点被及时剔除出去。
- 另外,尽管系统会随机找寻受托人产生新块,但是在一个轮次内,每个受托人都有机会产生一个新区块(并获得奖励)并广播
- 亿书每个区块都会与特定的受托人关联起来,其高度(height)和产生器公钥(generatorPublicKey)必是严格对应的。块高度可以轻松找到当前块的受托人周期(modules/round.js 文件51行的calc()方法),generatorPublicKey代表的就是受托人。
-
奖励周期:根据区块链高度,设置里程碑时间(Milestone),在某个时间点调整区块奖励。
- 奖励金额:第一阶段(大概1年)奖励5EBC(亿书币)/块,第二年奖励4EBC(亿书币)/块,4年之后降到1EBC(亿书币)/块,以后永远保持1EBC/块,所以总量始终在少量增发。
- 第一阶段时间长度 = rewards.distance * 10秒 / (24 * 60 * 60) = 347.2天,增发量 = rewards.distance * 5 = 3000000 * 5 = 1500万。第二阶段1200万,第三阶段900万,第四阶段600万,以后每阶段300万。这种适当通胀的情况是DPoS机制的一个特点,也是为了给节点提供奖励,争取更多用户为网络做贡献。
- 对于拥有大量侧链应用(下一篇介绍)的平台产品来说,一定要保证有足够代币供各侧链产品使用,不然会造成主链和侧链绑定紧密,互相掣肘,对整个生态系统都不是好事情。这种情况可以通过最近以太坊的运行情况体会出来,特别是侧链应用使用主链代币众筹时更不必说,此消彼长,价格波动剧烈。
本文整理自: