Solidity 代币映射合约,兑换代币的思路和代码分享
   9 分钟阅读    朱安邦

最近写了一个代币映射的合约,已经投入生产环境并稳定运行。可能很多人做金融项目的时候也会遇到类似的场景,所以分享一下我的实现思路,让看到的合约开发者少走一些弯路,抛砖引玉,仅供参考。

代币映射的需求

  • xETH 1:1 映射为 ETH
  • xSHIB 1:1 映射为 SHIB

其中xETHxSHIB这两个 x 开头的代币是我写的 Farm 合约内给用户发的农场奖励币, x 代币本身没有价值,只有兑换为本币才有价值。(Farm 内逻辑比较复杂,有不同锁定期,不同挖矿权重的概念,Farm 相关的分享,后续有时间再整理;当前仅分享代币映射合约的内容)

因为 Token Swap 通常指的是 Uniswap 类 Dapp 内代币的交换。所以这个合约我给起了个 Token Mapping 的名字。

需求整理

本质是非价值资产,映射为价值资产。 为了兼容以后,我没有写死具体映射哪个合约,可以增加任意数量的映射对,使用下面的形式 :

  • ✅ 允许 Token 映射为 主网币
  • ✅ 允许 TokenA 映射为 TokenB
  • ❌ 禁止 主网币 映射为 Token

大家在写合约的时候,尽量做的兼容一些,不要写的太耦合。

关于精度和汇率的处理

我接到的需求是 1:1 映射,但是写合约的时候,是计划支持任意比例的映射。所以这里需要有一个固定的乘数,假设为 BASE_MUL = 10000;如果配置映射对的时候,传入的汇率是 10000,则代表 100%映射。传入 5000,则代表 50%的映射。合约内部计算最终转给用户的金额数量时,可以使用公式 user.amount * 10000 / BASE_MUL,这样就解决非1:1映射的问题。

这面这么做,看似合理,其实还有一个问题,就是不同代币的汇率是不同的,ETH/SHIB 都是 18 位精度的,但是区块链上还有精度不是 18 的代币。所以我们写代码的时候,还需要考虑非 18 位之间互相转换的问题;所以 BASE_MUL = 1e18 才能更好的普及多精度,此时需要传入的 _mapping_rate 计算公式为 18 - FromDecimals + ToDecimals。这个公式非常重要,我们根据这个公式,来演示一下不同精度的代币之间映射场景:

  • From 和 To 币种的精度都一样,且都是 1e18,假设映射金额 user.amount = 1e18
    • 如果是 1:1 映射,则传入 _mapping_rate 应该为 1e18
    • taretAmount = 1e18 * 1e18 / 1e18 => 1e18
    • 如果是 1:0.5 映射,则传入 _mapping_rate 应该为 1e18/2
    • taretAmount = 1e18 * 1e18/2 / 1e18 => 5e17
    • 如果是 1:2 映射,则传入 _mapping_rate 应该为 1e18*2
    • taretAmount = 1e18 * 1e18*2/ 1e18 => 2e18
  • From 和 To 币种的精度不一样,1:1 映射
    • FromDecimals 为 18
      • ToDecimals 为 8 : 则传入 _mapping_rate1e8(18-18+8)
      • ToDecimals 为 6 : 则传入 _mapping_rate1e6(18-18+6)
      • ToDecimals 为 2 : 则传入 _mapping_rate1e2(18-18+2)
    • ToDecimals 为 18
      • FromDecimals 为 8 : 则传入 _mapping_rate1e28(18-8+18)
      • FromDecimals 为 6 : 则传入 _mapping_rate1e30(18-6+18)
      • FromDecimals 为 2 : 则传入 _mapping_rate1e34(18-2+18)
    • FromDecimals 为 8
      • ToDecimals 为 18 : 则传入 _mapping_rate1e28
      • ToDecimals 为 8 : 则传入 _mapping_rate1e18(18-8+8)
      • ToDecimals 为 6 : 则传入 _mapping_rate1e16(18-8+6)
      • ToDecimals 为 2 : 则传入 _mapping_rate1e12(18-8+2)
  • 如果是 1:0.5 映射,则传入 _mapping_rate(18 - FromDecimals + ToDecimals) / 2
  • 如果是 1:2 映射,则传入 _mapping_rate(18 - FromDecimals + ToDecimals) * 2

