April 22, 2018 · 区块链 本文字数: 7.7k 阅读时长:31 min 全站字数:345.6k

代码级理解加密数字货币原理

  1. 简介
  2. 最小可运行的区块链
    1. 区块的数据结构
    2. 区块哈希
    3. 创世区块
    4. 生成一个区块
    5. 存储区块链
    6. 验证区块的完整性
    7. 选择最长的区块链
    8. 节点之间通信
    9. 控制节点
    10. 区块链架构
  3. PoW(Proof of Work)共识算法
    1. 难度、随机数和工作量证明谜题
    2. 寻找新的区块
    3. 在难度上达成共识
    4. 时间戳验证
    5. 难度累积
  4. 交易
    1. 公钥加密和签名
    2. ECDSA中的私钥和公钥
    3. 交易概览
    4. 交易输出
    5. 交易输入
    6. 交易的数据结构
    7. 交易Id
    8. 交易签名
    9. 未花费的交易输出
    10. 更新未花费交易输出
    11. 交易验证
    12. Coinbase transaction
    13. 小结
  5. 钱包
    1. Overview
    2. 生成和存储私钥
    3. 钱包余额
    4. 生成交易
    5. 使用钱包
    6. 小结
  6. 交易中继
    1. 交易池
    2. 广播
    3. 验证收到的未确认的交易
    4. 从交易池到区块链
    5. 更新交易池
    6. 小结
  7. 钱包UI界面和区块链浏览器
    1. Overview
    2. 新的接口
    3. 前端技术
    4. 区块链浏览器
    5. 钱包界
  8. 参考资料

简介

从零开始搭建一个数字货币原型系统。使用TypeScript开发,目的是为了了解数字货币所涉及的技术。

最小可运行的区块链

区块链最简单定义就是一个分布式的数据库,这个数据库维护了一个连续且一直增长的有序记录列表。

From Wikipedia : Blockchain is a distributed database that maintains a continuously-growing list of records called blocks secured from tampering and revision.

一个基本的区块链实现包括:

  1. 区块和区块链定义
  2. 提供向区块链增加区块的方法
  3. 节点可以和其他节点同步区块链数据。
  4. 提供HTTP接口控制节点。

区块的数据结构

区块只包括最基本的属性:

  1. index。区块在区块链中的高度。
  2. data。 区块中存储的数据。
  3. timestamp。时间戳。
  4. hash。区块内容的sha256哈希。
  5. previousHash。指向前一个区块的哈希。显式定义了前一个区块。

区块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Block {

public index: number;
public hash: string;
public previousHash: string;
public timestamp: number;
public data: string;

constructor(index: number, hash: string, previousHash: string, timestamp: number, data: string) {
this.index = index;
this.previousHash = previousHash;
this.timestamp = timestamp;
this.data = data;
this.hash = hash;
}
}

区块哈希

区块的哈希是区块最重要的属性,哈希是通过使用整个区块的内容计算得出,保证区块数据的完整性。只要区块内容有变化,区块哈希也会变化。
区块哈希是区块的唯一标识。

计算区块哈希的代码:

1
2
const calculateHash = (index: number, previousHash: string, timestamp: number, data: string): string =>
CryptoJS.SHA256(index + previousHash + timestamp + data).toString();

区块中的hashpreviousHash两个属性保证,如果有个区块内容被修改,那么这个区块之后的所有区块都必须修改。
这种特性对于PoW(Proof of Work)共识算法特别关键。区块的高度越大,修改区块的难度也就越大,因为需要修改所有之后的区块。

创世区块

创世区块(Genesis block)是区块链中的第一个区块,唯一一个没有previousHash的区块。创世区块通过在源代码中硬编码实现。

1
2
3
const genesisBlock: Block = new Block(
0, '816534932c2b7154836da6afc367695e6337db8a921823784c14378abed4f7d7', null, 1465154705, 'my genesis block!!'
);

生成一个区块

生成一个区块必须知道前一个区块的哈希。区块链的数据由用户提供,其他参数则通过代码生成。

1
2
3
4
5
6
7
8
const generateNextBlock = (blockData: string) => {
const previousBlock: Block = getLatestBlock();
const nextIndex: number = previousBlock.index + 1;
const nextTimestamp: number = new Date().getTime() / 1000;
const nextHash: string = calculateHash(nextIndex, previousBlock.hash, nextTimestamp, blockData);
const newBlock: Block = new Block(nextIndex, nextHash, previousBlock.hash, nextTimestamp, blockData);
return newBlock;
};

存储区块链

为了简单起见,区块链内容将存储在内存数组中,不进行持久化存储。

1
const blockchain: Block[] = [genesisBlock];

验证区块的完整性

在给定的时间,必须可以验证一个区块或者一个区块链的完整性,特别是从其他节点新收到一个区块的时候,决定了是否接受该区块作为区块链中一个新的区块。

