阅读 1231

详解 ERC20 代币及众筹 - 熊丽兵 | Jeth 第一期

编者按:本文系 登链科技CTO 熊丽兵 讲师,在由掘金技术社区主办,以太坊社区基金会、以太坊爱好者与 ConsenSys 协办的《开发者的以太坊入门指南 | Jeth 第一期 - 北京场》 活动上的分享整理。Jeth 围绕以太坊技术开发主题的系列线下活动。每期 Jeth 会邀请以太坊开发领域的优秀技术团队和工程师在线下分享技术干货。旨在为开发者提供线下技术交流互动机会,帮助开发者成长。

熊丽兵老师本次活动分享视频回放(B站)

分享整理传送门

智能合约全栈介绍 - Howard | Jeth 第一期

以太坊智能合约 + DApp 从入门到上线:来自前端工程师的实战指南 - 王仕军 | Jeth 第一期

熊丽兵老师目前在登链科技担任 CTO,是全网访问量最大的区块链技术博客《深入浅出区块链》博主,对底层公链技术,区块链技术落地都有深入研究。熊老师曾先后加入创新工场及猎豹移动,全面负责数款千万级用户开发及管理工作,2014年作为技术合伙人参与创建酷吧时代科技,2016年起重心投入区块链技术领域。

很高兴参加掘金技术社区这次举办的《开发者的以太坊入门指南》活动,今天我带来的分享主题是通过代币和众筹来介绍智能合约的开发。我先做一下自我介绍,我叫熊丽兵,应该有一些人看过我的博客《深入浅出区块链》,我现在在登链科技担任 CTO。

我今天分享内容分为图上的四个部分,我是最后一个做分享的讲师,相信在场的观众坚持到现在有点疲惫,但是我的内容非常实用,有很多人拿我的代码已经筹了不少钱,或许我的代码对你也有帮助,希望大家能认真听。

Token 代币是什么

  • 币 → 钱
  • 代币 → 可以替代钱

代币,币其实就是钱,代币可以代替钱——这是我给代币下的一个定义。从这个定义出发,不管是比特币还是以太币都算代币。我们今天要讲的这个代币是基于以太坊的智能合约开发出来的。代币不单单是可以代替钱,还可以代替很多的东西,可以代替积分,也可以代替一本书或一首歌,以上的都可以用以太坊智能合约来代替。

智能合约

智能合约就是以太坊上的程序,是代码和数据(状态)的集合。 智能合约跟人工智能的“智能”是没有关系的,智能合约并不智能。智能合约最早是尼克萨博提出来的一个概念,是指把法律条文程序化,这个理念和以太坊上的程序非常类似,因为法律条文的执行不应该受到任何人的干涉与干扰;以太坊的程序也是一样的,只要你写好代码以后没有任何人可以干扰以太坊程序的运行。

智能合约有很多编程语言,最常见、被官方推荐的是 Solidity 语言,这个语言的后缀是 .sol 。上图是一个简单的 Hello World,其中 contract 对应着合约,我们可以把它理解为一个“类”,如果把 contract 变成 class,它就是定义一个“类”了,这跟我们写别的语言定一个“类”有点相似。因此这个合约的名字就叫做 Hello World,作用是返回一个字符串,这是一个最简单的智能合约。写合约的时候不像其他语言的有main方法,Solidity 中是没有的,每一个函数都是单独要用的。

如何实现代币

我们再来看看如何来实现一个代币,实现一个代币最根本的是要维护一个帐本。这里有一个简单的帐本,尾号122帐户里面有余额100,尾号123账户有120,如果我们要执行从尾号122的账户转账10块钱到尾号123的帐户的时候,是不是从这个100里面减掉10,从120里面加上10。如果要实现这样一个帐本应该怎么做?大家想一想,我们是不是可以把帐户这部分(账号)当成 key,把余额当成 value,这就是 ”键值对“,就是一个Map,或者叫字典。

Solidity里面有一个数据结构叫作 mapping ,我们可以用这个关键字 mapping 这样的结构来保存帐本信息,这里 Key 是一个地址或一个账号。 value 表示余额。 另外,我们要发币的话要设置发行量。以及我们需要有一个转账函数,从一个地址转到另外一个地址。