上面 BASE_MUL = 1e18 是在映射对里配置的,配置给出添加和更新两种方法,并且只能是 owner 来使用

  • addMappingPair( address _from_token, address _to_token, uint256 _mapping_rate)
  • updatePairRate( address _from_token, address _to_token, uint256 _mapping_rate)

关于信息读取的处理

设置好映射对的添加,剩下就是获取映射对代币映射的操作了

1.获取映射对

获取内容根据常规的标准,提供下面四个方法

  • pairs(pair_index): 获取指定索引的映射对
    fromToken: 0xAAA,
    toToken: 0xBBB,
    
  • getPairs: 获取所有映射对
  • mappingRates(_from_token,_to_token): 通过 fromToken + toToken 获取汇率
    • 如果映射汇率为 1e18, 则用户收到的 targetAmount 为 user.amount * 1e18 / 1e18
  • pairLength: 合约内的映射对数量。如返回 1, 代表有一个映射对

2.代币映射

  • mappingToken(_from_token,_to_token,_amount): 映射代币
    • from token 兑换为 to tokenfrom token 的数量为 amount
    • _to_token 为主网币时候,需要传入 0x0000000000000000000000000000000000000000

考虑到风险,如果映射对内的合约被攻击了,我们需要停止映射。所以这里面需要做 _mapping_rate 是否为 0 的判断。如果为 0,则禁止映射。

如果遇到意外情况,还可以设置一个全局的开关mappingStatus。只有mappingStatus 为 true 才允许映射。

管理功能

合约内可能会有很多资产,可能会有提取资产的需求 withdraw;需要根据传入地址决定提取哪一种资产。资产主要是 2 类,主网币 + ERC20 Token

按照现在 DAO 逻辑,owner 最终是给 DAO 合约的;可能有某个资产的销毁需求,所以又做了一个伪销毁方法burn,把指定金额的币转入黑洞地址(搜索代币供应量的时候,总量不变;但是转入黑洞地址的钱也提取不出来)。

完整代码

最终的代码如下

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

import '@openzeppelin/contracts/access/Ownable.sol';
import '@openzeppelin/contracts/token/ERC20/IERC20.sol';
import '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol';

