最近写了一个代币映射的合约,已经投入生产环境并稳定运行。可能很多人做金融项目的时候也会遇到类似的场景,所以分享一下我的实现思路,让看到的合约开发者少走一些弯路,抛砖引玉,仅供参考。
代币映射的需求
xETH
1:1 映射为ETH
xSHIB
1:1 映射为SHIB
其中xETH
和xSHIB
这两个 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_rate
为1e8
(18-18+8) - ToDecimals 为 6 : 则传入
_mapping_rate
为1e6
(18-18+6) - ToDecimals 为 2 : 则传入
_mapping_rate
为1e2
(18-18+2)
- ToDecimals 为 8 : 则传入
- ToDecimals 为 18
- FromDecimals 为 8 : 则传入
_mapping_rate
为1e28
(18-8+18) - FromDecimals 为 6 : 则传入
_mapping_rate
为1e30
(18-6+18) - FromDecimals 为 2 : 则传入
_mapping_rate
为1e34
(18-2+18)
- FromDecimals 为 8 : 则传入
- FromDecimals 为 8
- ToDecimals 为 18 : 则传入
_mapping_rate
为1e28
- ToDecimals 为 8 : 则传入
_mapping_rate
为1e18
(18-8+8) - ToDecimals 为 6 : 则传入
_mapping_rate
为1e16
(18-8+6) - ToDecimals 为 2 : 则传入
_mapping_rate
为1e12
(18-8+2)
- ToDecimals 为 18 : 则传入
- FromDecimals 为 18
- 如果是
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 token
,from 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 token
,from 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_address
到to address
- 如果收到错误信息
Invalid to_address
: 代表to_address
地址为 0 - 如果
_token_address
为0x0000000000000000000000000000000000000000
表示为提取主网币
- 如果收到错误信息
- 🔒
burn(_token_address, uint256 _amount)
- 销毁指定
token_address
到address(1)
- 该方法不是真正的销毁
- 不支持销毁主网币
- 销毁指定
owner 权限相关的函数
transferOwnership
: 设置新的 owner 地址- 比如转移给 DAO 地址。
renounceOwnership
: 放弃所有权 (该方法谨慎考虑后才能操作)- 放弃所有权将使合约没有 owner,将无法再调用 onlyOwner 函数。
用映射方案代替稳定币兑换
对于散户来说,正常 USDT 换成 USDC 是在 Swap 或者交易所兑换。Swap 内保守是每一层都要收千三的手续费,还有滑点损失。交易所分为挂单手续费和吃单手续费,大约在千一手续费+提币费;如果新公链项目方想在自己公链上做稳定币兑换业务,或者项目方想赚这个钱。可以用我这个映射合约方案来解决,无滑点损失,引导用户在自己的 Dapp 内使用即可。这样只需要很少的资金就可以稳定币的来回兑换,比去主流 swap 内组 LP 的资金利用率高很多,这个方案几乎是能想到的资金利用率最高方法了。
比如可以添加两个交易对,每次兑换收千三手续费,无滑点:
USDT ---> USDC
: 1000 => 997USDC ---> USDT
: 1000 => 997
这些值都是可以任意设置的,比如你想要极低的手续费,可以设置兑换 1 百万收 1 块的手续费
USDT ---> USDC
: 1000000 => 999999USDC ---> USDT
: 1000000 => 999999
如果当市场普遍看衰 USDT,但是你认为可以起来,还可以设置 1000 个 USDT 兑换 500 USDC。并禁止 USDC 兑换 USDT
USDT ---> USDC
: 1000 => 500USDC ---> USDT
: 禁止
如果两者之间,一个稳定币出现了悲观情绪,导致中心化交易所兑换比例比较大,代码层面是以通过 updatePairRate
来动态调整;极端情况下有价格脱锚风险的时候,或者你想套利的时候,可以直接把汇率设为0
,禁止某个方向的兑换。
Tips: 除了正常的 Swap 兑换外,Pancake 还有一个恒定和的稳定币兑换,它在恒定乘积公式 (x*y=k
) 之上添加恒定和公式 (x+y=k
),这种方式可以大大提高资金利用率和手续费,但是相比我的这个映射的解决方案,单纯资金利用率上来说还是个弟弟。
下面重点说下映射方案的优缺点
映射方案的缺点
- ❌ 不是完全的去中心化
- 如果池子内没有流动性了,则无法继续进行兑换了
- owner 可以设置全局兑换状态
- owner 可以开启或关闭某个映射对的兑换状态
- owner 可以把流动性内的代币取出
上面的所有缺点,仅仅只是因为这个方案违背了完全去中心化的理念导致的。
在完全去中心化方案中,即使一个稳定币有很大的脱锚风险(或归零风险),也是由市场波动来决定的,而且在去中心化方案中是没有代币归零的概念,代币的价格可以无限接近于零,但永远不等于 0;当市场交易订单把价格打到极低的时候,比如把 UST 打到 $ 0.0000000000001/个
,也是属于正常的兑换场景,由此产生的损失由流动性提供者以及资产持有者来承担。如果你感觉有风险,你可以选择撤出流动性或把代币卖出。
完全去中心化方案的核心是:先有过程,再有结果 ;由过程来推动结果,过程中的损失由资产支持者承担。
而金融的本质是趋利避害,在危险没有到来的时候,假设已经快到某个结果了,由这个假设结果来指导当下的操作策略。这种思想是:先有结果,再有过程;由过程来获得结果来临前的资产优势,从而达到可控范围内的收益;当然这种情况下也可能因为结果判断错误,导致可控范围内的资产损失。(我不是专业的交易者,这里关于金融的理解可能不对,仅仅是从合约层面考虑可能有的场景,从而让自己写出更优秀的解决方案)
映射方案的优点
- ✅ 资金利用率达到极致:资金率最高的解决方法,如果池子没有To 币种流动性,直接增加即可(可充值,也可把多的 From 币种 搬成To 币种)
- ✅ 市场大幅波动的时候:可以通过设置全局兑换开关,一键暂停。合约内资产可以做合理配置,等稳定后再次开启。
- ✅ 某个稳定币大幅异动时,可以单独进行暂停
- ✅ 可以进行流动性的取款,实现十个锅九个盖的操作
常见代币的添加
✅ 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
— 😊 全文完 😊 —