实现Blockchain/Bitcoin/Ethereum最全教程(第一部分)


(alphaplus) #1

我们已经委托“维权骑士”为所有BitTiger的内容维权。**未经允许,禁止转载。**如需转载,请私信 @Ray Cao。文章比较长,超出了知乎专栏的字数限制,因此分为了几个部分:


Blockchain是什么?保存持续增长的记录的分布式数据库。

如何才能实现这样一个系统呢?那么我们需要回答几个核心问题。

如何保存数据?

保存数据的困难有哪些?

  • 数据逐渐增加,如何应对?
  • 如何对之前的数据进行修改?

解决方案

  • 如同记录log,不断的记录新的数据,然后将这些数据连接起来

  • 也就是LinkedList

    Class DataBlock {
    getData();
    setData();
    getPre();
    getNext();
    }

数据能否被修改呢?因为如果支持修改,可能引发很多的同步问题。那么能否不支持修改呢?实际上,GFS的整体架构就是构建在不可修改的数据结构之上。

于是,我们推论出构建不可修改的串联的数据块。但是我们要注意的是虽然底层数据不能修改,但是上层的数据视图是可以修改的。

如何让很多人都对数据有信心,相信是真实的,不能被篡改呢?每个人都保存一份同样的数据。

如何添加数据呢?

我们添加的每一份数据都需要按照同样的方式添加到每个人保存的副本中。

我们能否具有计算能力呢?为什么需要计算能力?我们从此可以得到一个通用的计算机,也把数据的修改模型更加地简化和通用化。

我们如何定义计算能力呢?要回答这个问题,我们首先要想的是这个分布式的计算机的各个部分是如何构成的。

谁来构成整个存储空间?每一个具体的地址。每一个地址保存了什么?数据。如何才能对地址计算呢?我们可以把对数据的处理逻辑也放入这个地址。那么一个地址到底需要什么呢?地址信息、�财富信息、数据信息、代码。

于是,所谓的状态就是指系统中每一个地址和地址对应的状态的集合。我们通过一个一个的交易来进入新的状态。

接着, 我们可以把状态转移的过程也记录下来,这个就是记录transaction的block。这些block连接在一起,形成blockchain。

如何应对同时写入的混乱?

如何防止很多人一起写造成的混乱呢?大家同时解决一个难题,谁先解出来,谁就能够写入。

如何防止有人同时解出来?这个有可能,但是连续多次都是同时有人解出来的概率较低,于是选择链最长的那一个。


NaiveCoin实战

我们在上一节介绍了BlockChain的基本原理,让我们在这里通过实际的代码进一步实战,实现一个支持Token的BlockChain。

如何保存数据?

区块链的基本概念是:保存持续增长的有序数据的分布式数据库。那么,我们需要满足以下功能:

  • 定义数据块
  • 定义数据块之间的关系
  • 添加数据块的功能
  • 节点之间的通讯
  • 节点之间同步数据
  • 对节点的控制能力

区块的结构是什么?

因为区块链中的数据是相互连接的数据块,因此我们需要创建LinkedList来实现这样的场景。

![](data:image/svg+xml;utf8,)

(来源: NaiveCoin

如上图所示,我们可以看到以下核心元素:

  • index:区块的递增编号
  • data:该区块所保存的数据
  • timestamp:时间戳
  • hash:通过SHA256算法对该区块进行的签名
  • previousHash:前一个数据块的hash值,从而将区块串联了起来

于是我们可以得到对应的代码:

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;
    }
}

如何保证数据不被篡改?

在计算机的世界中,一切都是用数学来解释,用数学来证明。对于一个数据块,我们计算出它的摘要。只要保证数据有变化,必然会引发摘要变化即可。在这里我们使用的是SHA256的Hash算法。从这个角度来说,Hash也可以被看成这块数据的DNA。

具体的Hash的过程如下,

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

聪明的读者可能会发现,这个简单的方法并不能防范更加复杂的黑客攻击。因此,我们会不断的改进我们的代码,以抵挡这个世界的某些恶意。

但是现在的我们至少能够通过Hash来唯一的验证一个区块链的结构了。为什么?因为我们在做Hash的时候,也把这个区块对应的上一个区块的Hash放了进来,因此如果有人想要篡改整个区块链上的任何一个区块,都会产生蝴蝶效应,后续的区块都会为止失效。