/// @title Contract for exchanging ERC20 tokens
/// @author Anbang
/// @notice One-way conversion, irreversible
contract TokenMapping is Ownable {
    using SafeERC20 for IERC20;

    /* ============ Struct ============ */
    struct MappingType {
        address fromToken;
        address toToken;
    }

    /* ============ State Variables ============ */
    /// @notice mapping status
    /// @dev can only be set by owner
    /// @return current status
    bool public mappingStatus = true;

    /// @notice pair
    /// @dev if you add a pair, you need push
    MappingType[] public pairs;

    /// @notice pair rate
    /// @dev When calculating, you need to divide by BASE_MUL
    /// @return current pair rate
    mapping(address => mapping(address => uint256)) public mappingRates;

    uint256 internal constant BASE_MUL = 1e18;

    /* ============ Events ============ */
    event PairAdd(address indexed from_token, address indexed to_token, uint256 mapping_rate);
    event PairUpdate(address indexed from_token, address indexed to_token, uint256 mapping_rate);
    event MappingStatus(bool status);
    event Withdraw(address indexed token_address, address indexed to_address, uint256 amount);
    event Burn(address indexed token_address, uint256 amount);
    event MappingToken(address indexed from_token, address indexed to_token, address indexed to_address, uint256 source_amount, uint256 target_amount);
    event Error(address caller, bytes data);

    /* ============ Modifiers ============ */
    /// @notice TODO
    /// @dev
    modifier pairLimit(
        address _from_token,
        address _to_token,
        uint256 _mapping_rate
    ) {
        require(_from_token != address(0), 'Invalid from address');
        require(_from_token != _to_token, 'Same address');
        _;
    }

    /* ============ Limit Functions ============ */
    function addMappingPair(
        address _from_token,
        address _to_token,
        uint256 _mapping_rate
    ) public onlyOwner pairLimit(_from_token, _to_token, _mapping_rate) {
        require(mappingRates[_from_token][_to_token] == 0, 'Pair already exists');
        require(_mapping_rate != 0, 'Rate error');
        mappingRates[_from_token][_to_token] = _mapping_rate;
        // check and push
        bool pairIsHas = false;
        for (uint256 i = 0; i < pairs.length; i++) {
            MappingType memory item = pairs[i];
            if (item.fromToken == _from_token && item.toToken == _to_token) {
                pairIsHas = true;
                break;
            }
        }
        if (!pairIsHas) {
            pairs.push(MappingType({ fromToken: _from_token, toToken: _to_token }));
        }
        emit PairAdd(_from_token, _to_token, _mapping_rate);
    }

    function updatePairRate(
        address _from_token,
        address _to_token,
        uint256 _mapping_rate
    ) public onlyOwner pairLimit(_from_token, _to_token, _mapping_rate) {
        require(mappingRates[_from_token][_to_token] != 0, "Pair doesn't exist");
        mappingRates[_from_token][_to_token] = _mapping_rate;
        emit PairUpdate(_from_token, _to_token, _mapping_rate);
    }

    function setMappingStatus(bool _status) public onlyOwner {
        mappingStatus = _status;
        emit MappingStatus(_status);
    }

    // withdraw token or gasCoin
    // gasCoin token_address is address(0)
    function withdraw(
        address _token_address,
        address _to_address,
        uint256 _amount
    ) public onlyOwner {
        require(_to_address != address(0), 'Invalid to_address');
        if (_token_address == address(0)) {
            payable(_to_address).transfer(_amount);
        } else {
            IERC20(_token_address).transfer(_to_address, _amount);
        }
        emit Withdraw(_token_address, _to_address, _amount);
    }

    function burn(address _token_address, uint256 _amount) public onlyOwner {
        IERC20(_token_address).transfer(address(1), _amount);
        emit Burn(_token_address, _amount);
    }

    /* ============ Main Functions ============ */
    /// @notice mapping from_token for to_token
    /// @param _from_token: source token
    /// @param _to_token: target token
    /// @param _amount: mapping token
    function mappingToken(
        address _from_token,
        address _to_token,
        uint256 _amount
    ) public payable {
        require(mappingStatus, 'Mapping closed');
        require(mappingRates[_from_token][_to_token] != 0, "Pair doesn't exist");
        require(_amount > 0, 'Invalid amount');
        uint256 targetAmount = (_amount * mappingRates[_from_token][_to_token]) / BASE_MUL;

        IERC20(_from_token).safeTransferFrom(address(msg.sender), address(this), _amount);
        if (_to_token == address(0)) {
            payable(msg.sender).transfer(targetAmount);
        } else {
            IERC20(_to_token).transfer(msg.sender, targetAmount);
        }
        emit MappingToken(_from_token, _to_token, msg.sender, _amount, targetAmount);
    }

    function pairLength() external view returns (uint256) {
        return pairs.length;
    }

    function getPairs() external view returns (MappingType[] memory) {
        return pairs;
    }

    // receive
    receive() external payable {}

    fallback() external {
        emit Error(msg.sender, msg.data);
    }
}

单元测试等就不放上来了,那些对于理解合约来说不重要。

合约描述文档

方法:只读

  • mappingStatus: 当前合约的映射状态
    • true: 开启状态
    • false: 关闭状态;

