科普向介绍请参考 tutorial。技术开发教程请参考本文。本系列文章更偏向技术入门。参考网上资料根据个人理解整理而成。本文主要参考廖雪峰博客。


前一篇文章地址:区块链开发教程 # 1 比特币公链
本文正式介绍区块链2.0时代,以太坊为代表的智能合约时代。

以太坊(Ethereum)是一个支持智能合约的区块链平台,它与Bitcoin最大的不同是,以太坊通过一个虚拟机(EVM)可以运行智能合约。以太坊是Vitalik Buterin(在2013年提出的概念,从2015年正式启动并运行,基于PoW,目前每个区块奖励是2 Ether,约13~15秒左右出一个块。

特点:

  • 以太坊的账户模型就是余额模型,不是UTXO。
  • 以太坊支持智能合约(图兰完备的脚本语言solidity,运行在Ethereum Virtual Machine,以太坊虚拟机上)

1 账户

以太坊账户分为外部账户和合约账户两类:

  • 外部账户:即普通用户用私钥控制的账户;
  • 合约账户:一种拥有合约代码的账户,它不属于任何人,也没有私钥与之对应。

本节仅讨论外部账户。一个以太坊账户就是一个公钥哈希后得到的地址,同样由一个私钥推导出对应的公钥,然后再计算出地址。私钥与公钥算法与比特币完全相同,均为secp256k1椭圆曲线,但和比特币不同的是,以太坊采用非压缩公钥,然后直接对公钥做keccak256哈希,得到32字节的哈希值,取后20字节加上0x前缀即为地址:

const
    randomBytes = require('randombytes'),
    ethUtil = require('ethereumjs-util');
// 生成256bit的随机数作为私钥:
let priKey = randomBytes(32).toString('hex');
// 计算公钥(非压缩格式):
let pubKey = ethUtil.privateToPublic(new Buffer(priKey, 'hex')).toString('hex');
// 计算地址:
let addr = ethUtil.pubToAddress(new Buffer(pubKey, 'hex')).toString('hex');

以太坊对私钥和地址均采用十六进制编码,因此没有校验,如果某一位写错了,仍然是一个有效的私钥或地址。keccak256哈希算法在以太坊中也被称为SHA3算法,但是要注意,keccak算法原本是SHA3的候选算法,然而在SHA3最后的标准化时,对 keccak 做了改进,因此,标准的SHA3算法和keccak是不同的,只是以太坊在开发时就选择了尚未成为SHA3标准的keccak算法。后续我们在讨论以太坊的哈希算法时,一律使用keccak256而不是SHA3-256。

  • 带校验的地址
    以太坊通过EIP-55实现了一个带校验的地址格式:对地址做一个keccak256哈希,然后按位对齐,将哈希值>=8的字母变成大写:

    以太坊地址就是依靠部分变成大写的字母进行校验,它的好处是带校验的地址和不带校验的地址对钱包软件都是一样的格式,缺点是有很小的概率无法校验全部小写的地址。

下面这个程序可以自动搜索指定前缀地址的私钥:

const randomBytes = require('randombytes');
const ethUtil = require('ethereumjs-util');

let prefix = '0xAA';
if (/^0x[a-fA-F0-9]{1,2}$/.test(prefix)) {
    let
        max = parseInt(Math.pow(32, prefix.length-2)),
        qPrefix = prefix.toLowerCase().substring(2),
        prettyPriKey = null,
        prettyAddress = null,
        priKey, pubKey, addr, cAddr, i;

    for (i=0; i<max; i++) {
        priKey = randomBytes(32).toString('hex');
        pubKey = ethUtil.privateToPublic(new Buffer(priKey, 'hex')).toString('hex');
        addr = ethUtil.pubToAddress(new Buffer(pubKey, 'hex')).toString('hex');
        if (addr.startsWith(qPrefix)) {
            cAddr = ethUtil.toChecksumAddress('0x' + addr);
            if(cAddr.startsWith(prefix)) {
                prettyPriKey = priKey;
                prettyAddress = cAddr;
                break;
            }
        }
    }

    if (prettyPriKey === null) {
        console.error('Not found.');
    } else {
        console.log('Private key: 0x' + prettyPriKey);
        console.log('Address: ' + prettyAddress);
    }
} else {
    console.error('Invalid prefix.');
}

HD钱包
因为以太坊和比特币的非对称加密算法是完全相同的,不同的仅仅是公钥和地址的表示格式,因此,比特币的HD钱包体系也完全适用于以太坊。用户通过一套助记词,既可以管理比特币钱包,也可以管理以太坊钱包。

以太坊钱包的派生路径是m/44’/60’/0’/0/0,用代码实现如下:

const
    bitcoin = require('bitcoinjs-lib'),
    bip39 = require('bip39'),
    ethUtil = require('ethereumjs-util');

// 助记词和口令:
let
    words = 'bleak version runway tell hour unfold donkey defy digital abuse glide please omit much cement sea sweet tenant demise taste emerge inject cause link',
    password = 'bitcoin';

// 计算seed:
let seedHex = bip39.mnemonicToSeedHex(words, password);
console.log('seed: ' + seedHex); // b59a8078...c9ebfaaa

// 生成root:
let root = bitcoin.HDNode.fromSeedHex(seedHex);
console.log('xprv: ' + root.toBase58()); // xprv9s21ZrQH...uLgyr9kF
console.log('xpub: ' + root.neutered().toBase58()); // xpub661MyMwA...oy32fcRG

// 生成派生key:
let child0 = root.derivePath("m/44'/60'/0'/0/0");
let prvKey = child0.keyPair.d.toString(16);
let pubKey = ethUtil.privateToPublic(new Buffer(prvKey, 'hex')).toString('hex');
let address = '0x' + ethUtil.pubToAddress(new Buffer(pubKey, 'hex')).toString('hex');
let checksumAddr = ethUtil.toChecksumAddress(address);

console.log("       prv m/44'/60'/0'/0/0: 0x" + prvKey); // 0x6c03e50ae20af44b9608109fc978bdc8f081e7b0aa3b9d0295297eb20d72c1c2
console.log("       pub m/44'/60'/0'/0/0: 0x" + pubKey); // 0xff10c2376a9ff0974b28d97bc70daa42cf85826ba83e985c91269e8c975f75f7d56b9f5071911fb106e48b2dbb2b30e0558faa2fc687a813113632c87c3b051c
console.log("      addr m/44'/60'/0'/0/0: " + address); // 0x9759be9e1f8994432820739d7217d889918f2f07
console.log("check-addr m/44'/60'/0'/0/0: " + checksumAddr); // 0x9759bE9e1f8994432820739D7217D889918f2f07

2 区块结构

  • 以太坊采用的是账户模型,如果小明的账户在某个区块的资产是1 ETH,当小明给小红转账0.2 ETH后,刨除手续费,他的账户还剩下约0.8 ETH。由于小明的账户地址不变,所以,以太坊的区块结构必须能在每个区块持续地跟踪并记录小明的账户余额变动。

以太坊存储账户数据的数据结构是MPT:Merkle Patricia Tree
当MPT的每个叶子结点的值确定后,计算出的Root Hash就是完全确定的。


每一个区块通过Root Hash将完全确定所有账户的状态,所以,从全局看,以太坊就是一个状态机,每个区块通过记录一个 stateRoot 来表示一个新状态。如果给定某个区块的 stateRoot,我们肯定能完全确定所有账户的所有余额等信息。因此,stateRoot 就被称为当前的世界状态。

如果第一个区块只有几个账户,随着账户的增加,如果有数百万个账户,到后面岂不是区块存储的数据量会越来越大?
实际上,每个区块的stateRoot表示的是一个完全状态的逻辑树,但每个区块记录的数据只包括修改的部分,如果我们观察第二个区块的树,它实际上只记录修改的两个账户,以及两个账户因修改后导致的上层路径的Hash发生的变化:

想要将一个有几百万节点的树完整地放入内存需要消耗大量的内存,而以太坊全节点也并不会将整颗逻辑树放入内存。实际上,每个节点的数据被存放到LevelDB中,节点仅在内存中存储当前活动的一些账户信息。如果需要操作某个不在内存的账户,则会将其从LevelDB加载到内存。如果内存不够,也会将长期不活动的节点从内存中移除,因为将来可以通过节点的路径再次从LevelDB加载。

  • 账户数据
    nonce 递增的整数,每发送一次交易,nonce递增1,因此,nonce记录的就是交易次数
    balance 账户余额,以wei为单位,1 Ether等于 1 0 18 10^{18} 1018wei
    合约账户还有下面二者:
    storageRoot 存储合约相关的状态数据
    codeHash 存储合约代码的Hash
  • 区块数据
    区块头除了记录parentHash(上一个区块的Hash)、stateRoot(世界状态)外,还包括:
    sha3Uncles:记录引用的叔块;
    transactionRoot:记录当前区块所有交易的Root Hash;
    receiptsRoot:记录当前区块所有交易回执的Root Hash;
    logsBloom:一个Bloom Filter,用于快速查找Log;
    difficulty:挖矿难度值;
    number:区块高度,严格递增的整数;
    timestamp:区块的时间戳(以秒为单位);
    ……
    transactionRoot和receiptsRoot也是两个MPT树,但他两和stateRoot不同,他们仅表示当前区块的两棵树,与前面的区块状态无关。

叔块

目前以太坊采用的是PoW挖矿(基于Ethash算法),会产生分叉,由于最长链共识,最终某个分叉将胜出。虽然#4的竞争导致一个胜出另一个失败,但以太坊鼓励后续的#5区块引用另一个废弃的#4块,这种引用的废弃块被称为叔块(Uncle Block):

区块头记录的sha3Uncles就是叔块,一个区块可引用0~2个叔块,且叔块高度必须在前7层之内。叔块的目的是给予竞争失败的矿工部分奖励,避免出现较长的分叉。

3 交易

以太坊交易需要手续费Gas(汽油)。以太坊给每一个虚拟机指令都标记了一个Gas基本费用,称为gasUsed。消耗CPU比消耗存储便宜,简单计算比复杂计算便宜,读取比写入便宜。
除了gasUsed外,用户还需要提供一个gasPrice,以Gwei(1Gwei= 1 0 9 10^9 109Wei)为单位。通过竞价得到一个矿工愿意接受的gasPrice。如果一个交易消耗了120000的gasUsed,而gasPrice是50 Gwei,则交易费用是:120000 x 50 Gwei = 6000000 Gwei = 0.006 Ether

实际上对一笔交易给出gasPricegasLimit,如果执行完成后有剩余,剩余费用退还,如果执行过程中花完gas,那么交易执行失败,但已执行的Gas不会退。

假定某个账户想执行一笔交易,他给出gasPrice为50 Gwei,预估gasUsed约120000,设定gasLimit为150000,则预支付的Ether为:150000 x 50 Gwei = 7500000 Gwei = 0.0075 Ether。如果账户的Ether余额不足0.0075,则该交易根本无法发送。如果账户余额等于或超过0.0075,例如余额为0.008,则矿工可以将该交易打包。假设实际执行消耗的gasUsed为120000,则交易费用0.006,账户剩余0.002。很少有交易能准确预估gasUsed,只有标准转账交易是21000,因此,标准的转账交易gasLimit可以设置为21000(即恰好消耗完毕无剩余)。

Gas Price是全网用户竞价产生的,它时刻在波动。如果交易少,Gas Price将下降,如果交易多,网络拥堵,则Gas Price将上升。以太坊的Gas价格可以在Etherscan跟踪。

  • 交易回执
    以太坊区块为每一笔交易都会产生一笔回执(Recipt),表示交易的最终状态。一个回执信息主要包括:
    status:执行结果,1表示成功,0表示失败;
    gasUsed:已消耗的Gas数量;
    txHash:交易Hash;
    logs:交易产生的日志;
    ……

以太坊交易分为:转账交易、合约交易(外部账号调用某个合约的某个public函数)。合约交易Gas只能预估,具体费用以实际执行后消耗的为准。

4 智能合约

以太坊相比比特币的一个重大创新就是它支持智能合约(Smart Contract)。智能合约,就是一种运行在区块链上的程序。和普通程序不同的是,智能合约要保证在区块链网络的每一个节点中运行的结果完全相同,这样才能使任何一个节点都可以验证挖矿产出节点生成的区块里,智能合约执行的结果对不对。

以太坊提供了一个EVM(Ethereum Virtual Machine)虚拟机来执行智能合约的字节码,并且,和普通程序相比,为了消除程序运行的不确定性,智能合约有很多限制,例如,不支持浮点运算(因为浮点数有不同的表示方法,不同架构的CPU运行的浮点计算精度都不同),不支持随机数不支持从外部读取输入等等。

一个智能合约被编译后就是一段EVM字节码,将它部署在以太坊的区块链时,会根据部署者的地址和该地址的nonce分配一个合约地址,合约地址和账户地址的格式是没有区别的,但合约地址没有私钥,也就没有人能直接操作该地址的合约数据要调用合约,唯一的方法是调用合约的公共函数。

这也是合约的一个限制:合约不能主动执行,它只能被外部账户发起调用。如果一个合约要定期执行,那只能由服务器定期发起合约调用。

合约作为地址,可以接收Ether,也可以发送Ether。合约内部也可以存储数据。合约的数据存储在合约地址关联的存储上,这就使得合约具有了状态,可以实现比较复杂的逻辑,包括存款、取款等。合约在执行的过程中,可以调用其他已部署的合约,前提是知道其他合约的地址和函数签名,这就大大扩展了合约的功能。例如,一个合约可以调用另一个借贷合约的借款方法,再调用交易合约,最后再调用还款方法,实现所谓的“闪电贷”(即在一个合约调用中实现借款-交易-还款)功能。多个合约的嵌套调用也使得因为代码编写的漏洞导致黑客攻击的可能性大大增加。为了避免漏洞,编写合约时需要更加小心。

编写合约

这里用投票合约为示例。

  • 一个Solidity文件可以包含多个合约,一般还是一个文件一个合约,且文件名保持与合约一致。
// SPDX-License-Identifier: GPL-3.0

pragma solidity =0.8.7;

contract Vote {

    event Voted(address indexed voter, uint8 proposal);

    mapping(address => bool) public voted;

    uint256 public endTime;

    uint256 public proposalA;
    uint256 public proposalB;
    uint256 public proposalC;

    constructor(uint256 _endTime) {
        endTime = _endTime;
    }

    function vote(uint8 _proposal) public {
        require(block.timestamp < endTime, "Vote expired.");
        require(_proposal >= 1 && _proposal <= 3, "Invalid proposal.");
        require(!voted[msg.sender], "Cannot vote again.");
        voted[msg.sender] = true;
        if (_proposal == 1) {
            proposalA ++;
        }
        else if (_proposal == 2) {
            proposalB ++;
        }
        else if (_proposal == 3) {
            proposalC ++;
        }
        emit Voted(msg.sender, _proposal);
    }

    function votes() public view returns (uint256) {
        return proposalA + proposalB + proposalC;
    }
}

以太坊合约支持读、写两种类型的成员函数,以view修饰的函数是只读函数,它不会修改成员变量,即不会改变合约的状态。没有view修饰的函数是写入函数,它会修改成员变量,即改变了合约的状态。
合约可以定义事件(Event),我们在Vote合约中定义了一个Voted事件。只定义事件还不够,触发事件必须在合约的写函数中通过emit关键字实现。当调用vote()写方法时,会触发Voted事件。事件可用来通知外部感兴趣的第三方,他们可以在区块链上监听产生的事件,从而确认合约某些状态发生了改变。

构造函数在部署合约时就会立刻执行,且仅执行一次。合约部署后就无法调用构造函数。

任何外部账户都可以发起对合约的函数调用。如果调用只读方法,因为不改变合约状态,所以任何时刻都可以调用,且不需要签名,也不需要消耗Gas。但如果调用写入方法,就需要签名提交一个交易,并消耗一定的Gas。

在一个交易中,只能调用一个合约的一个写入方法。无需考虑并发和同步的问题,因为以太坊交易的写入是严格串行的。

以太坊合约具备类似数据库事务的特点,如果中途执行失败,则整个合约的状态保持不变,不存在修改某个成员变量后,后续断言失败导致部分修改生效的问题。

部署合约

部署合约也是一个交易,需要一个外部账户,花费一定的Gas,就可以把合约部署到链上。
在开发阶段,直接使用主网太费钱,可以使用Ropsten测试网,并从faucet.ropsten.be或faucet.dimensions.network获取一些测试网的Ether。

以太坊官方提供了一个Remix的在线IDE,用于编写、编译和部署以太坊合约。这是从零开始部署一个合约的最简单的方式。基于remix部署: ref

可以使用Truffle这个JavaScript工具通过JavaScript脚本全自动部署合约,减少手动操作导致的出错的可能。

调用合约

访问合约的只读函数时,无需消耗Gas,也无需连接钱包,直接切换到Etherscan的“Read”面板,即可看到只读函数的返回值:

写操作:

编写DAPP

Dapp基础架构:

以太坊的区块链网络实际上是一个由若干节点构成的P2P网络,所谓读写合约,实际上是向网络中的某个节点发送JSON-PRC请求。当我们想要做一个基于Vote合约的Dapp时,我们需要开发一个页面,并连接到浏览器的MetaMask钱包,这样,页面的JavaScript就可以通过MetaMask读写Vote合约,页面效果如下:

第一步,引入相关库,这里我们引入ethers.js这个库,它封装了读写合约的逻辑。

<script src="https://cdn.jsdelivr.net/npm/ethers@5.0.32/dist/ethers.umd.min.js"></script>

第二步,我们需要获取MetaMask注入的Web3,可以通过一个简单的函数实现:

function getWeb3Provider() {
    if (!window.web3Provider) {
        if (!window.ethereum) {
            console.error("there is no web3 provider.");
            return null;
        }
        window.web3Provider = new ethers.providers.Web3Provider(window.ethereum, "any");
    }
    return window.web3Provider;
}

第三步,在用户点击页面“Connect Wallet”按钮时,尝试连接MetaMask:

async function () {
    if (window.getWeb3Provider() === null) {
        console.error('there is no web3 provider.');
        return false;
    }
    try {
        // 获取当前连接的账户地址:
        let account = await window.ethereum.request({
            method: 'eth_requestAccounts',
        });
        // 获取当前连接的链ID:
        let chainId = await window.ethereum.request({
            method: 'eth_chainId'
        });
        console.log('wallet connected.');
        return true;
    } catch (e) {
        console.error('could not get a wallet connection.', e);
        return false;
    }
}

最后一步,当我们已经连接到MetaMask钱包后,即可写入合约。写入合约需要合约的ABI(Application Binary Interface) 信息,即合约函数调用的接口信息,这些信息在Remix部署时产生。在Remix的contracts-artifacts目录下找到Vote.json文件,它是一个JSON,右侧找到"abi": […],把abi对应的部分复制出来:

以常量的形式引入Vote合约的地址和ABI:

const VOTE_ADDR = '0x5b2a057e1db47463695b4629114cbdae99235a46';
const VOTE_ABI = [{ "inputs": [{ "internalType": "uint256", "name": "_endTime", "type": "uint256" }], ...


async function vote(proposal) {
    // TODO: 检查MetaMask连接信息
    // 根据地址和ABI创建一个Contract对象:
    let contract = new ethers.Contract(VOTE_ADDR, VOTE_ABI, window.getWeb3Provider().getSigner());
    // 调用vote()函数,并返回一个tx对象:
    let tx = await contract.vote(proposal);
    // 等待tx落块,并至少1个区块确认:
    await tx.wait(1);
}
  1. 页面的JavaScript代码无法直接访问以太坊网络的P2P节点,只能间接通过MetaMask钱包访问;
  2. 钱包之所以能访问以太坊网络的节点,是因为它们内置了某些公共节点的域名信息;
  3. 如果用户的浏览器没有安装MetaMask钱包,则页面无法通过钱包读取合约或写入合约。

对于大多数的Dapp来说,是需要服务器端的,这是因为,当用户浏览器没有安装钱包,或者钱包并没有连接到Dapp期待的网络时,页面将无法获得合约的任何数据。例如,上述Dapp就无法读取到三种投票的数量,因此无法在页面上绘制对比图。如果部署一个服务器端,由服务器连接P2P网络的节点并读取合约,然后以JSON API的形式给前端提供相关数据,则可以实现一个更完善的Dapp。因此,完整的Dapp架构如下:


为Dapp搭建后端服务器时要严格遵循以下规范:
1.后端服务器只读取合约,不存储任何私钥,因此无法写入合约,保证了安全性;
2.后端服务器要读取合约,就必须连接到P2P节点,要么选择公共的节点服务(例如Infura),要么自己搭建一个以太坊节点(维护的工作量较大);
3.后端服务器应该通过合约产生的日志(即合约运行时触发的event)监听合约的状态变化,而不是定期扫描。监听日志需要通过P2P节点创建Filter并获取Filter返回的日志;
4.后端服务器应该将从日志获取的数据做聚合、缓存,以便前端页面能快速展示相关数据。

设计Dapp时,既要考虑将关键业务逻辑写入合约,又要考虑日志输出有足够的信息让后端服务器能聚合历史数据。前端、后端和合约三方开发必须紧密配合。

Reference

  1. 廖雪峰的教程
  2. 教程
  3. 区块链教程

Question

  1. 以太坊合约地址和账户地址是否有表示差异?会不会出现合约地址和账户地址重名情形。如果重名,接收地址A如何判断是合约地址A1还是账户地址A2?
Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