手把手教你实现TokenBank智能合约

  • Louis
  • 更新于 2024-07-09 13:50
  • 阅读 1478

在手把手教你实现BigBank文章中,我们实现了一个稍微复杂点的存款、取款业务。但是聪明的你可能发现了,我们的BigBank虽然名字中带有big,但是有一个明显的缺点:它只能存入和取出ETH原生代币,面对广大的符合ERC20标准的Token却无能为力。

相关背景

手把手教你实现BigBank文章中,我们实现了一个稍微复杂点的存款、取款业务。但是聪明的你可能发现了,我们的BigBank虽然名字中带有big,但是有一个明显的缺点:

它只能存入和取出ETH原生代币,面对广大的符合ERC20标准的Token却无能为力。

这就好比你去一家银行,只能存取人民币,你想去换点港币去香港旅游,但是银行告诉你不支持港币业务,这就很让人头疼。

这篇文章,我们就从ERC20代币开始分析,逐步实现我们的TokenBank合约。

功能实现要求

  • TokenBank 合约,可以将自己的 ERC20 Token 存入到 TokenBank, 和从 TokenBank 取出。
  • TokenBank 有两个方法:
      1. deposit() : 需要记录每个地址的存入数量;
      1. withdraw(): 用户可以提取自己的之前存入的 token;

先从ERC20说起

ERC-20标准是用于在以太坊区块链上创建智能合约的代币的技术规范。它定义了一系列规则,所有ERC-20代币都必须遵守这些规则,以便它们能够与以太坊网络上的其他代币和应用程序兼容。

ERC-20标准的主要目的是确保所有ERC-20代币都具有相同的基本功能,使其易于使用和集成。这使得开发人员可以轻松地创建新的代币,并确信它们将能够与现有的以太坊基础设施和工具一起工作。

还记的我们介绍过的接口吗?这里可以把标准认为是接口,我们自己铸造的ERC20 Token必须继承这个接口。并实现它其中的方法。

ERC-20标准定义的基本功能

ERC-20标准定义了代币合约必须实现的一些基本功能,包括:

  1. 总供应量查询功能:合约必须能够返回代币的总供应量。
  2. 余额查询功能:合约必须能够返回特定地址持有的代币余额。
  3. 转账功能:合约必须能够允许地址之间相互转移代币。
  4. 授权功能:合约可以允许其他地址代表拥有者转移一定数量的代币。
  5. 转账事件触发:当代币转移时,合约必须能够触发事件,允许监听器捕获转账操作的详细信息。

而可选功能则包括:

  1. 增发和销毁:合约可以实现代币的增发(发行新的代币)和销毁(销毁已有的代币)功能。
  2. 冻结功能:合约可以实现对特定地址或代币的冻结和解冻功能,用于安全措施或法规合规。
  3. 批准和撤销批准:合约可以实现对其他合约或地址的批准转移功能,允许指定的地址或合约在特定条件下转移代币。

一个简单的 ERC20 Token 示例:

在真实的生产环境中,我们一般不会亲自去手动实现,一方面社区已经比较成熟的方案和标准库供我们调用,不需要我们重复造轮子,一方面,我们自己写的如果测试不充分,可能会有很多的漏洞。