方法: 对于用户

  • mappingToken(_from_token,_to_token,_amount): 映射代币
    • from token 兑换为 to tokenfrom token 的数量为 amount
    • _to_token 为主网币时候,需要传入 0x0000000000000000000000000000000000000000
    • 错误解析
      • 当收到 Mapping closed 错误的时候,此时 mappingStatus 处在 false 状态: 所有代币不允许进行映射。
      • 当收到 Pair doesn't exist 错误的时候,此时 mappingRates[_from_token][_to_token] 为 0,需要添加映射对。
      • 当收到 Invalid amount 错误的时候,此时传入的 _amount 为 0,请输入需要映射的数量。
      • 当收到 ERC20: transfer amount exceeds allowance错误的时候,此时_from_token 为 Token 地址,没有对 TokenMapping 合约做授权。

方法:对于管理

  • 🔒 addMappingPair(_from_token,_to_token,_mapping_rate): 添加映射对
    • _to_token 为主网币时候,需要传入 0x0000000000000000000000000000000000000000
    • _mapping_rate 是映射汇率,基础乘数是 1e18(“1000000000000000000”)
      • ‼️ 重要: _mapping_rate 计算公式为 18 - FromDecimals + ToDecimals
      • 代码内的转换逻辑为: taretAmount = user.amount * _mapping_rate / 1e18
    • 如果收到错误信息 Pair already exists: 表示该映射对已经存在了,如果想要修改映射汇率,请使用 updatePairRate 方法。
    • 如果收到错误信息 Invalid from address: 代表 _from_token 为 0,不支持从主网币映射为 token 币;
    • 如果收到错误信息 Same address: 代表 _from_token_to_token 相同,需要核对地址参数。
  • 🔒 updatePairRate(_from_token,_to_token,_mapping_rate): 更新映射对的映射比例
    • 如果收到错误信息 Pair doesn't exist;表示该映射对不存在,请使用 addMappingPair 方法添加。
    • 注意: 如果将 _mapping_rate 设置为 0,代表关闭该映射对,以后如果再次开启此映射,需要重新添加该添加对。
  • 🔒 setMappingStatus (bool _status): 设置兑换状态
    • true: 开启状态
    • false: 关闭状态;
  • 🔒 withdraw(_token_address, _to_address, uint256 _amount): 取出指定 token_addressto address
    • 如果收到错误信息 Invalid to_address : 代表 to_address 地址为 0
    • 如果 _token_address0x0000000000000000000000000000000000000000 表示为提取主网币
  • 🔒 burn(_token_address, uint256 _amount)
    • 销毁指定 token_addressaddress(1)
    • 该方法不是真正的销毁
    • 不支持销毁主网币

owner 权限相关的函数

  • transferOwnership : 设置新的 owner 地址
    • 比如转移给 DAO 地址。
  • renounceOwnership : 放弃所有权 (该方法谨慎考虑后才能操作)
    • 放弃所有权将使合约没有 owner,将无法再调用 onlyOwner 函数。

用映射方案代替稳定币兑换

对于散户来说,正常 USDT 换成 USDC 是在 Swap 或者交易所兑换。Swap 内保守是每一层都要收千三的手续费,还有滑点损失。交易所分为挂单手续费和吃单手续费,大约在千一手续费+提币费;如果新公链项目方想在自己公链上做稳定币兑换业务,或者项目方想赚这个钱。可以用我这个映射合约方案来解决,无滑点损失,引导用户在自己的 Dapp 内使用即可。这样只需要很少的资金就可以稳定币的来回兑换,比去主流 swap 内组 LP 的资金利用率高很多,这个方案几乎是能想到的资金利用率最高方法了。

比如可以添加两个交易对,每次兑换收千三手续费,无滑点:

  • USDT ---> USDC: 1000 => 997
  • USDC ---> USDT: 1000 => 997

这些值都是可以任意设置的,比如你想要极低的手续费,可以设置兑换 1 百万收 1 块的手续费

  • USDT ---> USDC: 1000000 => 999999
  • USDC ---> USDT: 1000000 => 999999

如果当市场普遍看衰 USDT,但是你认为可以起来,还可以设置 1000 个 USDT 兑换 500 USDC。并禁止 USDC 兑换 USDT

  • USDT ---> USDC: 1000 => 500
  • USDC ---> USDT: 禁止