为了验证区块的完整性,必须验证以下内容:

  1. index必须比前一个区块大1。
  2. 区块的previousHash必须和前一个区块的哈希匹配。
  3. 区块的哈希必须和其自身数据的哈希匹配。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const isValidNewBlock = (newBlock: Block, previousBlock: Block) => {
if (previousBlock.index + 1 !== newBlock.index) {
console.log('invalid index');
return false;
} else if (previousBlock.hash !== newBlock.previousHash) {
console.log('invalid previoushash');
return false;
} else if (calculateHashForBlock(newBlock) !== newBlock.hash) {
console.log(typeof (newBlock.hash) + ' ' + typeof calculateHashForBlock(newBlock));
console.log('invalid hash: ' + calculateHashForBlock(newBlock) + ' ' + newBlock.hash);
return false;
}
return true;
};

还必须验证区块链的完整性,防止错误的区块内容使节点崩溃。

1
2
3
4
5
6
7
const isValidBlockStructure = (block: Block): boolean => {
return typeof block.index === 'number'
&& typeof block.hash === 'string'
&& typeof block.previousHash === 'string'
&& typeof block.timestamp === 'number'
&& typeof block.data === 'string';
};

接下来就可以验证整个区块链的完整性。检查第一个是否是创世区块,然后验证创世区块之后的区块是否满足上述条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const isValidChain = (blockchainToValidate: Block[]): boolean => {
const isValidGenesis = (block: Block): boolean => {
return JSON.stringify(block) === JSON.stringify(genesisBlock);
};

if (!isValidGenesis(blockchainToValidate[0])) {
return false;
}

for (let i = 1; i < blockchainToValidate.length; i++) {
if (!isValidNewBlock(blockchainToValidate[i], blockchainToValidate[i - 1])) {
return false;
}
}
return true;
};

选择最长的区块链

在指定时间区块链应该只有一条,但是当有多个节点同时生成一个新的区块时,我们选择最长的那个区块链。

代码逻辑如下:

1
2
3
4
5
6
7
8
9
const replaceChain = (newBlocks: Block[]) => {
if (isValidChain(newBlocks) && newBlocks.length > getBlockchain().length) {
console.log('Received blockchain is valid. Replacing current blockchain with received blockchain');
blockchain = newBlocks;
broadcastLatest();
} else {
console.log('Received blockchain invalid');
}
};

节点之间通信

区块链是一个分布式的数据库,必须能够在多个节点之间同步和共享区块链数据。使用下面的规则来保证整个网络的数据同步:

  1. 当一个节点生成一个新的区块时,广播到整个网络。
  2. 当一个节点连接到另外一个节点时会查询最新的区块信息。
  3. 当一个节点遇到一个大于当前节点index的区块时,该节点可以选择接收该新区快或者查询整个区块链。

节点之间使用点对点通信。使用websocket作为通信协议。不会自动发现节点,需要手动加入,存活的节点信息存储在
WebSocket[]变量中。

控制节点

提供HTTP API控制节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const initHttpServer = ( myHttpPort: number ) => {
const app = express();
app.use(bodyParser.json());

app.get('/blocks', (req, res) => {
res.send(getBlockchain());
});
app.post('/mineBlock', (req, res) => {
const newBlock: Block = generateNextBlock(req.body.data);
res.send(newBlock);
});
app.get('/peers', (req, res) => {
res.send(getSockets().map(( s: any ) => s._socket.remoteAddress + ':' + s._socket.remotePort));
});
app.post('/addPeer', (req, res) => {
connectToPeers(req.body.peer);
res.send();
});

app.listen(myHttpPort, () => {
console.log('Listening http on port: ' + myHttpPort);
});
};

直接使用curl命令控制节点。

1
2
#get all blocks from the node
> curl http://localhost:3001/blocks

区块链架构

区块暴露两个端口:一个是用户接口,另外一个是点对点通信接口。

PoW(Proof of Work)共识算法

在上文的区块链中增加区块是没有任何代价的,在本节将加入工作量证明(Proof-of-work),即通过计算来进行解题,解决谜题后才能在区块链中加入新的区块。解谜题的过程通常叫做挖矿。

使用工作量证明可以在概率上控制区块链中区块增长的速度。通过改变谜题的难度来实现。目前还没有引入交易,所以生成一个区块并没有奖励。通常在数字货币系统中,矿工生成新的区块会有数字货币奖励。

工作量证明谜题必须具有的一个特性是:解题很难但是验证很简单。SHA256就具有该特性。

难度、随机数和工作量证明谜题

需要在区块的数据结构中加入difficultynonce来实现工作量证明谜题。工作量证明谜题就是寻找一个符合条件的区块的哈希,这个哈希以多个0为前缀。难度就是哈希前缀中有多少个0。前缀的0是以哈希的二进制形式来进行验证。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Block {

public index: number;
public hash: string;
public previousHash: string;
public timestamp: number;
public data: string;
public difficulty: number;
public nonce: number;

constructor(index: number, hash: string, previousHash: string,
timestamp: number, data: string, difficulty: number, nonce: number) {
this.index = index;
this.previousHash = previousHash;
this.timestamp = timestamp;
this.data = data;
this.hash = hash;
this.difficulty = difficulty;
this.nonce = nonce;
}
}