我们看一下如何实现最简单的代币,我们来看一下这个代币的代码,它只有15行。它定义了一个叫作My Token的合约,它用了mapping 键值对的数据结构,这个类型的键是address(地址类型),值是 uint 无符号型整数的。变量的名字叫作 balanceOf,balance 在这里指代余额。那我们这个合约里面有两个方法,一个叫做构造函数,另外一个是转帐的方法。构造函数来初始化发行量,最初的时候所有的发行的货币都在 owner 手里,我们通过msg.sender获得当前创建合约的人是谁。刚开始发行的时候,所有的代币都在 owner 自己手里,这个就像央行发行货币的时候,刚开始货币都在央行手里是一样的。

然后另外一个方法就是transfer,有两个参数:接受者的地址,以及发送的金额。看看具体的实现,第一个用来判断交易条件,即判断他有没有足够的钱完成交易。第二步是做一个溢出的判断,待会儿后面会有一个分析,就是目标这个帐户加上这个余额要大于他原来的帐户。假如说目标帐户的余额接近这个存储的上限,他加上一个值他可能会发生溢出,我们这里要判断是否会出现这种情况。

第三步和第四步就是做简单的减法和加法,在原帐户里面减去或加上一个金额,综上我们通过这十几行的代码就实现了代币。

ERC-20 标准

  • 什么是 ERC-20
  • 标准包含哪些内容 名称、发行量、统一函数名、事件名

我们接下来看一下 ERC-20,我们刚刚已经实现了这个代币,为什么还要有 ERC-20?如果钱包要支持代币的转帐也好,获取代币的名字也罢,都需要有统一的名字。ERC-20 其实是一个规范,大家可以点开上方 GitHub 链接中查看规范的具体内容。 ERC-20 包含了名称、发行量、统一的转帐名称、授权的函数名以及事件名。

pragma solidity 0.4.20;

contract ERC20Interface {
  string public name;
  string public symbol;
  uint8 public  decimals;
  uint public totalSupply;

  function transfer(address _to, uint256 _value) returns (bool success);
  function transferFrom(address _from, address _to, uint256 _value) returns (bool success);
  function approve(address _spender, uint256 _value) returns (bool success);
  function allowance(address _owner, address _spender) view returns (uint256 remaining);

  event Transfer(address indexed _from, address indexed _to, uint256 _value);
  event Approval(address indexed _owner, address indexed _spender, uint256 _value);
}
复制代码

这个是ERC20的一个接口文件,我们来具体看一下它有哪些内容:

name 是我们需要指定名字,比如说我们要发生一个掘金Token,它的名字叫做掘金Token。

symbol是 代币的符号,如常见的 BTC、ETH 等。

decimal 是代币最少交易的单位,它表示小数点的位数,如果最少可以交易0.1个代币的话,小数点位数就是1;假如最少交易一个代币,就没有小数点,那这个值就是0。

totalSupply 是指总发行量。

下面几个方法是用来进行转帐的: transfer 指代转账的目标地址,它会提供一个返回值是否转账成功。

transferFrom 由被委托人调用,由被委托人转移授权人的货币,

approve 是把权限委托给别人,括号里写的是被委托人的地址和被委托了多大的金额

allowance 可以返回被授权委托的金额,委托人可以查询剩下的金额。

TransferApproval 可以监听并记录事件信息,当事件发生时你可以得到通知。

实现ERC-20接口

接下来演示具体的 ERC-20 代码,因为ERC-20 Token的代码有点长,我们切换到remix看一下代码:

contract ERC20 is ERC20Interface {

    mapping (address => uint256) public balanceOf;
    mapping (address => mapping (address => uint256)) internal allowed;

    constructor() public {
        totalSupply = 1000;
        name = "JueJin Token";
        symbol = "JJT";
        decimals = 0;
        balanceOf[msg.sender] = totalSupply;
    }

  function balanceOf(address _owner) view returns (uint256 balance) {
      return balanceOf[_owner];
  }

  function transfer(address _to, uint _value) public returns (bool success) {
      require(_to != address(0));
      require(_value <= balanceOf[msg.sender]);
      require(balanceOf[_to] + _value >= balanceOf[_to]);

      balanceOf[msg.sender] -= _value;
      balanceOf[_to] += _value;
      emit Transfer(msg.sender, _to, _value);
      return true;
    }

    function transferFrom(address _from, address _to, uint256 _value) returns (bool success) {
      require(_to != address(0));
      require(_value <= balanceOf[_from]);
      require(_value <= allowed[_from][msg.sender]);
      require(balanceOf[_to] + _value >= balanceOf[_to]);

      balanceOf[_from] -= _value;
      balanceOf[_to] += _value;

      allowed[_from][msg.sender] -= _value;
      emit Transfer(_from, _to, _value);
      return true;
    }

  function approve(address _spender, uint256 _value) returns (bool success) {
      allowed[msg.sender][_spender] = _value;
      emit Approval(msg.sender, _spender, _value);
      return true;
  }

  function allowance(address _owner, address _spender) view returns (uint256 remaining) {
      return allowed[_owner][_spender];
  }

}