但是为了学习,这里提供一份符合ERC20标准的Token代码。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract BaseERC20 {
    // Basic ERC20 token information
    string public name;
    string public symbol;
    uint8 public decimals;

    // Total supply of tokens
    uint256 public totalSupply;

    // Mapping of owner addresses to their token balances
    mapping(address => uint256) balances;

    // Mapping of owner addresses to spender addresses and their allowances
    mapping(address => mapping(address => uint256)) allowances;

    // Event emitted when tokens are transferred
    event Transfer(address indexed from, address indexed to, uint256 value);

    // Event emitted when an allowance for spending tokens is set
    event Approval(address indexed owner, address indexed spender, uint256 value);

    // Constructor to initialize token information and mint total supply to deployer
    constructor(
        string memory tokenName,
        string memory tokenSymbol,
        uint8 TokenDecimals,
        uint256 tokenTotalSupply
    ) {
        name = tokenName;
        symbol = tokenSymbol;
        decimals = TokenDecimals;
        totalSupply = tokenTotalSupply * (10 ** decimals);
        balances[msg.sender] = totalSupply;
    }

    // Returns the token balance of a given address
    function balanceOf(address _owner) public view returns (uint256 balance) {
        require(
            _owner != address(0),
            "ERC20: balance query for the zero address"
        );
        return balances[_owner];
    }

    // Transfers tokens from the message sender to a recipient
    function transfer(address _to, uint256 _value) public returns (bool success) {
        require(_to != address(0), "ERC20: transfer to the zero address");
        require(_value > 0, "ERC20: transfer amount must be greater than zero");

        uint256 senderBalance = balances[msg.sender];
        require(
            senderBalance >= _value,
            "ERC20: transfer amount exceeds balance"
        );

        // SafeMath not required for internal transfers in Solidity 0.8+ due to overflow checking
        if (_to != msg.sender) {
            balances[msg.sender] = senderBalance - _value;
            balances[_to] += _value;
        }

        emit Transfer(msg.sender, _to, _value);
        return true;
    }

    // Transfers tokens on behalf of another address using an allowance
    function transferFrom(
        address _from,
        address _to,
        uint256 _value
    ) public returns (bool success) {
        require(_to != address(0), "ERC20: transferFrom to the zero address");
        require(
            _value > 0,
            "ERC20: transferFrom amount must be greater than zero"
        );
        require(
            balances[_from] >= _value,
            "ERC20: transfer amount exceeds balance"
        );
        require(
            allowances[_from][msg.sender] >= _value,
            "ERC20: transfer amount exceeds allowance"
        );

        balances[_from] -= _value;
        balances[_to] += _value;
        allowances[_from][msg.sender] -= _value;

        emit Transfer(_from, _to, _value);
        return true;
    }

    // Approves an address to spend a certain amount of tokens on your behalf
    function approve(address _spender, uint256 _value) public returns (bool success) {
        address owner = msg.sender;
        require(owner != address(0), "ERC20: approve from the zero address");
        require(_spender != address(0), "ERC20: approve to the zero address");
        allowances[owner][_spender] = _value;

        emit Approval(msg.sender, _spender, _value);
        return true;
    }

    // Returns the remaining allowance for a spender on a specific owner's tokens
    function allowance(address _owner, address _spender)
        public
        view
        returns (uint256 remaining)
    {
        require(_owner != address(0), "ERC20: owner is the zero address");
        require(_spender != address(0), "ERC20: spender is the zero address");
        return allowances[_owner][_spender];
    }

不太好理解的allowances

从上面代码中可以看出,我们将allowances设置为了嵌套映射。这么设计是经过慎重思考的。

如果你觉得还是不够直观,这里我们可以用一个图示来说明一下:

Xnip2024-07-08_20-49-47.png

让我解释一下这个嵌套映射和可视化图表:

  1. 基本结构:

    mapping(address => mapping(address => uint256)) private allowances;

    这个结构可以理解为一个两层的映射:

    • 第一层:从一个地址(拥有者)映射到另一个映射
    • 第二层:从一个地址(被授权者)映射到一个数值(授权数量)
  2. 具体例子: 假设我们有以下情况:

    • Alice (地址: 0x1234...) 允许 Bob (地址: 0x5678...) 使用 100 个代币
    • Alice 还允许 Charlie (地址: 0x9ABC...) 使用 50 个代币
    • Bob 允许 Charlie 使用 30 个代币
  3. 在代码中的表示:

    allowances[0x1234...][0x5678...] = 100;
    allowances[0x1234...][0x9ABC...] = 50;
    allowances[0x5678...][0x9ABC...] = 30;
  4. 可视化解释:

    • 第一层映射:每个用户(如 Alice、Bob)都有自己的"抽屉"
    • 第二层映射:在每个用户的"抽屉"里,又有多个"文件夹",每个文件夹对应一个被授权的地址
    • 每个"文件夹"里存储的是授权的代币数量
  5. 如何使用:

    • 当 Bob 想要代表 Alice 转移代币时,合约会检查 allowances[Alice的地址][Bob的地址]
    • 如果 Charlie 想知道 Bob 允许他使用多少代币,可以查询 allowances[Bob的地址][Charlie的地址]

这种结构允许在 ERC20 代币中实现复杂的授权机制,使得用户可以安全地允许其他地址(如智能合约)代表自己使用一定数量的代币,而无需转移代币的所有权。

如何理解approve?

approve 函数。是非常重要的,它在 ERC20 代币的授权机制中扮演着关键角色。我们上面解释的allowances在这个函数中就有比较重要的应用。

让我们逐步深入理解 approve 函数:

  1. 基本定义:

    function approve(address spender, uint256 amount) public returns (bool)
  2. 目的: approve 函数允许代币持有者(调用者)授权另一个地址(通常是一个合约)代表自己使用一定数量的代币。(这个点非常重要,后续的TokenBank有个坑就是因为这个)

  3. 参数:

    • spender:被授权的地址,可以是另一个用户的地址或一个合约地址。
    • amount:授权使用的代币数量。
  4. 返回值:

    • 通常返回一个布尔值,表示操作是否成功。
  5. 工作原理:

    • 更新授权映射:设置 msg.sender(调用者)允许 spender 使用的代币数量。
    • 发出事件:触发 Approval 事件,记录这次授权操作。
  6. 使用场景:

    • 去中心化交易所:用户授权交易所合约使用他们的代币进行交易。
    • 定期付款:允许一个合约定期从用户账户中扣除一定数量的代币。
    • 多签钱包:授权多签钱包管理用户的部分代币。
  7. 重要注意事项:

    • approve 不会检查用户的余额。用户可以授权超过自己拥有的代币数量。
    • 每次调用 approve 都会覆盖之前的授权值,而不是增加或减少。
  8. 与其他函数的关系:

    • transferFrom:使用 approve 设置的授权额度。
    • allowance:查询当前的授权额度。
  9. 实际应用示例: 假设 Alice 想要使用一个去中心化交易所(DEX):

    // Alice 授权 DEX 合约使用最多 100 个代币
    tokenContract.approve(dexContractAddress, 100);
    
    // 之后,DEX 合约可以调用 transferFrom 来使用这些代币
    tokenContract.transferFrom(aliceAddress, bobAddress, 50);

理解 approve 函数对于掌握 ERC20 代币的工作机制非常重要。它为代币持有者提供了一种安全的方式来允许其他地址或合约管理他们的部分代币,而无需完全转移所有权。这种机制极大地增加了 ERC20 代币的灵活性和实用性,尤其是在复杂的 DeFi 应用中。

transfer 和 transferFrom函数的区别

transfertransferFrom 是 ERC20 标准中两个核心函数,它们有着不同的用途和工作方式。

  1. 基本定义:

    • transfer:

      function transfer(address recipient, uint256 amount) public returns (bool)
    • transferFrom:

      function transferFrom(address sender, address recipient, uint256 amount) public returns (bool)
  2. 主要区别:

    a) 调用者:

    • transfer: 直接由代币持有者调用。
    • transferFrom: 可以由第三方调用,前提是该第三方已被授权

    b) 资金来源:

    • transfer: 资金总是从调用者(msg.sender)的账户转出。
    • transferFrom: 资金从 sender 参数指定的账户转出,而不一定是调用者的账户。

    c) 授权机制:

    • transfer: 不需要预先授权。
    • transferFrom: 需要 sender 事先通过 approve 函数授权给调用者足够的额度。
  3. 用途:

    • transfer:

      • 用于直接转账,代币持有者将代币发送给另一个地址。
      • 简单的点对点转账场景。
    • transferFrom:

      • 用于授权转账,允许合约或第三方代表代币持有者转移代币。
      • 用于复杂的DeFi应用,如去中心化交易所、流动性池等。
      • 实现代币的"代付"功能,允许用户用代币支付gas费。
  4. 工作流程:

    transfer:

    1. 调用者直接执行 transfer
    2. 检查调用者余额。
    3. 从调用者账户扣除代币,增加到接收者账户。

    transferFrom:

    1. 代币持有者先调用 approve,授权某地址使用一定数量的代币。
    2. 被授权的地址调用 transferFrom
    3. 检查授权额度和发送者余额。
    4. 从发送者账户扣除代币,增加到接收者账户,并减少授权额度。
  5. 使用场景举例:

    • transfer: 用户A直接向用户B发送代币。
    • transferFrom: 去中心化交易所代表用户执行交易,或智能合约自动从用户账户扣除订阅费用。