根据难度验证哈希是否有效:

1
2
3
4
5
const hashMatchesDifficulty = (hash: string, difficulty: number): boolean => {
const hashInBinary: string = hexToBinary(hash);
const requiredPrefix: string = '0'.repeat(difficulty);
return hashInBinary.startsWith(requiredPrefix);
};

为了找到符合难度的哈希,必须不断通过修改区块的nonce来计算这个区块的哈希。由于SHA256是哈希函数,只要区块数据有变动,整个哈希内容就会完全不同。所谓挖矿就是使用不同的nonce值,计算满足难度条件的哈希。

寻找新的区块

寻找指定难度哈希是一个随机的过程。

1
2
3
4
5
6
7
8
9
10
const findBlock = (index: number, previousHash: string, timestamp: number, data: string, difficulty: number): Block => {
let nonce = 0;
while (true) {
const hash: string = calculateHash(index, previousHash, timestamp, data, difficulty, nonce);
if (hashMatchesDifficulty(hash, difficulty)) {
return new Block(index, hash, previousHash, timestamp, data, difficulty, nonce);
}
nonce++;
}
};

当前区块被找到,向整个网络广播该新区快。

在难度上达成共识

当前我们已经知道了如何在寻找和验证指定的难度。那么如何在难度上达成共识,即难度系数是如何调整的?这就需要引入规则来计算当前网络的难度系数。

首先定义关于网络的新的常量:

1
2
BLOCK_GENERATION_INTERVAL, defines how often a block should be found. (in Bitcoin this value is 10 minutes)
DIFFICULTY_ADJUSTMENT_INTERVAL, defines how often the difficulty should adjust to the increasing or decreasing network hashrate. (in Bitcoin this value is 2016 blocks)

这些常量是硬编码,不会改变。

1
2
3
4
5
// in seconds
const BLOCK_GENERATION_INTERVAL: number = 10;

// in blocks
const DIFFICULTY_ADJUSTMENT_INTERVAL: number = 10;

计算难度系数的方法就是:每产生10个区块,检查生成当前区块的时间是否和期望的时间一致;期望的时间是通过
BLOCK_GENERATION_INTERVAL * DIFFICULTY_ADJUSTMENT_INTERVAL计算得出,代表匹配当前难度的哈希速率;如果生成区块的时间大于两倍的期望时间,则将难度系数加一,否则减一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const getDifficulty = (aBlockchain: Block[]): number => {
const latestBlock: Block = aBlockchain[blockchain.length - 1];
if (latestBlock.index % DIFFICULTY_ADJUSTMENT_INTERVAL === 0 && latestBlock.index !== 0) {
return getAdjustedDifficulty(latestBlock, aBlockchain);
} else {
return latestBlock.difficulty;
}
};

const getAdjustedDifficulty = (latestBlock: Block, aBlockchain: Block[]) => {
const prevAdjustmentBlock: Block = aBlockchain[blockchain.length - DIFFICULTY_ADJUSTMENT_INTERVAL];
const timeExpected: number = BLOCK_GENERATION_INTERVAL * DIFFICULTY_ADJUSTMENT_INTERVAL;
const timeTaken: number = latestBlock.timestamp - prevAdjustmentBlock.timestamp;
if (timeTaken < timeExpected / 2) {
return prevAdjustmentBlock.difficulty + 1;
} else if (timeTaken > timeExpected * 2) {
return prevAdjustmentBlock.difficulty - 1;
} else {
return prevAdjustmentBlock.difficulty;
}
};

时间戳验证

由于工作量证明算法中需要用到区块中的时间戳,所以必须对时间戳进行验证以应对通过使用错误的时间戳来操纵难度系数的攻击。

区块的时间戳必须在一分钟内。

1
2
3
4
const isValidTimestamp = (newBlock: Block, previousBlock: Block): boolean => {
return ( previousBlock.timestamp - 60 < newBlock.timestamp )
&& newBlock.timestamp - 60 < getCurrentTimestamp();
};

难度累积

引入工作量证明后,最长的区块链是拥有最多难度累积的链,即需要最多哈希计算(= hashRate * time)的链。

为了得到区块链的累积难度,可以通过计算每个区块的2^difficulty并累加。使用2^difficulty来代表符合条件的哈希中前缀0的数目。例如对于难度5和11,需要2^(11-5) = 2^6的工作量来生成新的区块。

下图中B才是正确的链,拥有最多的难度累积。

真正重要的是区块的难度,而不是区块的哈希。例如如果难度是4,区块的哈希是 000000a34c…(难度为6),但是只有4会被计入难度累积。

