深入剖析ERC20

  • BY_DLIFE
  • 更新于 2024-04-21 18:13
  • 阅读 2418

1.ERC20简介​ERC20是以太坊区块链创建的可替代的技术标准,可替代代币是可以与另一种代币进行交换的代币,故此ERC20代币是一种同质化代币。ERC20协议更像是一种规范,规范了在智能合约中实施代币的标准API,使得代币具有基本的转账功能,以便其他链上第三方可以使用。ERC20接口

1. ERC20简介

​ ERC20是以太坊区块链创建的可替代的技术标准,可替代代币是可以与另一种代币进行交换的代币,故此ERC20代币是一种同质化代币。ERC20协议更像是一种规范,规范了在智能合约中实施代币的标准API,使得代币具有基本的转账功能,以便其他链上第三方可以使用。

ERC20接口:

pragma solidity ^0.8.20;

interface IERC20 {
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

​ 这是ERC20最基本的也是最重要的功能,凡是遵循ERC20标准的都需要实现该接口。这几个方法很简单,transferFrom()函数还是要值得注意,该函数的使用方式是,from需要提前为msg.sender授权,即需要from去亲自调用approve()函数。只有 frommsg.sender授权之后,transferFrom()函数才能够成功执行,这是在平时打CTF的时候经常容易忽视的操作,写完攻击逻辑之后,最后报错。。。才发现是在某些合约里的某些函数中的转账逻辑是transferFrom(),由于没有授权导致的。

2. USDT的坑

2.1 USDT的问题所在

​ 还有一个值得注意的点是,transfer()transferFrom()都是有返回值的!!!!为什么主要,就是因为全球使用最广的稳定币的源码中transfer()transferFrom()是没有返回值,这是一个坑!!!大坑!!!

可以到 浏览器看到Tether Token(USDT)的源码,可以看到这两个函数:

TetherToknen.sol:

  function transfer(address _to, uint _value) whenNotPaused {
    if (deprecated) {
      return UpgradedStandardToken(upgradedAddress).transferByLegacy(msg.sender, _to, _value);
    } else {
      return super.transfer(_to, _value);
    }
  }

  function transferFrom(address _from, address _to, uint _value) whenNotPaused {
    if (deprecated) {
      return UpgradedStandardToken(upgradedAddress).transferFromByLegacy(msg.sender, _from, _to, _value);
    } else {
      return super.transferFrom(_from, _to, _value);
    }
  }

这里两个函数是没有返回值,即使它调用的是父类的函数,在没有看到父类的具体函数实现,姑且说它的父类是有返回值的,但是子类中的函数是没有返回值,这是在Remix中编译不过去的,举例:

image.png 可以看到编译通过了。下面是子合约函数中没有返回值的:

image.png

所以不用去父类中找函数都可以知道父类的函数也是没有返回值的,不信就去验证一下:

BasicToken.sol::transfer()

  function transfer(address _to, uint _value) onlyPayloadSize(2 * 32) {
    uint fee = (_value.mul(basisPointsRate)).div(10000);
    if (fee > maximumFee) {
      fee = maximumFee;
    }
    uint sendAmount = _value.sub(fee);
    balances[msg.sender] = balances[msg.sender].sub(_value);
    balances[_to] = balances[_to].add(sendAmount);
    balances[owner] = balances[owner].add(fee);
    Transfer(msg.sender, _to, sendAmount);
    Transfer(msg.sender, owner, fee);
  }

StandardToken.sol::transferFrom()

