ERC20重要补充之approveAndCall

什么是ERC20

ERC20是以太坊上为token提供的一种协议,也可以理解成一种token的共同标准。遵循ERC20协议的token都可以兼容以太坊钱包,让用户在钱包中可以查看token余额以及操作token转账,而不需要自己再手动与token合约交互。

ERC20规定了以下基本方法:

contract ERC20 {
    // 方法
    function name() view returns (string name);
    function symbol() view returns (string symbol);
    function decimals() view returns (uint8 decimals);
    function totalSupply() view returns (uint256 totalSupply);
    function balanceOf(address _owner) view returns (uint256 balance);
    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);
}

可以看到,通过上面的几种方法,规定了一种token的基本信息、转账以及授权操作。这些操作基本可以覆盖货币使用的绝大部分场景,该协议一经提出后,立得到了开发者的接纳。

ERC20的局限

ERC20虽然广受开发者喜爱,但是依然有自己局限的一面。

让我们先从一个大家十分熟悉的场景开始谈起。假设某一天,星巴克突然宣布为了拥抱区块链技术,不再接受法币买咖啡了,大家以后可以用以太币或者星巴克自己发行的星星币来买咖啡。

首先,我们来看用以太币来买咖啡的流程。

1. 用以太币买咖啡

简单写一个买咖啡的合约(注:伪代码,仅表示逻辑)

contract BuyCoffee {
    function buy() public payable {
        starbucks.transfer(msg.value);
        COFFEE.transfer(msg.sender);
    }
}

(熟悉ERC721的小伙伴肯定看出来了,这里的COFFEE是遵守ERC721的NFT token,本文重点讲解的是ERC20,因此就不在赘述ERC721的实现了)。

整个调用过程如下图:

coffee-starcoin-Page-1

客户直接调用buy()方法,输入买咖啡需要的以太币数量,BuyCoffee合约就把自己有的COFFEE转给客户。整个过程只需要一步。

2. 用星星币买咖啡

星巴克自己发行了token,取名StarCoin,遵循ERC20协议。

那么BuyCoffee合约就要做一些小修改:(注:伪代码,仅表示逻辑)

contract BuyCoffee {
    // 一杯咖啡的StarCoin价格
    uint constant COFFEE_PRICE;
    //@param _fee - 用户买咖啡需要支付的StarCoin数量
    function buy(uint _fee) public payable {
        require(_fee >= COFFEE_PRICE);
        StarCoin.transferFrom(msg.sender, address(this), _fee);
        COFFEE.transfer(msg.sender);
    }
}

整个买咖啡的过程如下图:

coffee-starcoin-Copy of Page-1

图中可以看到,因为StarCoinBuyCoffee是两个合约,分别有自己独立的地址,所以客户买咖啡就要经过两次操作:

  • 先要把买咖啡的starcoin数量授权给BuyCoffee
  • 然后调用BuyCoffee中的buy(uint)方法买咖啡;

3. 以太币 vs 星星币

通过上面的分析可以看到,如果要使用星巴克发行的StarCoin进行付款的话,买一杯咖啡要操作两次,无疑这增加了操作成本,并且很反常识。一个很好的办法就是把StarCoinBuyCoffee合二为一,如果token逻辑和业务逻辑都在同一个合约里的话,就不存在上述问题了。

这看上去是一个不错的办法,然而治标不治本。万一以后星巴克还宣布可以使用星星币买积分、参加优惠活动甚至直接参与星巴克公司分红,鉴于智能合约不可更改的特点,这么多业务逻辑不可能一开始就全部规划好,以后的新业务依然面临多次操作的问题。

approveAndCall

approveAndCall方法可以完美地解决上述问题,把两次操作合并为一次,让用户在付款时感觉不到这些复杂的操作。

使用approveAndCall方法之后,整个操作的流程如下:

  1. 用户在token合约 (StarCoin) 中授权一笔token给业务合约 (BuyCoffee), 通过token合约中的approveAndCall方法;
  2. token合约通知业务合约,它已经被授权可以操作用户的一笔token,通过调用业务合约的receiveApproval方法;
  3. 业务合约就可以把用户的token转给自己,然后自己再去完成相关的业务逻辑(比如把咖啡转给用户,或者自己再做一些转账操作)。

整个过程就如下图:

approveandcall-Page-2

这就需要在token合约里创建approveAndCall方法,如下:

function approveAndCall(address _to, uint256 _value, bytes _extraData) {
    approve(_to, _value);
    ApproveAndCallFallBack(_to).receiveApproval(
        msg.sender,
        _value,
        extraData)
}

(参数的个数可以根据需要自行选择,例如可以加上address(tokenContract))

然后在service合约中创建receiveApproval方法,如下:

function receiveApproval(address _sender, uint256 _value, bytes _extraData) {
    require(msg.sender == tokenContract);
    // do something by breaking down _extraData
    ...
}

approveAndCall使用注意事项

为什么要使用approveAndCall以及怎样使用它,上文已经解释清楚了。有些可能觉得再多写一个ApproveAndCallFallBack接口有些多此一举,不如直接使用address(_to).call(...)来的简单直接。

ConsenSys的疏忽