这个属性被称为Nakamoto consensus,这是Satoshi发明Bitcoin时做出的最重要的发明。当遇到分叉时,旷工必须决定在哪一条链上继续投入资源(哈希速率)挖矿。由于利益分配,矿工会选择同一条链。

交易

在区块链中加入交易这一个功能,就可以在区块链上构建加密货币。为了在我们的区块链中加入交易,首先需要理解以下新的概念,包括公钥加密,签名,交易输入和输出。

公钥加密和签名

在公钥加密中会有一对秘钥:私钥和公钥。公钥可以从私钥中导出,但是私钥不能从公钥中导出。公钥可以公开和共享。
任何消息都可以使用私钥来创建签名。使用这个签名和公钥,其他人就可以验证签名是否是由与公钥相匹配的私钥生成。

我们的代码库使用椭圆曲线公钥加密(ECDSA)。两类不同的函数将使用:哈希函数(SHA256)用于工作量证明的挖矿机制以及保证区块的完整性;公钥加密用于交易。

ECDSA中的私钥和公钥

一个有效的私钥是32字节的字符串如19f128debc1b9122da0635954488b208b829879cf13b3d6cac5d1260c0fd967c。一个有效的公钥是04开头,紧跟64字节的字符串,如04bfcab8722991ae774db48f934ca79cfb7dd991229153b9f732ba5334aafcd8e7266e47076996b55a14bf9913ee3145ce0cfc1372ada8ada74bd287450313534a

公钥可以从私钥中导出。公钥将被用为交易中的接收方地址,即加密货币的地址。

交易概览

交易有两部分组成:输入和输出。输出指定加密货币发送到哪里,输入作为加密货币是否存在以及是否由发送者持有。输入总是和已经有的,未花费的输出关联。

交易输出

交易输出包括一个地址和加密货币数量。地址是ECDSA的公钥,即拥有和该公钥匹配的私钥的用户可以操作这些加密货币。

1
2
3
4
5
6
7
8
9
class TxOut {
public address: string;
public amount: number;

constructor(address: string, amount: number) {
this.address = address;
this.amount = amount;
}
}

交易输入

交易输入提供了加密货币的来源信息。每一个交易输入指向一个更早的交易输出。没有被锁定的加密货币可以作为交易输出。签名保证了只有拥有与公钥相匹配的私钥的人才能创建这个交易。

1
2
3
4
5
class TxIn {
public txOutId: string;
public txOutIndex: number;
public signature: string;
}

区块链中只有公钥和签名信息,从来不会有私钥。

作为总结,交易输入解锁加密货币,而交易输出重新锁定加密货币。

交易的数据结构

1
2
3
4
5
class Transaction {
public id: string;
public txIns: TxIn[];
public txOuts: TxOut[];
}

交易Id

交易Id是通过计算交易的内容的哈希得出。但是,签名并不包含在交易的内容哈希中,因为签名是在交易的后期加入的。

1
2
3
4
5
6
7
8
9
10
11
const getTransactionId = (transaction: Transaction): string => {
const txInContent: string = transaction.txIns
.map((txIn: TxIn) => txIn.txOutId + txIn.txOutIndex)
.reduce((a, b) => a + b, '');

const txOutContent: string = transaction.txOuts
.map((txOut: TxOut) => txOut.address + txOut.amount)
.reduce((a, b) => a + b, '');

return CryptoJS.SHA256(txInContent + txOutContent).toString();
};

交易签名

一旦交易被签名,交易的内容就不能修改了。因为交易是公开的,任何人都可以访问,即使是在交易还没有包含到区块链中。

当对交易输入进行签名时,只有交易Id会被签名。如果交易内容有任何的变化,交易Id一定会变化,也使得交易和签名都变得无效了。

1
2
3
4
5
6
7
8
9
10
const signTxIn = (transaction: Transaction, txInIndex: number,
privateKey: string, aUnspentTxOuts: UnspentTxOut[]): string => {
const txIn: TxIn = transaction.txIns[txInIndex];
const dataToSign = transaction.id;
const referencedUnspentTxOut: UnspentTxOut = findUnspentTxOut(txIn.txOutId, txIn.txOutIndex, aUnspentTxOuts);
const referencedAddress = referencedUnspentTxOut.address;
const key = ec.keyFromPrivate(privateKey, 'hex');
const signature: string = toHexString(key.sign(dataToSign).toDER());
return signature;
};

如果有人试图修改交易,将会发生:

一个攻击者运行一个节点,接收到一个交易,内容是:从AAA地址发送10个加密货币到地址BBB,交易Id是0x555...

攻击者将接收方地址改为CCC,然后转发到整个网络,现在交易的内容变为:从AAA地址发送10个加密货币到地址CCC。然而由于交易内容发生了变化,交易Id已经无效了,新的有效的交易Id是0x567...