  function transferFrom(address _from, address _to, uint _value) onlyPayloadSize(3 * 32) {
    var _allowance = allowed[_from][msg.sender];

    uint fee = (_value.mul(basisPointsRate)).div(10000);
    if (fee > maximumFee) {
      fee = maximumFee;
    }
    uint sendAmount = _value.sub(fee);

    balances[_to] = balances[_to].add(sendAmount);
    balances[owner] = balances[owner].add(fee);
    balances[_from] = balances[_from].sub(_value);
    if (_allowance < MAX_UINT) {
      allowed[_from][msg.sender] = _allowance.sub(_value);
    }
    Transfer(_from, _to, sendAmount);
    Transfer(_from, owner, fee);
  }

2.2 复现DOS异常

​ 本地复现。

​ 本地部署USDT,地址为:0x5FbDB2315678afecb367f032d93F642f64180aa3

​ 再部署一个转移代币的 TokenTransfer.sol,地址为:0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

interface IERC20_USDT {
    function transfer(address, uint256) external;
    function transferFrom(address, address, uint256) external;
}

interface IERC20_stad {
    function transfer(address, uint256) external returns(bool);
    function transferFrom(address, address, uint256) external returns(bool);
}

// USDT: 0x5FbDB2315678afecb367f032d93F642f64180aa3
contract TokenTransfer {

    IERC20_USDT USDT;
    IERC20_stad TOKEN;

    constructor(address _usdt) {
        USDT = IERC20_USDT(_usdt);
        TOKEN = IERC20_stad(_usdt);
    }

    function test_TransferWithOutReturnValue() external {
        USDT.transfer(msg.sender, 10);
    }

    function test_TransferWithReturnValue() external {
        TOKEN.transfer(msg.sender, 10);
    }

    function test_TransferFromWithOutReturnValue() external {
        USDT.transferFrom(msg.sender, 0x71bE63f3384f5fb98995898A86B02Fb2426c5788, 1);
    }

    function test_TransferFromWithReturnValue() external {
        TOKEN.transferFrom(msg.sender, 0x71bE63f3384f5fb98995898A86B02Fb2426c5788, 1);
    }
}

​ 该合约主要是测试transfertransferFrom函数,定义两个接口,一个接口中有返回值,一个没有,模拟将USDT转入一个遵循ERC20标准的合约,看看是否能将转入的ERC20 Token转出。

​ 可以看到调用test_TransferWithReturnValue,交易会被revert,而调用test_TransferWithOutReturnValue,交易则正常运行。

image.png

​ 同理test_TransferFromWithReturnValue操作也是会被revert。这也就说明了,用标准的ERC20接口转换USDT,就会造成资金永久封锁的情况。当然,为了解决这个问题,可以将合约中的ERC20接口中的那两个函数的返回值移除,即:

interface IERC20_USDT {
    function transfer(address, uint256) external;
    function transferFrom(address, address, uint256) external;
}

3.SafeERC20

3.1 兼容USDT

​ 当然还有其他的解决方式,也是最常用一种解决方式,那就是使用SafeERC20库。

using SafeERC20 for IERC20;

​ SafeERC20做了兼容严格遵守与不严格遵守ERC20协议标准的代币,兼容的原理如下:

    function _callOptionalReturn(IERC20 token, bytes memory data) private {
        bytes memory returndata = address(token).functionCall(data);
        if (returndata.length != 0 && !abi.decode(returndata, (bool))) {
            revert SafeERC20FailedOperation(address(token));
        }
    }

    function safeTransfer(IERC20 token, address to, uint256 value) internal {
        _callOptionalReturn(token, abi.encodeCall(token.transfer, (to, value)));
    }

​ 简单来说就是,通过Address.sol的低级调用方式,可以检测调用是否成功,且检测是否有返回值。当调用USDT的transfer函数时,如果执行成功,且return data = 0x,那么函数便可以执行,即跳过if的检测。

​ 同理 transferFrom函数也是如此。

4. ERC20系列数字货币

4.1 (Tether USD)USDT

​ 不遵循标准的ERC20协议,需要操作该代币时,建议使用SafeERC20.sol。

  • 发行公司: 由 Tether Limited 发行,成立于 2014 年的香港公司。
  • 市值: USDT 是市值最大的稳定币之一,截至 2022 年 7 月,市值超过 650 亿美元,占稳定币市场 50% 以上的份额。
  • 挂钩: USDT 与美元 1:1 挂钩,据称有大量抵押品储备支持,包括现金、商业票据和商品。
  • 历史: 最早作为 RealCoin 推出,后来于 2014 年 11 月更名为 Tether。然而,Tether 并不是没有争议的,曾因多次争议而备受关注,包括被指控误导投资者和缺乏对储备的透明度。

4.2 (USD Coin)USDC

​ 遵循标准的ERC20协议。源码链接:link

  • 发行公司: 由 Circle、Coinbase 和其他金融科技公司共同创立的财团 Center 是 USDC 的发行人。
  • 市值: USDC 是按市值计算的第二大稳定币,截至 2022 年 7 月,市值超过 540 亿美元。
  • 挂钩: 每个 USDC 与美元 1:1 挂钩,并由现金和美元等值资产支持。
  • 安全性: USDC 被认为是一种更安全的价值储存手段,因为它有现金和现金等价物支持,而且受到美国监管。

这两种稳定币的比较:

