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


(cdbdyx) #1

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


如何交易

我们在前两节实现的区块链只是对数据的基本保存,如何能够在这个基础上构建金融体系?但是一个金融体系的基本需求是什么呢?

  • 所有权:一个人能够安全的拥有token
  • 交易权:一个人能够把自己的token和他人交易

但是我们的区块链是一个没有“信任”的分布式的网络,如何才能构建出“确定性”呢?这需要我们找到一个不可抵赖的证明体系。

如何证明你是你

其实证明自己往往是最难的,这需要我们落地到一个我们可以相信的事情。想一想古代碎玉为半,之后团圆相认的场景。在计算机的世界也是一样,我们把一块美玉的一半告诉全世界,然后把另一半藏在自己身上,这样之后你自己能够拼接处这块美玉。

但这背后的难点在于,别人有了你公布出来的一半的玉,是可以伪造另一半的。但是在计算机的世界里,公钥加密体系却没有这个缺陷。

你有两个钥匙:公钥和私钥。公钥是由私钥推演出来的,并且会公布给所有人。对于你自己发出去的信息,你都可以用你的私钥签名。其他人会收到你的信息和你的签名,然后他会用你的公钥来验证这个信息是否是通过你的私钥进行的签名。具体流程如下图所示。

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

(来源: NaiveCoin

具体来说,我们会选择椭圆曲线加密算法(ECDSA)。到目前为止,我们引入了密码学中的两个核心工具:

  • SHA256来支撑区块数据一致性验证
  • ECDSA来支撑用户账号的验证

公钥和私钥长什么样

一个有效的私钥是一个32字节的字符串,示例如下:

19f128debc1b9122da0635954488b208b829879cf13b3d6cac5d1260c0fd967c

一个有效的公钥是由‘04’开头,紧接着64个字节的自负换,示例如下:

04bfcab8722991ae774db48f934ca79cfb7dd991229153b9f732ba5334aafcd8e7266e47076996b55a14bf9913ee3145ce0cfc1372ada8ada74bd287450313534a

公钥是由私钥演绎得到的,我们可以直接把它做为区块链中一个用户的账号地址。

如何记录一次交易

我们已经能够让用户证明自己是谁了,现在就要记录他们之间的交易了。我们需要三个信息

  • 从哪里来:发送者地址
  • 到哪里去:接收者地址
  • 交易多少:数量

即便如此,我们依然有个疑问?发送者如何证明自己有这个token呢?那么他就需要提供之前他获得这个token的证据。于是我们还需要第四个信息:指向自己的证据的指针。一个例子如下图所示。

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

(来源: NaiveCoin

于是我们需要两个结构分别表示交易的发起者和交易的接收者。

接收者长什么样

对于接受者,我们需要知道他的地址和交易的数量。如上一节所述,地址是ECDSA 的公钥。这意味着,我们还需要保证只有对应私钥的拥有者才能进一步操作这些token。这个结构体的代码如下:

class TxOut {
    public address: string;
    public amount: number;

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

发起者长什么样

交易的发起者需要提供自己token来源的证据,也就是指向之前的交易。但是他要证明自己对这个交易的拥有权,因此需要提供通过自己私钥加密的签名。这个结构体的代码如下:

class TxIn {
    public txOutId: string;
    public txOutIndex: number;
    public signature: string;
}

需要注意的是这里保存的只是通过私钥进行的签名,而不是私钥本身。在区块链的整个系统中,仅仅存在他的公钥和签名,而不会出现他的私钥。

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

(来源: NaiveCoin

如上图所示,整个过程就是发起者解锁了txIns中的tokens,然后把它们转给了TxOut中的接收者。

完整的交易长什么样

结合之前的讨论,我们可以构建出最终的交易:

class Transaction {
    public id: string;
    public txIns: TxIn[];
    public txOuts: TxOut[];
}

如何唯一表示一次交易

我们依然可以使用SHA256来进行Hash,并且使用这个Hash来做为交易的id。这里要注意的是我们并没有包含发起者的签名,这个会在之后添加。

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

如何对交易进行签名

因为在区块链中所有的交易都是公开的,因此要保证没有人能够利用这些交易进行攻击。于是我们需要对所有敏感的信息都进行签名。具体代码如下:

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

但是我们会发现这里只是对交易id进行了签名,这样足够了吗?

一种潜在的攻击方式如下:当攻击者CCC收到一个交易:从地址AAA向地址BBB发送10个token,交易id为0x555…。他会尝试把接受者修改为自己,然后把这个交易发送到网络中。于是这个消息变成了:从地址AAA向地址CCC发送10个token。但是,当另一个节点DDD接收到这个交易信息之后,会进行验证,他首先计算交易id。但是这时候因为接受者被改变了,因此交易id也会改变,例如成为了0x567…。于是发现问题。

及时攻击者也修改了id为0x567…,但是AAA只是对0x555…进行了签名,因此签名的数据会不匹配。因此,攻击者也会被识破。

到目前为止,整个协议看似是安全的。

如何找到用户拥有的token

在一起交易中,发起者需要提供自己所拥有的没有使用的token。因此,我们需要从当前的区块链中找到这些信息,于是我们需要维持整个系统中没有花费掉token的情况。这样的数据结构如以下代码所示:

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

我们进一步可以把系统中所有未花费的token记录在一个数组中:

let unspentTxOuts: UnspentTxOut[] = [];

如何更新未花费的数据信息

我们什么时候更新呢?当新的区块产生的时候。因为这个区块里会包含新的交易信息。因此,我们需要从新的区块中找到所有未花费的token的信息,并且记录在newUnspentTxOuts之中,代码如下:

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), []);

我们同时也要知道哪些未被花费的token被花费掉了,这个被记录在consumedTxOuts,代码如下:

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

最终我们通过删除已经花费的并且加上新的未话费的,从而产生了新的未话费数组resultingUnspentTxOuts,具体代码如下:

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

以上逻辑通过updateUnspentTxOuts的方法来实现。需要注意的是这个方法要在验证了区块正确性的基础上再来执行,否则会产生各种风险。

如何验证交易的有效性

刚才提到了,我们需要验证交易的有效性,要如何做呢?这背后要思考的是有什么情况会产生异常。

交易结构异常怎么办?我们需要判断交易的结构如何符合我们的标准。

const isValidTransactionStructure = (transaction: Transaction) => {
        if (typeof transaction.id !== 'string') {
            console.log('transactionId missing');
            return false;
        }
        ...
       //check also the other members of class
    }

交易id异常怎么办?我们需要进行判断。

if (getTransactionId(transaction) !== transaction.id) {
        console.log('invalid tx id: ' + transaction.id);
        return false;
    }

发起者信息异常怎么办?我们可以对签名进行判断,同时也要判断token尚未被花费。

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

交易数量异常怎么办?我们需要对发起者标注的未花费的数量和交易的实际数量进行对比,查看两者是否相等。

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

区块链的token最初从哪里来

我们可以不断的回溯每一个交易,但是最初的交易的token从哪里来呢?这需要我们定义无中生有的基础交易。

在基础交易中,它只有接收者,而没有发起者。这如同国家银行印刷了新的钞票。在我们的区块链中,将其定义为50。

const COINBASE_AMOUNT: number = 50;

这个没有起点的交易从哪里来呢?来自于我们对支撑系统的“矿工”的奖励。每当你挖出一个区块,系统会奖励你50个token。

我们要如何保存这些初始的奖励呢?可以添加一个额外的标志符。因为这个奖励是连同区块一起产生的,所以我们可以使用区块的id。

但是我们需要一些特殊的方法来验证这类初始奖励的有效性:

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

小结:如何交易

我们在本节讨论了如何在区块链中支持交易。核心概念是每个交易把一些未花费的token转换了新的主人。我们是通过一个人的私钥来定义归属权的。

但是我们依然需要手动的创建交易,因此我们会在下一章介绍如何实现钱包。


如何实现钱包

我们已经有了token,如何让用户更容易的管理自己的token并进行交易呢?我们需要支持什么样的核心功能?

  • 创建一个新钱包
  • 查看钱包的余额
  • 在钱包之间进行交易

在Bitcoin中你可以通过钱包管理自己的coin,在Ethereum中你也可以用钱包管理自己的各类token。

如何创建钱包

钱包的基础是什么?公钥和私钥。因此我们需要首先创建用户的这两把钥匙。首先是私钥,并且要保存在本地:node/wallet/private_key。

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

在这个基础上,我们可以通过私钥创建公钥。

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

需要注意的是把私钥保存在本地是一件很不安全的事情。虽然我们这里只是一个简化的版本,但是也有很多更保险的方法。因此,请善待你的私钥吧。

如何显示余额

所谓的余额,不过是一些未花费的交易的接收者的记录。那么要如何定位这些记录呢?用户的公钥。因此当你定位到之后只需要对记录求和即可。

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

上述代码首先基于公钥定位到了记录,然后进行了求和。

如何进行交易

如何能够屏蔽底层的发起者和接收者等复杂概念来简单的使用呢?而且我们的底层支持的是把发起者包括的所有token都给予接收者。如果发起者有50个token,但是指向转移10个呢?这时候需要我们把剩余的40个token还给发起者。具体场景如下图所示:

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

(来源: NaiveCoin

这个过程甚至能够更加负责,例如:

  • 用户C开始只有0个token
  • 之后的三个交易让C分别获得了10、20、30个token
  • C想要给D转发55个token

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

(来源: NaiveCoin

这个场景如上图所示,我们要如何做呢?具体来说我们需要把这三次交易的总token拆成两份,其中的55个给D,另外的5个还给C。

如何实现这个代码呢?我们首先在C所有未花费的交易中不断的累积token,直到总和达到或者超过目标值。

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

如代码所示,我们还记录了额外多出来的数量,我们之后会把它还给C。

因为我们有了需要使用的未花费的交易,于是我们能够创建发起者的数据了。

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

然后我们可以把对应的token分别给予D和C,也就是一个是我们的接受者,一个是还给发起者。当然,如果token恰好不多不少,我们就不需要归还了。

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

我们现在可以构建交易并且签名了。

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

如何使用钱包

我们现在构建使用钱包的一个外部接口。

app.post('/mineTransaction', (req, res) => {
        const address = req.body.address;
        const amount = req.body.amount;
        const resp = generatenextBlockWithTransaction(address, amount);
        res.send(resp);
    });

用户只需要提供接收者地址和交易数量就可以使用钱包了。

小结:如何实现钱包

我们实现了支持交易的钱包。虽然在使用中最多包括两个接收者,但实际上我们底层的接口支持更多复杂的场景。例如,把50个token分给三个不同的人。

但是现在你只能通过自己挖矿来添加新的区块,我们要如何才能更方便的使用呢?这是下一节的内容。


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

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


参考资料