复制代码

这个就是标准的ERC-20的代码,它首先 import 了一个接口文件,我们通过 is 这个方式实现这样一个接口,就像在 Java 里面 extends 一样。在这个合约里面, balanceOf 用来定义每一个地址所对应的余额。allowed 中我们刚刚讲到标准里面有两个方法,一个是 approve 授权,另一个是代理转帐,进行这个过程的时候就需要判断有没有授权,我们用 allowed 去做这个事情,它的 key 是地址,保存owner,value也是一个mapping,记录被授权人及额度。

    constructor() public {
        totalSupply = 1000;
        name = "JueJin Token";
        symbol = "JJT";
        decimals = 0;
        balanceOf[msg.sender] = totalSupply;
    }

  function balanceOf(address _owner) view returns (uint256 balance) {
      return balanceOf[_owner];
  }

复制代码

构造函数很简单,对我们刚刚指明的信息,比如说名字、总发行量、符号等,对状态的变量做一些初始化。那比如说我这个代币的名字就叫做掘金Token,balanceOf这个函数很简单,它就是返回某一个帐号他有多少余额。

function transfer(address _to, uint _value) public returns (bool success) {
      require(_to != address(0));
      require(_value <= balanceOf[msg.sender]);
      require(balanceOf[_to] + _value >= balanceOf[_to]);

      balanceOf[msg.sender] -= _value;
      balanceOf[_to] += _value;
      emit Transfer(msg.sender, _to, _value);
      return true;
    }
    
    function transferFrom(address _from, address _to, uint256 _value) returns (bool success) {
      require(_to != address(0));
      require(_value <= balanceOf[_from]);
      require(_value <= allowed[_from][msg.sender]);
      require(balanceOf[_to] + _value >= balanceOf[_to]);

      balanceOf[_from] -= _value;
      balanceOf[_to] += _value;

      allowed[_from][msg.sender] -= _value;
      emit Transfer(_from, _to, _value);
      return true;
    }
复制代码

transfer 和我们刚才说的方法差不多,只多了一步,他发出了这样一个事件,这个也是ERC20他标准里面需要实现的,我们在实现这样一个转帐的时候必须要把这个事件记录下来。那 transferFrom 在转出的时候不再是由转出的人而是由 From 这个地址发出的,但是 transferFrom 这个方法我们需要做一个检查,就是必须要有足够授权的额度,当我们执行了之后需要减掉一定的授权额度,其他的地方都一样。

  function approve(address _spender, uint256 _value) returns (bool success) {
      allowed[msg.sender][_spender] = _value;
      emit Approval(msg.sender, _spender, _value);
      return true;
  }
复制代码

我们再来看一下 approve 这段代码,approve 这个方法我可以授权给其他人来操作我帐号下的代币,这个参数是被授权人的地址和授权的额度。这个函数的实现其实就是对于我们刚刚定义的一个状态变量allowed进行一个赋值,同样这个方法也需要去发出这样的一个事件,即我授权给谁了。

 function allowance(address _owner, address _spender) view returns (uint256 remaining) {
      return allowed[_owner][_spender];
  }
复制代码

最后我们看看 allowance,它就是返回授权额度。

以上就是ERC-20代币的标准实现,有50多行的代码,实现之后就是可以简单的部署一下。

众筹

我们接着讲众筹,这个是我给众筹的一个定义,就是(约定时间内)向公众募资(约定数量),ICO 意思是首次代币发行,首次代币发行的时候其实是向公众募资以太币,因此也是一个众筹行为,最出名的项目就是 EOS ,他们在将近一年的时间里募集了721万个 ETH。