ConsenSys公司的思路也是这样的,以下代码就是Consensys的approveAndCall方法:

  /* Approves and then calls the receiving contract */
    function approveAndCall(address _spender, uint256 _value, bytes _extraData) returns (bool success) {
        allowed[msg.sender][_spender] = _value;
        Approval(msg.sender, _spender, _value);

        //call the receiveApproval function on the contract you want to be notified. This crafts the function signature manually so one doesn't have to include a contract in here just for this.
        //receiveApproval(address _from, uint256 _value, address _tokenContract, bytes _extraData)
        //it is assumed that when does this that the call *should* succeed, otherwise one would use vanilla approve instead.
        if(!_spender.call(bytes4(bytes32(sha3("receiveApproval(address,uint256,address,bytes)"))), msg.sender, _value, this, _extraData)) { throw; }
        return true;
    }
}

想看全部源码的可以访问:https://github.com/ConsenSys/Token-Factory/blob/187895aa43d78fc3872fa05f55f005a421006f77/contracts/HumanStandardToken.sol

但是大家如果稍加尝试就会发现,如果这里的_extraData超过32个字节,就会报错。

原因就在于address(_to).call(...)这样的调用,并不会对所传数据做ABI.encode编码,而bytes作为动态数据类型,它的ABI编码方式和基础的、固定长度类型的变量是不一样的。

举个例子:

下面是长度为64字节的bytes (换行只是为了让大家看着不费力) :

0x0000000000000000000000000000000100000000000000000000000000000001
  000000000000000000000000964633feef5a290be634c2e718353b98def350be

它的ABI编码如下 (换行只是为了让大家看着不费力) :

0x0000000000000000000000000000000100000000000000000000000000000060
  0000000000000000000000000000000100000000000000000000000000000040
  0000000000000000000000000000000100000000000000000000000000000001
  000000000000000000000000964633feef5a290be634c2e718353b98def350be
  • 第一行(第一个32byte):距离参数开始位置的偏移量;
  • 第二行(第二个32byte):bytes参数的长度;
  • 第三行和第四行(最后64个byte):bytes参数的内容;

所以上面的bytes参数如果超过32byte长度,第二个32byte就会被当成bytes参数的长度,最后因为out of gas而导致调用失败。

以上错误的修复方式

针对上面的ConsenSys公司的代码,正确写法应该是:

/* Approves and then calls the receiving contract */
    function approveAndCall(address _spender, uint256 _value, bytes _extraData) returns (bool success) {
        approve(_spender, _value); //如果该token遵循ERC20的话
             if(!_spender.call(bytes4(keccak256("receiveApproval(address,uint256,address,bytes)")), abi.encode(msg.sender, _value, this, _extraData)) { throw; }
        return true;
    }
}

address(_spender).call(...)方法中,使用abi.encode()方法对参数进行ABI编码,可以防止出现上述错误。

approveAndCall的正确打开方式

接着上面的代码继续说,除了上面的abi.encode对参数进行ABI编码的例子,还可以使用abi.encodeWithSelector(...)方法:

/* Approves and then calls the receiving contract */
    function approveAndCall(address _spender, uint256 _value, bytes _extraData) returns (bool success) {
        approve(_spender, _value); //如果该token遵循ERC20的话
             if(!_spender.call(abi.encodeWithSelector(bytes4(keccak256("receiveApproval(address,uint256,address,bytes)")),msg.sender, _value, this, _extraData)) { throw; }
        return true;
    }
}

abi.encodeWithSelector会自动忽略前四个字节,对后面的内容进行ABI编码。

还有一个使代码看上去更加简洁的代码方式就是上面提到的,增加ApproveAndCallFallBack接口:

interface ApproveAndCallFallBack {
    function receiveApproval(address from, uint256 _amount, address _token, bytes _data) public;
}

之后approveAndCall方法内的实现变为:

function approveAndCall(address _spender, uint256 _amount, bytes _extraData
    ) returns (bool success) {
        if (!approve(_spender, _amount)) throw;

        ApproveAndCallFallBack(_spender).receiveApproval(
            msg.sender,
            _amount,
            this,
            _extraData
        );

        return true;
    }

以上代码贡献自:

https://github.com/evolutionlandorg/token-contracts/blob/82d174250e8ec53882a9f03b8ed6c9767ca730a0/src/RING.sol#L130

注:这是一个以太坊上的沙盘游戏。其中RING token的设计目的之一就是为了在游戏中买卖地块,感兴趣的同学可以详细研究其中的erc20和erc721token之间的交互方式。

写在最后

这一篇解释了为什么使用approveAndCall以及怎样更好地使用它。区块链是一个更新迭代迅速同时又极其强调安全的领域,对于权威组织给出的代码,我们也不能简单地copy-and-paste,审计和测试是必须的。

至于ERC20为什么没有把approveAndCall添加进协议中,可能早期在以太坊上流通的大部分多为token合约,还没有能够建立去较为复杂的应用强的程序,因此更加强调的是token作为货币具有的流通手段的职能;随着以太坊生态的发展出现了越来越多的应用,这时ERC20 token的支付手段的职能才被大家重视起来。

也可能因为approveAndCall和业务的联系过于紧密,ERC20作为一个框架性的协议,这些细节并不在考虑范围之内。

鉴于智能合约的不可更改性,希望今后的发行token的组织机构或者个人,在实现ERC20的基础上,可以尽可能安全地实现approveAndCall方法,使得基于token的应用生态更加鲁棒。

最后提醒,ERC223的tokenFallback方法也有类似的效果,如果大家感兴趣也可以自己做进一步的研究。

友情提醒:ERC223的tokenFallback方法在之前提到的https://github.com/evolutionlandorg/项目中也有不错的实现样例,感兴趣的朋友可以自行参考。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容