如果交易Id被设置为新的值,交易的签名就无效了,签名仅匹配原来的交易Id:0x555..。被修改的交易将不会被其他节点接收,是无效的。

未花费的交易输出

交易的输入必须总是指向一个未花费的交易输出(uTxO)。如果你在区块链上有一些加密货币,你将实际拥有很多未花费的交易输出,这些交易输出的公钥都和你拥有的私钥相匹配。

对于交易验证,我们可以只关注未花费的交易输出列表就可以验证交易是否有效。未花费交易输出总是可以总当前区块链中导出。在本文的实现中,将在处理交易时就更新未花费的交易输出信息。

数据结构为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class UnspentTxOut {
public readonly txOutId: string;
public readonly txOutIndex: number;
public readonly address: string;
public readonly amount: number;

constructor(txOutId: string, txOutIndex: number, address: string, amount: number) {
this.txOutId = txOutId;
this.txOutIndex = txOutIndex;
this.address = address;
this.amount = amount;
}
}

let unspentTxOuts: UnspentTxOut[] = [];

更新未花费交易输出

每当有一个新的区块加入到区块链中时,必须更新未花费交易输出列表。因为这个交易会花费已经存在的交易输出并生成新的未花费交易输出。

为了处理新的区块,我们首先接受新的区块中所有新的未花费交易输出(newUnspentTxOuts)。

1
2
3
4
5
const newUnspentTxOuts: UnspentTxOut[] = newTransactions
.map((t) => {
return t.txOuts.map((txOut, index) => new UnspentTxOut(t.id, index, txOut.address, txOut.amount));
})
.reduce((a, b) => a.concat(b), []);

其次,还需要知道是哪一个交易输出(consumedTxOuts)被作为区块中新的交易所消费。这是通过检查新交易的交易输入来实现:

1
2
3
4
const consumedTxOuts: UnspentTxOut[] = newTransactions
.map((t) => t.txIns)
.reduce((a, b) => a.concat(b), [])
.map((txIn) => new UnspentTxOut(txIn.txOutId, txIn.txOutIndex, '', 0));

最后,我们生成新的未花费交易输出,通过移除consumedTxOuts,并增加newUnspentTxOuts。

1
2
3
const resultingUnspentTxOuts = aUnspentTxOuts
.filter(((uTxO) => !findUnspentTxOut(uTxO.txOutId, uTxO.txOutIndex, consumedTxOuts)))
.concat(newUnspentTxOuts);

上述的代码在updateUnspentTxOuts方法中。这个方法只有在区块中的交易被验证有效后才进行。

交易验证

  1. 正确的交易数据结构
1
2
3
4
5
6
7
8
const isValidTransactionStructure = (transaction: Transaction) => {
if (typeof transaction.id !== 'string') {
console.log('transactionId missing');
return false;
}
...
//check also the other members of class
}
  1. 验证交易Id
1
2
3
4
if (getTransactionId(transaction) !== transaction.id) {
console.log('invalid tx id: ' + transaction.id);
return false;
}
  1. 验证交易输入

交易输入的签名必须有效,而且必须指向一个为花费的交易输出。

1
2
3
4
5
6
7
8
9
10
11
12
const validateTxIn = (txIn: TxIn, transaction: Transaction, aUnspentTxOuts: UnspentTxOut[]): boolean => {
const referencedUTxOut: UnspentTxOut =
aUnspentTxOuts.find((uTxO) => uTxO.txOutId === txIn.txOutId && uTxO.txOutId === txIn.txOutId);
if (referencedUTxOut == null) {
console.log('referenced txOut not found: ' + JSON.stringify(txIn));
return false;
}
const address = referencedUTxOut.address;

const key = ec.keyFromPublic(address, 'hex');
return key.verify(transaction.id, txIn.signature);
};
  1. 验证交易输出

交易的输出总和必须等于交易输入的总和。

1
2
3
4
5
6
7
8
9
10
11
12
const totalTxInValues: number = transaction.txIns
.map((txIn) => getTxInAmount(txIn, aUnspentTxOuts))
.reduce((a, b) => (a + b), 0);

const totalTxOutValues: number = transaction.txOuts
.map((txOut) => txOut.amount)
.reduce((a, b) => (a + b), 0);

if (totalTxOutValues !== totalTxInValues) {
console.log('totalTxOutValues !== totalTxInValues in tx: ' + transaction.id);
return false;
}

Coinbase transaction

交易输入必须指向一个未花费的交易输出,那么这些交易输出的最初来自与区块链的哪里?为了解决这个问题,引入coinbase transactioncoinbase transaction交易只包含一个输出,但是没有输入。这意味着这个交易生成了新的加密货币。我们指定coinbase transaction交易的输出是50个加密货币。

1
const COINBASE_AMOUNT: number = 50;