实现众筹

那我们再来看一下如何来实现一个众筹。

  1. 首先要设定众筹的时间,你不能无限期的众筹;然后设定一个目标的金额,还有兑换的价格,因为我们刚刚讲ICO他其实是用 ETH 买我们自己的代币,需要设定一个兑换的价格;此外就是受益人,当我们众筹完成之后谁能够来提取募集到的 ETH 。

  2. 实现一个以太和代币的一个兑换,当我们的合约收到别人打过来的 ETH 之后,我们要给他转对应的代币给他,注意这样的一个过程是被动触发的。

  3. 实现提款/退款,众筹目标完成后,受益人是可以提款的,把所有的 ETH 给提走;若众筹没有完成,应该允许退款,当然这一步不是所有人能够做到的。

然后接着我们还是一样,就是我们来看一下 ICO 的代码来了解如何实现众筹。

pragma solidity ^0.4.16;

interface token {
    function transfer(address receiver, uint amount) external ;
}

contract Ico {
    address public beneficiary;
    uint public fundingGoal;
    uint public amountRaised;
    
    uint public deadline;
    uint public price;
    token public tokenReward;
    
    mapping(address => uint256) public balanceOf;
    bool crowdsaleClosed = false;

    event GoalReached(address recipient, uint totalAmountRaised);
    event FundTransfer(address backer, uint amount, bool isContribution);


    constructor (
        uint fundingGoalInEthers,
        uint durationInMinutes,
        uint etherCostOfEachToken,
        address addressOfTokenUsedAsReward
    ) public {
        beneficiary = msg.sender;
        fundingGoal = fundingGoalInEthers * 1 ether;
        deadline = now + durationInMinutes * 1 minutes;
        price = etherCostOfEachToken * 1 ether;
        tokenReward = token(addressOfTokenUsedAsReward);
    }


    function () public payable {
        require(!crowdsaleClosed);
        
        uint amount = msg.value;  // wei
        balanceOf[msg.sender] += amount;
  
        amountRaised += amount;
        if (amount == 0) {
            tokenReward.transfer(msg.sender, amount / price);
        }
        
        

        emit FundTransfer(msg.sender, amount, true);
    }

    modifier afterDeadline() {
        if (now >= deadline) {
            _;
        }
    }


    function checkGoalReached() public afterDeadline {
        if (amountRaised >= fundingGoal) {
            emit GoalReached(beneficiary, amountRaised);
        }
        crowdsaleClosed = true;
    }


    function safeWithdrawal() public afterDeadline {
        
        if (amountRaised < fundingGoal) {
            uint amount = balanceOf[msg.sender];
            balanceOf[msg.sender] = 0;
            if (amount > 0) {
                msg.sender.transfer(amount);
                emit FundTransfer(msg.sender, amount, false);
            }
        }

        if (fundingGoal <= amountRaised && beneficiary == msg.sender) {
            beneficiary.transfer(amountRaised);
            emit FundTransfer(beneficiary, amountRaised, false);
        }
    }
}
复制代码

首先第一步是要设定相关的参数,这些参数是在构造的时候去做设计的,我们看看有哪些参数。

  • beneficiary 是受益人;
  • fundingGoal 是众筹的目标;
  • amountRaised 表示当前众筹的总额;
  • Deadline 是众筹的截止日期;
  • price 是兑换价格
  • tokenReward 是所关联的代币,实际上我们需要用代币的一个地址去给他关联起来,待会儿我们看构造函数的时候就可以看到;
  • mapping(address → uint256) 是用来记录每一个参与的众筹人投入了多少 ETH。
  • bool crowdsaleClosed 判断我们的众筹是否已经关闭了;
  • event GoalReached 记录众筹完成的事件;
  • event FundTransfer 记录转换的事件;
constructor (
        uint fundingGoalInEthers,
        uint durationInMinutes, 
        uint etherCostOfEachToken,
        address addressOfTokenUsedAsReward
    ) public {
        beneficiary = msg.sender;
        fundingGoal = fundingGoalInEthers * 1 ether;
        deadline = now + durationInMinutes * 1 minutes;
        price = etherCostOfEachToken * 1 ether;
        tokenReward = token(addressOfTokenUsedAsReward);
    }