如果两者之间,一个稳定币出现了悲观情绪,导致中心化交易所兑换比例比较大,代码层面是以通过 updatePairRate 来动态调整;极端情况下有价格脱锚风险的时候,或者你想套利的时候,可以直接把汇率设为0,禁止某个方向的兑换。

Tips: 除了正常的 Swap 兑换外,Pancake 还有一个恒定和的稳定币兑换,它在恒定乘积公式 (x*y=k) 之上添加恒定和公式 (x+y=k),这种方式可以大大提高资金利用率和手续费,但是相比我的这个映射的解决方案,单纯资金利用率上来说还是个弟弟。

下面重点说下映射方案的优缺点

映射方案的缺点

  1. ❌ 不是完全的去中心化
    1. 如果池子内没有流动性了,则无法继续进行兑换了
    2. owner 可以设置全局兑换状态
    3. owner 可以开启或关闭某个映射对的兑换状态
    4. owner 可以把流动性内的代币取出

上面的所有缺点,仅仅只是因为这个方案违背了完全去中心化的理念导致的。

在完全去中心化方案中,即使一个稳定币有很大的脱锚风险(或归零风险),也是由市场波动来决定的,而且在去中心化方案中是没有代币归零的概念,代币的价格可以无限接近于零,但永远不等于 0;当市场交易订单把价格打到极低的时候,比如把 UST 打到 $ 0.0000000000001/个,也是属于正常的兑换场景,由此产生的损失由流动性提供者以及资产持有者来承担。如果你感觉有风险,你可以选择撤出流动性或把代币卖出。

完全去中心化方案的核心是:先有过程,再有结果 ;由过程来推动结果,过程中的损失由资产支持者承担。

而金融的本质是趋利避害,在危险没有到来的时候,假设已经快到某个结果了,由这个假设结果来指导当下的操作策略。这种思想是:先有结果,再有过程;由过程来获得结果来临前的资产优势,从而达到可控范围内的收益;当然这种情况下也可能因为结果判断错误,导致可控范围内的资产损失。(我不是专业的交易者,这里关于金融的理解可能不对,仅仅是从合约层面考虑可能有的场景,从而让自己写出更优秀的解决方案)

映射方案的优点

  1. ✅ 资金利用率达到极致:资金率最高的解决方法,如果池子没有To 币种流动性,直接增加即可(可充值,也可把多的 From 币种 搬成To 币种
  2. ✅ 市场大幅波动的时候:可以通过设置全局兑换开关,一键暂停。合约内资产可以做合理配置,等稳定后再次开启。
  3. ✅ 某个稳定币大幅异动时,可以单独进行暂停
  4. ✅ 可以进行流动性的取款,实现十个锅九个盖的操作

常见代币的添加

✅ xETH ---> ETH  1:1映射  (18位精度 映射为 18位精度)
_from_token   : 0x3333333333333333333333333333333333333333
_to_token     : 0x0000000000000000000000000000000000000000
_mapping_rate : 1000000000000000000


✅ xSHIB ---> SHIB  1:1映射 (18位精度 映射为 18位精度)
_from_token   : 0x4444444444444444444444444444444444444444
_to_token     : 0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE
_mapping_rate : 1000000000000000000


✅ USDT ---> USDC   1:0.995映射  (6位精度 映射为 6位精度)
0xdAC17F958D2ee523a2206206994597C13D831ec7
0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
_mapping_rate : 995000000000000000


✅ USDC ---> USDT   1:0.995映射 (6位精度 映射为 6位精度)
0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
0xdAC17F958D2ee523a2206206994597C13D831ec7
_mapping_rate : 995000000000000000

合约交流

如果你对这个合约有更多的想法,欢迎一起交流,可以给我发邮件,或者在 Twitter 与我互动。

我的 Twitter 账号 @anbang_account

— 😊 全文完 😊 —

关于站长

我叫朱安邦,本站的站长。如果您对网站有什么好的建议,欢迎在 Twitter 上与我交流