coinbase transaction总是区块的第一个交易,是由生成这个区块的矿工加入。这种机制作为对矿工的奖励,如果你寻找到了新的区块,你将获取50个加密货币。
我们将在coinbase transaction的输入中加入区块高度。为了保证coinbase transaction有一个唯一的交易Id。否则,每一个coinbase transaction描述:给50个加密货币到地址0xabc,将具有相同的交易Id。

coinbase transaction交易的验证和普通交易的验证稍有不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const validateCoinbaseTx = (transaction: Transaction, blockIndex: number): boolean => {
if (getTransactionId(transaction) !== transaction.id) {
console.log('invalid coinbase tx id: ' + transaction.id);
return false;
}
if (transaction.txIns.length !== 1) {
console.log('one txIn must be specified in the coinbase transaction');
return;
}
if (transaction.txIns[0].txOutIndex !== blockIndex) {
console.log('the txIn index in coinbase tx must be the block height');
return false;
}
if (transaction.txOuts.length !== 1) {
console.log('invalid number of txOuts in coinbase transaction');
return false;
}
if (transaction.txOuts[0].amount != COINBASE_AMOUNT) {
console.log('invalid coinbase amount in coinbase transaction');
return false;
}
return true;
};

小结

我们在区块链中加入了交易。基本原理很简单,即交易输入总是指向一个未花费的交易输出,使用签名保证加密货币的解锁部分的完整性。交易输出将保证重新锁定到接收者的地址中。

但是创建交易仍然非常困难,我们需要手动创建交易输入和交易输出,并使用我们的私钥签名。在之后我们引入钱包后会使这个过程变得简单。

这里也没有加入交易中继,将交易打包到区块链中,我们必须自己打包交易到区块链中。这也是我们为什么没有引入交易费的概念。

钱包

Overview

钱包的目的是为了创建一种对终端用户更抽象的接口。终端用户可以:

  1. 创建一个新的钱包。即生成新的私钥。
  2. 查看钱包的余额。
  3. 向其他地址发送加密货币。

终端用户无需立即交易输入和交易输出,就像比特币,向一个地址发送比特币,发布自己的地址使得其他用户可以向自己转入比特币。

生成和存储私钥

最简单的方式生成和存储私钥,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const privateKeyLocation = 'node/wallet/private_key';

const generatePrivatekey = (): string => {
const keyPair = EC.genKeyPair();
const privateKey = keyPair.getPrivate();
return privateKey.toString(16);
};

const initWallet = () => {
//let's not override existing private keys
if (existsSync(privateKeyLocation)) {
return;
}
const newPrivateKey = generatePrivatekey();

writeFileSync(privateKeyLocation, newPrivateKey);
console.log('new wallet with private key created');
};

公钥可以从私钥计算得出:

1
2
3
4
5
const getPublicFromWallet = (): string => {
const privateKey = getPrivateFromWallet();
const key = EC.keyFromPrivate(privateKey, 'hex');
return key.getPublic().encode('hex');
};

需要注意的是以明文的方式存储私钥是不安全的,这里只是为了实现简单。而且这个钱包只支持一个私钥,为了获取一个新的公钥地址,需要新建一个钱包。

钱包余额

当你在区块链上拥有加密货币,实际上你将拥有一系列未花费的交易输出,这些交易输出的公钥和你自己的私钥匹配。
所以计算余额就是计算所有属于你的未花费交易输出的总和:

1
2
3
4
5
6
const getBalance = (address: string, unspentTxOuts: UnspentTxOut[]): number => {
return _(unspentTxOuts)
.filter((uTxO: UnspentTxOut) => uTxO.address === address)
.map((uTxO: UnspentTxOut) => uTxO.amount)
.sum();
};

从代码中可以看出,查询余额不需要私钥,即任何人都可以查询到你的余额。

生成交易

当发送加密货币时,钱包的用户不需要关心交易输入和交易输出。但是当用户A有余额50,这是一个交易输出,想发送10个加密货币到用户B?

在这种情况下,A向用户B发送10个,然后将剩下的40个发送给A。所有的交易输出必须被花费,所以当将加密货币分配给新的交易输出是必须分隔加密货币。这个过程可以通过下面的图说明:

让我们来看一个更复杂的情景:

  1. 用户C只有0个加密货币。
  2. 用户C收到三笔交易,分别为10,20,30个加密货币。
  3. 用户C想发送55个加密货币给D。

在这种情况下三笔交易输出必须被使用,发送55个给D,发送5个给C。

代码实现如下,首先创建交易输入。循环未花费交易输出直到交易输出的总和大于等于想要发送的数额。

1
2
3
4
5
6
7
8
9
10
11
12
13
const findTxOutsForAmount = (amount: number, myUnspentTxOuts: UnspentTxOut[]) => {
let currentAmount = 0;
const includedUnspentTxOuts = [];
for (const myUnspentTxOut of myUnspentTxOuts) {
includedUnspentTxOuts.push(myUnspentTxOut);
currentAmount = currentAmount + myUnspentTxOut.amount;
if (currentAmount >= amount) {
const leftOverAmount = currentAmount - amount;
return {includedUnspentTxOuts, leftOverAmount}
}
}
throw Error('not enough coins to send transaction');
};