  • 流动性: USDT 的交易量更大,更广泛可用,但USDC 的交易量较低。
  • 透明度: USDC 在透明度和监管方面表现较好,而 USDT 面临一些争议。
  • 用途: USDT 在期货交易中很受欢迎,提供了更高的收益,而 USDC 是去中心化金融 (DeFi) 领域的首选,因为它被认为更安全。

4.3 其他

  • (Shiba Inu)SHIB:SHIB是一种基于以太坊的山寨币,算是狗狗币的一种替代品,遵循标准的ERC20协议。
  • Binance USD(BUSD):遵循标准的ERC20协议。
  • DAI Stablecoin (DAI):遵循标准的ERC20协议。
  • HEX (HEX):遵循标准的ERC20协议。

5. ERC20 extensions

来自 OpenZeppelin,链接ERC4626单独xue'xi

5.1 ERC1363.sol

​ 这个拓展协议实现的功能是,用户在执行transfer、transferFrom和approve操作的时候,可以传入calldata,完成一些函数调用,或者是参数的传递,实现逻辑类似ERC721的 checkOnERC721Received()

它的_checkOnTransferReceived()_checkOnApprovalReceived()函数会分别去调用IERC1363Receiver(to).onTransferReceivedIERC1363Spender(spender).onApprovalReceived,这里会埋下被重入的安全隐患,在实用这个协议的时候需要注意这点。

5.2 ERC20Burnable.sol

​ 该合约提供了销币功能。

  • burn(uint256 value):销毁msg.sender的value个代币。
  • burnFrom(address account, uint256 value) :msg.sender销毁account的value个代币,前提是account给予msg.sender权限。

5.3 ERC20Capped.sol

我的理解是,给ERC20代币的的totalsupply盖帽子,也就是设置上限,设置某个ERC20代币的发行量不能超过cap。限制的逻辑在这里(from==0,则被检测为铸币操作):

if (from == address(0)) {
            uint256 maxSupply = cap();
            uint256 supply = totalSupply();
            if (supply > maxSupply) {
                revert ERC20ExceededCap(supply, maxSupply);
            }
        }

5.4 ERC20FlashMint.sol

​ 该合约提供了一个借贷功能,只能借该合约生成的代币,且最大接待额为:token == address(this) ? type(uint256).max - totalSupply() : 0;还需要支付fee,这个借贷函数不需要主动还款,因为ta采用的是burn操作,直接将你手中借来的token全部销毁。但是这不影响执行某些重入攻击,比如可以借钱去执行套利操作这类的,这是借贷函数的“通病”吧。

5.5 ERC20Pausable.sol

​ 该合约提供了一个紧急停止功能,在_update()函数加上whenNotPaused修饰符,当所有者暂停合约时,该合约生成的代币将不能执行一系列操作,如transfer、mint、transferFrom等。

5.6 ERC20Permit.sol

​ 该合约提供了一个新的授权操作,permit()函数的作用便是完成 owner对spender的授权,个人理解是,因为原ERC20中的approve函数必须是owner亲自去调用才能完成授权,这比较麻烦owner,而permit则是可以通过owner提供的签名来验证,并执行owner对spender的授权操作。

举个例子来对比,以开门为例:

  • 原ERC20的授权方式:owner想让spender进屋拿东西,而owner需要亲自开门让他进去
  • ERC20Permit的授权方式:同样的示例,owner可以直接把家门口的钥匙给spender,让spender直接去开门拿东西就好了。

5.7 ERC20Votes.sol

​ 该合约支持类似Compound的投票和授权。

5.8 ERC20Wrapper.sol

​ 该合约支持代币的包装,用户可以存入和取出_underlying代币,存入多少_underlying代币,就可以铸造多少ERC20Wrapper代币,同理取出多少 _underlying代币,便会销毁多少ERC20Wrapper代币代币。

还提供了一个 _recover函数,该函数的作用是将错误转入该合约的 _underlying代币数量全部铸造为ERC20Wrapper代币,我对“错误转入”的理解是,没有通过depositFor()函数转入 _underlying代币。

最后,码字不易,点个赞呗~🤪

点赞 5
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
BY_DLIFE
BY_DLIFE
0x39CF...9999
立志成为一名优秀的智能合约审计师、智能合约开发工程师,文章内容为个人理解,如有错误,欢迎在评论区指出。