复制代码

构造函数就是我们需要在创建合约的时候知道的几个参数。

  • uint fundingGoalInEthers 目标总额;
  • unit durationInMinutes 众筹持续时间单位是分钟,这个大家可以随意去调的。
 function () public payable {
        require(!crowdsaleClosed);
        
        uint amount = msg.value;  // wei
        balanceOf[msg.sender] += amount;
  
        amountRaised += amount;
        if (amount == 0) {
            tokenReward.transfer(msg.sender, amount / price);
        }
复制代码

这个函数很奇怪,它没有函数的名字,这个函数会在别人往这个合约打入 ETH 的时候他会被动触发的。我们通过用户打入的 ETH 的金额,去换算我们应该给他兑换多少代币。 那首先我们用msg.value 来获得打给用户的以太币的数量,记录每一个人从过去到现在一共打了多少 ETH 。

此外我们还要有一个变量amountRaised不停地把募集到的金额记录下来,给用户打入对应的代币。 msg.sender是打入以太币的地址,tranfer这个方法正是我们刚刚写ERC20实现的方法,我们要用这个方法去给用户发送我们的代币。

function safeWithdrawal() public afterDeadline {
        
        if (amountRaised < fundingGoal) {
            uint amount = balanceOf[msg.sender];
            balanceOf[msg.sender] = 0;
            if (amount > 0) {
                msg.sender.transfer(amount);
                emit FundTransfer(msg.sender, amount, false);
            }
        } 
        
复制代码

无论是用户退款还是受益人取ETH,我们都需要提款,但必须要在众筹结束之后才可以提款,这就是afterDeadLine的用处。afterDeadLine就是一个函数修改器,有点像Python的装饰器,它在函数执行的时候可先进行一些判断,只有符合条件的情况下才会去执行这样的一个函数。那这里必须符合的条件,就是当前的时间必须是在DeadLine之后。 回头来看safeWithdrawal的实现,我们先要判断当前募集到的总额是否小于目标的金额,如果是小于目标的金额表示众筹失败,失败的话所有参与众筹的人都可以把钱提走。这里面我们首先拿到用户之前打入的以太币数量,然后通过 API 提供的 transfer 方法,给某一个地址转入对应的以太,通过这个方法可以把用户之前发过来的以太打回去。

if (fundingGoal <= amountRaised && beneficiary == msg.sender) {
            beneficiary.transfer(amountRaised);
            emit FundTransfer(beneficiary, amountRaised, false);
        }
复制代码

这里还有另外一个分支就是如果要募集到资金的总额他设定的目标,这样受益人就可以把所有的以太提走。当然这里面还有一个条件,调用这个方法的人必须是当前的受益者,这个应该很好理解,不是说所有人都可以提款,然后我们同样是调用transfer 的方法把以太转到受益人的地址名下,这样就完成了一个ICO。总体上代码也不多,只有七八十行。

扩展功能

我们接着来看一下 ERC-20 的扩展功能。

  • 空投大家也接触过,空投是可以不需要打入任何的以太就可以获得对应的Token,所以被很多的项目拿来做营销和推广项目。
        amountRaised += amount;
        if (amount == 0) {
            tokenReward.transfer(msg.sender, 10);
        } else {
            tokenReward.transfer(msg.sender, amount / price);
        }
复制代码

假如说我们要给每个账户空投10个币,代码就可以这么写,学会了就可以发币了。

  • 挖矿本质就是增发。增发很简单,增发是我们刚刚讲到的总供应量totalSupply,通过函数修改总供应量不就是增发了吗,也就是平常说的挖矿了。
  • 锁定,有一些项目他们怕别人砸盘,所以对代币的转移有一些限制,就是我不让你转,并有分时间段做一些限制,比方说参与众筹之后的三个月内不能转走。锁定的本质就是在代币转帐的时候加入了一些控制条件。

常见漏洞分析

美链

美链前段时间炒得比较火,正是因为它这个溢出漏洞,我们在这个链接中可以了解发生漏洞时交易的情况。在这笔交易的Token Transfer 里面我们可以看到有巨大数量的币转移到了两个不同的地址。实际上美链共发行70亿个代币,转移的币远大于发行量,这笔交易造成凭空增发了很多代币,这个漏洞出来了之后所有的交易所都已经关闭了美链的交易,当时美链应该是60多亿市值,然后因为这个漏洞基本上直接归零。

Function: batchTransfer(address[] _receivers, uint256 _value)

MethodID: 0x83f12fec

[0] :0000000000000000000000000000000000000000000000000000000000000040
[1] :8000000000000000000000000000000000000000000000000000000000000000
[2] :0000000000000000000000000000000000000000000000000000000000000002
[3] :000000000000000000000000b4d30cac5124b46c2df0cf3e3e1be05f42119033
[4] :0000000000000000000000000e823ffe018727585eaf5bc769fa80472f76c3d7
复制代码

上方就是页面 Input Data 栏中当时函数调用的情况。这里用的是批量转帐的方法,这边传入一个地址,给所有这些地址做转移对的金额。这个攻击的交易把 value 设计得非常巧妙:他是8后面接了63个0,因为 uint 最大存储上限是256位,换算成16进制刚好是64个字符。如果我们对这个8后面接了63个0的数乘了2,我们知道一个数乘一个2相当于向左移一位(16进制8是二进制1000),但是他只存了256位,溢出了之后就变成0。

我们刚刚看到传入的这个是8后面接了63个0,那这样的话unit256 amount = unit256(cnt) * _value中的value 乘以地址的个数的时候乘以了2之后,刚好amount就是0,这就导致这行代码后面的所有检测都会通过,他有一个判断就是他原地址的余额需要大于amount,那么这里溢出后amount是 0。

接下来我们来看下半部分代码中的条件,balance[msg.sender] = balances[msg.sender].sub(amount)转出了这个人他减去了金额减去了0,但是剩下的这两个传入地址需要加上8后面加63个0这样一个代币的金额,这样的话就对这两个地址,就是相当于平空增加了这么多代币,这个就是他溢出的漏洞。这个其实溢出的漏洞是这个合约里面比较常见的一个漏洞,其实解决方法很简单,就是这里应该去引入SafeMath去做加法,我们应该所有的算数运算方法都要用SafeMath避免去溢出这样的一个漏洞。

EDU漏洞

我刚刚讲这张合约的时候他有一部是可以授权给其他人转帐,就是别人可以代表我去转帐,那 EDU 漏洞会在这种情况下触发,即在没有经过授权的情况下别人就可以把钱转走。

我想这个智能合约的作者没有理解transferFrom的意思,他忘了去用 allowed[_from][msg.sender] >= _value) 判断转帐的时候是否有足够权限。其实即使他没有加这一句,如果他要是引入了我刚刚讲的SafeMath也可以同样避免这个问题。 每一次执行减法的时候,每个 mappping 都有为0的默认值。如果他要是引入了 SafeMath 的话,0减去一个值也会发生溢出。因为溢出有两种情况,一种是向上溢出,一个是向下溢出。allowed[_from][msg.sender] 的值是无符号型的整形,他如果是0去减去一个值的话,按照道理值是负数,但是这里uint不保存负数,所以这个值减去之后会变成一个巨大的正整数,就发生了下溢出错误,但是程序依然没有处理到。所以你无论你有多少代币,别人都可以转走。

延伸

  • 代币(Token) 项目的基础,一个可以交易的内容

  • 区块链思维 无法篡改的双刃剑 OpenZeppelin/SafeMath

最后是一个简单的总结:代币是一个区块链项目的基础,它不单单是我们看到交易的钱,还可以代替很多的交易内容。我们刚刚讲到了一些漏洞,这就涉及到区块链的思维,在我们平时开发的时候讲究的是互联网思维,即快速迭代和不断试错;但是区块链不能这样做,区块链有一个无法修改的特点,这是把双刃剑。你发布之后没有办法轻易地修改,所以我们在发布智能合约的时候要非常谨慎,我们要经过完善的设计还有细致的测试。 推荐一个解决办法是使用OpenZeppelin ,它把编写智能合约最佳的实践做成了一些库,即轮子。我们在编写智能合约的时候尽量不要自己去造轮子,而是用他们的代码,因为他们的代码经过很多审查。比如OpenZeppelin 就提供了一些 SafeMath 能避免我们发送溢出。

以上就是今天的分享,谢谢大家。下方是我的微信二维码,欢迎交流!

关注下面的标签,发现更多相似文章
评论