下面创建交易输入:

1
2
3
4
5
6
7
8
const toUnsignedTxIn = (unspentTxOut: UnspentTxOut) => {
const txIn: TxIn = new TxIn();
txIn.txOutId = unspentTxOut.txOutId;
txIn.txOutIndex = unspentTxOut.txOutIndex;
return txIn;
};
const {includedUnspentTxOuts, leftOverAmount} = findTxOutsForAmount(amount, myUnspentTxouts);
const unsignedTxIns: TxIn[] = includedUnspentTxOuts.map(toUnsignedTxIn);

接下来创建两个交易输出:一个给接收者,一个给发送者找零,可能为0。

1
2
3
4
5
6
7
8
9
const createTxOuts = (receiverAddress:string, myAddress:string, amount, leftOverAmount: number) => {
const txOut1: TxOut = new TxOut(receiverAddress, amount);
if (leftOverAmount === 0) {
return [txOut1]
} else {
const leftOverTx = new TxOut(myAddress, leftOverAmount);
return [txOut1, leftOverTx];
}
};

最后计算交易Id,并对交易进行签名:

1
2
3
4
5
6
7
8
9
const tx: Transaction = new Transaction();
tx.txIns = unsignedTxIns;
tx.txOuts = createTxOuts(receiverAddress, myAddress, amount, leftOverAmount);
tx.id = getTransactionId(tx);

tx.txIns = tx.txIns.map((txIn: TxIn, index: number) => {
txIn.signature = signTxIn(tx, index, privateKey, unspentTxOuts);
return txIn;
});

使用钱包

增加一个接口用于操作钱包。

1
2
3
4
5
6
app.post('/mineTransaction', (req, res) => {
const address = req.body.address;
const amount = req.body.amount;
const resp = generatenextBlockWithTransaction(address, amount);
res.send(resp);
});

用户只需要提供地址和总数,节点会计算其他的信息。

小结

我们实现了一个原始的未加密的钱包。尽管算法不支持创建多余2笔的交易,但是应该注意区块链支持多个交易。你可以创建一个有效的交易将50个加密货币分为5,15,30个加密货币发送。这里需要手动通过/mineRawBlock接口发送。

而且,为了将交易加入到区块链中,你必须自己挖矿。节点目前还不能交换交易信息,并将交易信息打包到区块中,最后加入到区块链中。

交易中继

本节我们实现交易的中继。在比特币种,这些交易叫做未确认的交易。一般情况下,当某人想将一个交易加入到区块链中,他会向整个网络广播交易,希望其他节点可以通过挖矿将交易加入到区块链中。

这个特性对于一个可以运行的加密货币来说非常重要,因为你不需要自己去挖矿就可以交易。

节点要支持共享两个类型的数据:

  1. 区块链的状态(区块链中已经包含的区块和交易)
  2. 未确认的交易(还没有包含到区块链中的交易)

交易池

将未确认的交易存储到交易池中,在比特币中叫mempool。

1
let transactionPool: Transaction[] = [];

增加一个接口用于在本地的交易池中增加一笔交易,基于已经实现的钱包功能。优先使用这个方法作为将交易打包到区块链中的方法。

1
2
3
app.post('/sendTransaction', (req, res) => {
...
})

创建交易,并加入到交易池中,而不是尝试挖矿。

1
2
3
4
5
const sendTransaction = (address: string, amount: number): Transaction => {
const tx: Transaction = createTransaction(address, amount, getPrivateFromWallet(), getUnspentTxOuts(), getTransactionPool());
addToTransactionPool(tx, getUnspentTxOuts());
return tx;
};

广播

所有未确认的交易将被广播到整个网络,最终一些节点会挖矿将交易加入到区块链中。需要引入一下的规则来处理未确认的交易:

当一个节点收到一个之前没有收到的未确认的交易时,首先将整个未确认交易广播到其他节点。

当一个节点第一次连接到其他节点,将查询该节点的交易池。需要增加消息类型:

1
2
3
4
5
6
7
enum MessageType {
QUERY_LATEST = 0,
QUERY_ALL = 1,
RESPONSE_BLOCKCHAIN = 2,
QUERY_TRANSACTION_POOL = 3,
RESPONSE_TRANSACTION_POOL = 4
}

交易池消息将通过下面的方法创建:

1
2
3
4
5
6
7
8
9
const responseTransactionPoolMsg = (): Message => ({
'type': MessageType.RESPONSE_TRANSACTION_POOL,
'data': JSON.stringify(getTransactionPool())
});

const queryTransactionPoolMsg = (): Message => ({
'type': MessageType.QUERY_TRANSACTION_POOL,
'data': null
});