实现TokenBank合约:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/**
 * @title TokenBank
 * @dev A smart contract that allows users to deposit and withdraw ERC20 tokens.
 */
contract TokenBank {

    /// @dev A mapping to store balances for each token address and user address.
    mapping(address => mapping(address => uint256)) private balances;

    /**
     * @dev Deposits a specified amount of ERC20 tokens from the caller's address to the contract.
     * @param token The address of the ERC20 token to deposit.
     * @param amount The amount of tokens to deposit.
     *
     * Emits an event if the deposit is successful.
     *
     * Requirements:
     * - `amount` must be greater than zero.
     * - The transfer from the caller to the contract must be successful.
     */
    function deposit(address token, uint256 amount) public {
        require(amount > 0, "Amount must be greater than zero");
        require(
            IERC20(token).transferFrom(msg.sender, address(this), amount),
            "Transfer failed"
        );
        balances[token][msg.sender] += amount;

        // Emit an event for successful deposits (optional)
        // emit Deposit(msg.sender, token, amount);
    }

    /**
     * @dev Withdraws a specified amount of ERC20 tokens from the contract to the caller's address.
     * @param token The address of the ERC20 token to withdraw.
     * @param amount The amount of tokens to withdraw.
     *
     * Emits an event if the withdrawal is successful.
     *
     * Requirements:
     * - `amount` must be greater than zero.
     * - The caller must have sufficient balance of the specified token.
     * - The transfer from the contract to the caller must be successful.
     */
    function withdraw(address token, uint256 amount) public {
        require(amount > 0, "Amount must be greater than zero");
        require(balances[token][msg.sender] >= amount, "Insufficient balance");
        balances[token][msg.sender] -= amount;
        require(IERC20(token).transfer(msg.sender, amount), "Transfer failed");

        // Emit an event for successful withdrawals (optional)
        // emit Withdrawal(msg.sender, token, amount);
    }

    /**
     * @dev Returns the balance of a specific ERC20 token for a given user.
     * @param token The address of the ERC20 token.
     * @param account The address of the user.
     * @return The balance of the specified token for the user.
     */
    function balanceOf(address token, address account) public view returns (uint256) {
        return balances[token][account];
    }
}