![](data:image/svg+xml;utf8,)

(来源: NaiveCoin

如上图所示,如果我们把区块44的数据从TREE改为STREET,那么它自身的Hash结果会改变,接着区块45中的previousHash也会发生改变,于是区块45的Hash也会改变,以此类推。因此,越早的区块发生异常,那么带来的影响就会越大。

如何创建第一个区块

第一个数据块的难点在哪里?它没有previousHash!因此,我们直接硬编码即可。

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

如何创建新的区块

创建区块如同炒菜,需要备好所有的原料。如同以下的代码所示,我们需要找到最后一个有效的区块,推理出下一个区块的index,得到当前的时间戳,再结合上一个区块的hash和当前的数据,也就能知道当前区块的hash,从而创建出新的区块。

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;
};

区块保存在哪里

在当前的版本中,我们只是保存在JavaScript所使用的内存中,因此很容易丢失,但是我们可以逐渐完善,让数据的保存越来越持久。

const blockchain: Block[] = [genesisBlock];

如何验证数据的有效性

在任何一个时刻,如果其他人给了我们一个新的区块,我们如何验证这个区块是正确的呢?这需要符合以下的基本要求:

  • 区块之间的索引是+1递增的
  • 当前区块的previousHash需要和之前区块的Hash相同
  • 区块自身的Hash需要正确

于是,我们实现的代码如下:

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;
};

这里还有一个细节,如果区块内数据的结构不正确,也可能是一个问题。因此我们还要额外进行判断。

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';
};

我们现在可以完整的验证一条区块链了吗?可以。我们首先要单独处理第一个区块,然后依次验证之后的每一个区块。

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;
};

区块链分叉了怎么办

在一个分布式的环境中,有可能不同的人在同一个区块后添加了新的不同的区块,那我们要听谁的呢?听大多数人的话(尽管现实中大多数人的话也许……)!那谁才能代表大多数的人民呢?实力更强大,更长的那一条链。因此在遇到多条链的时候,我们可以直接选择更长的一条。具体代码如下,

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');
    }
};

节点之间要如何通讯

因为在整个网络中有很多节点,大家都有可能去创建区块,这就需要大家通过协商通讯的方式达成共识,这需要以下三个基本能力:

  • 当个节点创建了一个区块,需要通知整个网络
  • 当一个节点连接上了一个新的节点,需要主动询问对方最新的区块
  • 当一个节点遇到一个新的区块,它会根据判断的结果向网络请求更多的区块

![](data:image/svg+xml;utf8,)

(来源: NaiveCoin

上图给出了节点通讯的具体流程。需要注意的是,在我们的代码中,所有的连接都被爆存在了 WebSocket[]。我们并没有实现节点发现的功能,因此节点的位置需要手动的添加。

如何控制节点

我们需要一个对外的接口来控制一个节点,从而能够查看节点的区块、添加区块、查看连通的节点、添加节点。于是我们通过以下代码实现了HTTP对外的服务接口。

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);
    });
};

于是,我们可以直接访问接口进行控制。例如,获得全部区块的列表。

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

系统的架构是什么

![](data:image/svg+xml;utf8,)

(来源: NaiveCoin

如上图所示,我们每个节点都会向外提供两个服务:

  • 让外部用户能够控制这个节点的HTTP server服务
  • 支持节点之间通讯的Websocket HTTP server服务

小结:如何保存数据

综上所述,我们已经构建了能够保存区块的区块链的服务结构,实现了创建区块和控制节点的基本能力。让我们继续添加更多的功能吧。


如何应对攻击

在我们已经实现的版本中,每个人都能在其中添加区块,这样不仅可能造成混乱,而且如果有人拼命的添加区块也会阻塞整个网络。

如何应对呢?那我们就限制每个人添加区块的能力吧。如何限制呢?记得你在网站每次注册新账号的时候都会出现的验证码吗?我们只要让大家在每次添加区块的时候都要做一道“难题”即可。这就是Proof-of-Work的基本原理,而这个解题过程就被称之为挖矿。

因此,这个难题的设置会影响到节点添加区块的难度。越难的题会让我们越难添加区块,相对来说安全性会上升,但是延迟很可能增加。

如何设置不同难度的题目

一个好的题目要让计算机便于理解,运算规则相对简单,运算方式相对公平。于是结合Hash算法的题目被设计了出来:找到一个特定区块,这个区块的Hash需要有特殊的前缀。

这个前缀越特殊,难度就越大。于是我们可以定义出题目的难度difficulty为你所定义的特殊的前缀是由几个0组成。例如,如果你只要求找到的区块的Hash有一个0(difficulty=0),那么可能相对简单;但是如果你要求你找到的区块的Hash的前缀有10个0(difficulty=10),那么就有点难了。下图给出了更细节的展示。

![](data:image/svg+xml;utf8,)

(来源: NaiveCoin

我们可以相应的实现检查Hash是否满足difficulty的代码。

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

为了找到满足difficulty条件的Hash,我们需要对同一个区块计算出不同的Hash。但是这个Hash算法的一致性相矛盾。可是我们可以通过在区块中加入新的参数来实现Hash结果的变化,因为SHA256会因为数据的任何微小变化为完全变化。于是我们添加了一个叫做Nonce的参数,并且不断的改变这个参数直到挖到我们想要的Hash结果。于是一个区块的数据结构更新如下:

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;
    }
}