当收到未确认的交易时,我们尝试加入到我们的交易池中,这意味着这个未确认交易是有效的而且之前没有出现过。然后广播到其他节点。

1
2
3
4
5
6
7
8
9
10
11
12
case MessageType.RESPONSE_TRANSACTION_POOL:
const receivedTransactions: Transaction[] = JSONToObject<Transaction[]>(message.data);
receivedTransactions.forEach((transaction: Transaction) => {
try {
handleReceivedTransaction(transaction);
//if no error is thrown, transaction was indeed added to the pool
//let's broadcast transaction pool
broadCastTransactionPool();
} catch (e) {
//unconfirmed transaction not valid (we probably already have it in our pool)
}
});

验证收到的未确认的交易

在加入交易池之前需要验证交易是否有效。所有已经存在的交易规则都需要进行验证,如必须格式正确,交易输出,输入和签名必须匹配。

还需要增加新的规则,如果交易池中已经存在交易输入,则不能加入到交易池中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const isValidTxForPool = (tx: Transaction, aTtransactionPool: Transaction[]): boolean => {
const txPoolIns: TxIn[] = getTxPoolIns(aTtransactionPool);

const containsTxIn = (txIns: TxIn[], txIn: TxIn) => {
return _.find(txPoolIns, (txPoolIn => {
return txIn.txOutIndex === txPoolIn.txOutIndex && txIn.txOutId === txPoolIn.txOutId;
}))
};

for (const txIn of tx.txIns) {
if (containsTxIn(txPoolIns, txIn)) {
console.log('txIn already found in the txPool');
return false;
}
}
return true;
};

没有显式的方法从交易池中移除交易。交易池在每次收到新的区块时更新。

从交易池到区块链

当节点启动时,会开始挖矿,交易池中的交易加入到候选的区块中。

1
2
3
4
5
const generateNextBlock = () => {
const coinbaseTx: Transaction = getCoinbaseTransaction(getPublicFromWallet(), getLatestBlock().index + 1);
const blockData: Transaction[] = [coinbaseTx].concat(getTransactionPool());
return generateRawNextBlock(blockData);
};

由于交易已经在加入交易池时验证过,这里不再进行验证。

更新交易池

当一个新的区块生成,必须重新验证交易池中的交易,以防新的区块是的交易池中的交易变的无效。如交易池中的交易
已经通过挖矿加入到了区块链中;未花费的交易已经被其他交易使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const updateTransactionPool = (unspentTxOuts: UnspentTxOut[]) => {
const invalidTxs = [];
for (const tx of transactionPool) {
for (const txIn of tx.txIns) {
if (!hasTxIn(txIn, unspentTxOuts)) {
invalidTxs.push(tx);
break;
}
}
}
if (invalidTxs.length > 0) {
console.log('removing the following transactions from txPool: %s', JSON.stringify(invalidTxs));
transactionPool = _.without(transactionPool, ...invalidTxs)
}
};

可以看出,我们只需要知道当前未花费的交易输出就可以判断一个交易是否可以从交易池中移除。

小结

现在我们不需要自己挖矿就可以将交易加入到区块链中。但是由于我们并没有实现交易费用,所以打包交易到区块链并没有奖励。

钱包UI界面和区块链浏览器

Overview

这一小节我们创建钱包的UI界面和区块链浏览器。为了实现这些,我们必须增加额外的接口:

  1. 查询区块和交易信息。
  2. 查询特定地址。

新的接口

查询指定地址的区块。

1
2
3
4
app.get('/block/:hash', (req, res) => {
const block = _.find(getBlockchain(), {'hash' : req.params.hash});
res.send(block);
});

查询特定交易:

1
2
3
4
5
6
7
app.get('/transaction/:id', (req, res) => {
const tx = _(getBlockchain())
.map((blocks) => blocks.data)
.flatten()
.find({'id': req.params.id});
res.send(tx);
});

查询特定钱包地址的信息,这里我们返回未花费交易列表以及余额信息:

1
2
3
4
5
app.get('/address/:address', (req, res) => {
const unspentTxOuts: UnspentTxOut[] =
_.filter(getUnspentTxOuts(), (uTxO) => uTxO.address === req.params.address)
res.send({'unspentTxOuts': unspentTxOuts});
});

还需要增加对于特定地址花费的交易输出的列表,用于展示指定地址的交易记录。

前端技术

使用Vue.js实现前端界面部分。

区块链浏览器

区块链浏览器就是一个网站可以浏览区块链的状态。一个典型用途是查询指定地址的余额,或者验证指定的交易是否包含在区块链中。

这里我们只是通过一个HTTP请求查询区块链中的信息,不提供修改接口。

钱包界

用户可以通过界面发送加密货币和查看地址余额。还返回交易池信息。

参考资料

  1. Naivecoin: a tutorial for building a cryptocurrency: https://lhartikk.github.io/
  2. Code: https://github.com/lhartikk/naivecoin/tree/master