细心的朋友可能发现了,我们直接引用了openzeppelin接口,这也是上文中提到的,真实的生产环境中,我们不需要自己实现这些能力。

如何使用和验证我们的合约是否正确呢?

部署ERC20合约:

我们首先部署ERC20合约,这里为了方便起见,我们还是使用remix, 因为需要钱包授权交互,我们使用真实的测试网络。

我这里直接贴出来我自己部署的合约地址:

0x2F91329978dBb23fEa7FE89Ab98c24BE808537B0

部署成功后,可以看到这个样子:

Xnip2024-07-08_21-15-33.png

将ERC20代币添加到自己的钱包中:

为了交互方便,我们需要将这个token添加到自己的钱包中:

Xnip2024-07-08_21-17-48.png

Xnip2024-07-08_21-18-35.png

添加完代币之后,我们可以代币列表中看到我们刚刚添加的代币。

部署TokenBank合约:

Xnip2024-07-08_21-33-41.png

我这里直接贴出来我自己部署的合约地址:

0xc4c85b3aadced03c018e7dba42718104f33d7166

用户调用ERC20的approve

为了顺利实现TokenBank的功能,我们需要用户授权TokenBank合约调用ERC20一部分的份额。

Xnip2024-07-08_21-47-29.png

授权一定要在存款和取款行为之前,否则交易会失败。

调用deposit方法:

我们调用deposit方法,入参填入ERC20合约地址和需要存入的数量

Xnip2024-07-08_21-49-19.png

点击拉起钱包交互,点击确认。交易成功。

Xnip2024-07-08_21-53-15.png

调用withdraw方法:

调用withdraw方法,入参填入自己的钱包地址和取款存入的数量;

Xnip2024-07-08_21-51-54.png

Xnip2024-07-08_21-52-42.png

整个流程跑通。

最后

如果你期望我们的TokenBank合约可以接收原生代币,可以继承手把手教你实现BigBank这篇文章中的合约,一切都显的自然了。

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

0 条评论

请先 登录 后评论
Louis
Louis
web3 developer,技术交流或者有工作机会可加VX: magicalLouis