如何解一个难题

基于以上的分析,我们不断的增加Nonce的值,直到找到一个有效的Hash,具体代码如下:

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++;
    }
};

当我们找到了一个有效的Hash,就把这个区块广播给整个网络。

如何确定难度

虽然我们能够指定问题的难度,但是我们要如何设置难度呢?而且如何才能让网络的节点都认同这个难度呢?

让我们回归到区块链的本身。区块链无非是一个区块的链表,并且每隔一段时间会加入一个新的区块。而我们的题目就是在控制加入区块的难度,也就是加入的时间间隔,于是我们引入一个全局参数:

  • BLOCK_GENERATION_INTERVAL:定义了多久产生一个区块(Bitcoin是10分钟,Ethereum大概10-20秒)

但是随着环境的变化,例如有更多的节点加入网络,我们并不能一致维持这个时间,因此我们每隔一段时间需要调整一下难度,于是我们引入第二个全局参数:

  • DIFFICULTY_ADJUSTMENT_INTERVAL:定义了每隔多久调整一次难度(Bitcoin是2016个区块,Ethereum是更加动态的调整)

在我们的代码中,我们会设置间隔为10秒。

// in seconds
const BLOCK_GENERATION_INTERVAL: number = 10;

// in blocks
const DIFFICULTY_ADJUSTMENT_INTERVAL: number = 10;

于是在我们的区块链中每产生10个区块就会查看区块的生成频率是否满足我们的预期

BLOCK_GENERATION_INTERVAL * DIFFICULTY_ADJUSTMENT_INTERVAL。我们会根据预期和现实之间的差异决定如何调整难度。具体来说,我们判断差异是否到了2倍的范围,我们会对difficulty进行+1或者-1的操作。具体代码如下:

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分钟以内。

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

有人通过低难度产生超长链怎么办

我们在上一节讨论过当遇到分叉的时候,选择更长的一个。但是一个恶意节点可以产生一个很长的但是每个区块的难度都是最简单的分叉。这样怎么办?那就把选择标准从最长调整为最难的。也就是说,我们会选择累计解题难度最大的分叉,因为这背后所代表的人民的力量更加强大。

如何计算累计的难度呢?因为每加一个0,我们的计算难度的期望会乘以2,所以我们计算每个区块的2^difficulty来求得累计的难度,以此做为选择标准。

![](data:image/svg+xml;utf8,)

(来源: NaiveCoin

如上图所示,再A和B两个链条中,虽然B更短,但是因为B的难度更大,所以我们会选择B。

有一个需要注意的是,这里的关键是难度,而并非Hash的前置的0,因为我们有可能碰巧得到一个更多的0的情况。这个思路被称之为“中本聪共识”,是中本聪发明Bitcoin时的一个重要贡献。因为每个节点只有相对较小的Hash计算能力,因此他们会倾向于选择累计难度更长的链条来贡献自己的力量,从而让自己的贡献得到认可。

小结:如何应对攻击

Proof-of-work的特点在于难于计算,易于验证。因此寻找特定前缀的SHA256成为一个很好的难题。

我们已经在代码中加入了难度,并且节点可以通过挖矿来把区块添加到区块链中。


知乎专栏字数有限,更多内容,请移步到BitTiger知乎专栏,至本文第二部分查看。

更多资源,请至www.BitTiger.io查看。


参考资料