VirtualsProtocol是Base链上一个类似于Pump.fun的发币平台。这篇文章主要来讲讲Virtuals的合约代码,学习他们的整个发币流程和细节。
Virtuals Protocol 是 Base 链上一个类似于 Pump.fun 的发币平台。这篇文章主要来讲讲 Virtuals 的合约代码,学习他们的整个发币流程和细节。
与 Pump 类似,Virtuals 上发币也分两个阶段,分别是内盘和外盘。内盘是指在合约内的 Bonding Curve 曲线上进行交易的阶段。外盘是指在 DEX,例如 Uniswap 上交易的阶段。当内盘销售结束时(一般是达到某市值,或者是预设数量的 Token 已经售完),合约将自动在 DEX 上创建交易对,此时要交易该 Token 便只能在 DEX 中进行。
Virtuals 中发行的币在内盘阶段只能通过 VITRUAL Token 购买。在 DEX 阶段,一般也是的 VIRTUAL-TOKEN 的交易对。这段时间 Virtuals 比较火,因此 VITRUAL 需求量大,涨势很好。
内盘部分主要涉及下面这些合约:
看过 Uniswap 代码的朋友应该对这一套架构比较熟悉,Virtuals 在内盘代码的架构上借鉴了 Uniswap 的逻辑。其中一个区别是,Virtuals 的内盘和外盘发行的是两个 Token。这里的 FERC20 是内盘阶段交易的 MEME Token,仅在内盘阶段使用,当进入到外盘后,会变成另一个 Token。用户可以以 1: 1 的比例将内盘的 MEME 兑换成外盘的 MEME。
用户操作接口在 Bonding
合约中,主要有下面三个方法:
先来看 launch
方法。
// Bonding.sol
function launch(
string memory _name,
string memory _ticker,
uint8[] memory cores,
string memory desc,
string memory img,
string[4] memory urls,
uint256 purchaseAmount
) public nonReentrant returns (address, address, uint) {
传入一些基本的 Token 信息。
// Bonding.sol
// fee = 100 VIRTUAL
require(
purchaseAmount > fee,
"Purchase amount must be greater than fee"
);
// VIRTUAL
address assetToken = router.assetToken();
require(
IERC20(assetToken).balanceOf(msg.sender) >= purchaseAmount,
"Insufficient amount"
);
uint256 initialPurchase = (purchaseAmount - fee);
IERC20(assetToken).safeTransferFrom(msg.sender, _feeTo, fee);
IERC20(assetToken).safeTransferFrom(
msg.sender,
address(this),
initialPurchase
);
发币是要收费的,之前是 10 VIRTUAL,最近改成了 100 VIRTUAL。传入的 purchaseAmount
中减去 100,剩余的数量就是发币者自己购买的初始数量。
// Bonding.sol
FERC20 token = new FERC20(string.concat("fun ", _name), _ticker, initialSupply, maxTx);
uint256 supply = token.totalSupply();
initialSupply
是由管理员设置的,目前是 10 亿,也就是说目前所有通过 Virtuals 发行的 Token,总供应量都是 10 亿。此时发行的 MEME 的 owner 是当前 Bonding
合约。
这里的 maxTx
是一个限制内盘 MEME 每笔转账数量的变量,目前没有限制,可以先不管。
// Bonding.sol
address _pair = factory.createPair(address(token), assetToken);
通过 factory
合约创建一个 Pair,也就是前面说的 FPair
。
// FFactory.sol
function _createPair(
address tokenA,
address tokenB
) internal returns (address) {
require(tokenA != address(0), "Zero addresses are not allowed.");
require(tokenB != address(0), "Zero addresses are not allowed.");
require(router != address(0), "No router");
FPair pair_ = new FPair(router, tokenA, tokenB);
_pair[tokenA][tokenB] = address(pair_);
_pair[tokenB][tokenA] = address(pair_);
pairs.push(address(pair_));
uint n = pairs.length;
emit PairCreated(tokenA, tokenB, address(pair_), n);
return address(pair_);
}
类似于 Uniswap 的写法,记录 Token 对应的 Pair。
FPair
的构造方法:
// FPair.sol
constructor(address router_, address token0, address token1) {
require(router_ != address(0), "Zero addresses are not allowed.");
require(token0 != address(0), "Zero addresses are not allowed.");
require(token1 != address(0), "Zero addresses are not allowed.");
router = router_;
tokenA = token0;
tokenB = token1;
}
注意 tokenA 总是新创建的 MEME,tokenB 总是 VIRTUAL。
再回到 Bonding
合约。
// Bonding.sol
// 给 router 合约授权,后面 addInitialLiquidity 需要转入
bool approved = _approval(address(router), address(token), supply);
require(approved);
// k = 3_000_000_000_000 * 10000 / 5000 = 6_000_000_000_000
uint256 k = ((K * 10000) / assetRate);
// 6000000000000000000000 = 6000 ether
uint256 liquidity = (((k * 10000 ether) / supply) * 1 ether) / 10000;
router.addInitialLiquidity(address(token), supply, liquidity);
这里先计算出来一个 liquidity
变量,实际值是 6000 ether。然后我们再来看 addInitialLiquidity
的代码:
// FRouter.sol
function addInitialLiquidity(
address token_,
uint256 amountToken_,
uint256 amountAsset_
) public onlyRole(EXECUTOR_ROLE) returns (uint256, uint256) {
require(token_ != address(0), "Zero addresses are not allowed.");
address pairAddress = factory.getPair(token_, assetToken);
IFPair pair = IFPair(pairAddress);
IERC20 token = IERC20(token_);
// 将初始的 1b MEME 转移到 pair 合约
token.safeTransferFrom(msg.sender, pairAddress, amountToken_);
// 这里的初始数量就是 1b,6000,单位都是 ether
pair.mint(amountToken_, amountAsset_);
return (amountToken_, amountAsset_);
}
方法参数分别是:
再来看 Pair 合约的 mint
方法:
// FPair.sol
function mint(
uint256 reserve0,
uint256 reserve1
) public onlyRouter returns (bool) {
require(_pool.lastUpdated == 0, "Already minted");
// 这里的初始数量就是 1b,6000,单位都是 ether
// 那么 k 就是 6000b ether
_pool = Pool({
reserve0: reserve0,
reserve1: reserve1,
k: reserve0 * reserve1,
lastUpdated: block.timestamp
});
emit Mint(reserve0, reserve1);
return true;
}
这里的计算就是 Bonding Curve 的核心。Virtuals 采用的是曲线模型是
X * Y = K
其中 K 就是上面代码中的 reserve0 * reserve1
,即 6000B ether。
也就是说内盘阶段的买卖都是在 X * Y = K
这条曲线上进行的,这和 Uniswap 是一致的。
注意这里的 mint
其实就是第一次生成 pair 的一个操作,同时初始化 reserve0 和 reserve1,k 的数值,并不是实际意义上的 mint token。
这时我们再回到 Bonding
的这行代码:
// Bonding.sol
router.addInitialLiquidity(address(token), supply, liquidity);
其实就是提供了公式中 X
和 Y
的初始值。
接着来看 launch
方法。
// Bonding.sol
Data memory _data = Data({
token: address(token),
name: string.concat("fun ", _name),
_name: _name,
ticker: _ticker,
supply: supply,
price: supply / liquidity,
marketCap: liquidity,
liquidity: liquidity * 2,
volume: 0,
volume24H: 0,
prevPrice: supply / liquidity,
lastUpdated: block.timestamp
});
Token memory tmpToken = Token({
creator: msg.sender,
token: address(token),
agentToken: address(0),
pair: _pair,
data: _data,
description: desc,
cores: cores,
image: img,
twitter: urls[0],
telegram: urls[1],
youtube: urls[2],
website: urls[3],
trading: true, // Can only be traded once creator made initial purchase
tradingOnUniswap: false
});
tokenInfo[address(token)] = tmpToken;
tokenInfos.push(address(token));
bool exists = _checkIfProfileExists(msg.sender);
// 记录用户创建的 MEME
if (exists) {
Profile storage _profile = profile[msg.sender];
_profile.tokens.push(address(token));
} else {
bool created = _createUserProfile(msg.sender);
if (created) {
Profile storage _profile = profile[msg.sender];
_profile.tokens.push(address(token));
}
}
uint n = tokenInfos.length;
emit Launched(address(token), _pair, n);
这部分主要是记录 MEME 和用户的相关信息。
// Bonding.sol
// Make initial purchase
IERC20(assetToken).forceApprove(address(router), initialPurchase);
router.buy(initialPurchase, address(token), address(this));
token.transfer(msg.sender, token.balanceOf(address(this)));
return (address(token), _pair, n);
最后这部分,实现了部署者购买初始数量的功能。注意这里的数量是已经减去部署费用之后的 VIRTUAL 数量。
这里的 router.buy()
方法最后会将购买到的 MEME 全部转给该合约,因此最后需要再将其转给 msg.sender
,即部署者。
router 合约的内容我们后面再看,先把 Bonding
合约的其它部分看完。
来看看 buy
方法的函数签名:
// Bonding.sol
function buy(
uint256 amountIn,
address tokenAddress
)
参数分别是要购买的 MEME 和数量,也就是说在内盘阶段,所有 MEME 的购买都是通过该方法进行。
// Bonding.sol
require(tokenInfo[tokenAddress].trading, "Token not trading");
// 获取根据(VIRTUAL,MEME)创建的 pair 地址
address pairAddress = factory.getPair(
tokenAddress,
router.assetToken()
);
IFPair pair = IFPair(pairAddress);
// A 是 MEME,B 是 VIRTUAL
(uint256 reserveA, uint256 reserveB) = pair.getReserves();
(uint256 amount1In, uint256 amount0Out) = router.buy(
amountIn,
tokenAddress,
msg.sender
);
这里校验的 trading
,在内盘阶段为 true,外盘阶段为 false,也就是说这里的 buy
是只能在内盘阶段被调用。
reserveA
和 reserveB
分别是 MEME 和 VIRTUAL 的数据,类似于 Uniswap 中的 reserve。
buy
方法的两个返回值分别是实际花费的 VIRTUAL 数量和购买获得的 MEME 数量。返回值的 amount1In
与参数的 amountIn
的区别是前者扣除了手续费。
接着是更新 MEME 的相关信息,这里省略:
// Bonding.sol
tokenInfo[tokenAddress] = ...;
// Bonding.sol
// gradThreshold = 0.125 b
if (newReserveA <= gradThreshold && tokenInfo[tokenAddress].trading) {
_openTradingOnUniswap(tokenAddress);
}
最后这里,当 Pair 中 Token 的数据小于一个阈值,也就是 Pair 中的 Token 的余额已经所剩不多时,在 DEX(Uniswap)中开一个新的交易对,即开外盘。
sell
方法与 buy
方法大同小异,只是换了交易方向,大家自己看看理解就好。
整体逻辑比较简单,核心主要在于这部分:
// Bonding.sol
// graduate 的作用是将 VIRTUAL 转移到本合约
router.graduate(tokenAddress);
// 授权 VIRTUAL 到 agent factory 合约
IERC20(router.assetToken()).forceApprove(agentFactory, assetBalance);
uint256 id = IAgentFactoryV3(agentFactory).initFromBondingCurve(
string.concat(_token.data._name, " by Virtuals"),
_token.data.ticker,
_token.cores,
// 线上数据
// 0xa7647ac9429fdce477ebd9a95510385b756c757c26149e740abbab0ad1be2f16
_deployParams.tbaSalt,
// 0x55266d75d1a14e4572138116af39863ed6596e7f
_deployParams.tbaImplementation,
// 259200 = 3 days
_deployParams.daoVotingPeriod,
// 0
_deployParams.daoThreshold,
assetBalance
);
address agentToken = IAgentFactoryV3(agentFactory)
.executeBondingCurveApplication(
id,
// 每个 MEME 都一样,1B
// 1_000_000_000
_token.data.supply / (10 ** token_.decimals()),
// 不同的 MEME 不一样
tokenBalance / (10 ** token_.decimals()),
pairAddress
);
_token.agentToken = agentToken;
router.approval(
pairAddress,
agentToken,
address(this),
IERC20(agentToken).balanceOf(pairAddress)
);
token_.burnFrom(pairAddress, tokenBalance);
这里的主要难点在于调用了 agentFactory
的两个方法:
initFromBondingCurve
可以简单理解为生成了一个流程对象,并返回该对象的 id。其将内盘 Pair 中的所有 VIRTUAL 余额全部转入了 agentFactory
合约中
executeBondingCurveApplication
中包含了创建新的外盘 MEME,添加流动性等逻辑。并给 Pair 合约 mint 了一些数量的外盘 MEME。
router 合约有这几个主要方法:
我们先来看 getAmountsOut
:
// FRouter.sol
function getAmountsOut(
address token,
address assetToken_,
uint256 amountIn
) public view returns (uint256 _amountOut) {
require(token != address(0), "Zero addresses are not allowed.");
address pairAddress = factory.getPair(token, assetToken);
IFPair pair = IFPair(pairAddress);
(uint256 reserveA, uint256 reserveB) = pair.getReserves();
uint256 k = pair.kLast();
uint256 amountOut;
if (assetToken_ == assetToken) {
uint256 newReserveB = reserveB + amountIn;
uint256 newReserveA = k / newReserveB;
amountOut = reserveA - newReserveA;
} else {
uint256 newReserveA = reserveA + amountIn;
uint256 newReserveB = k / newReserveA;
amountOut = reserveB - newReserveB;
}
return amountOut;
}
该方法的功能是计算买卖某 Token 时,能够获得多少数量。我们前面说过, reserveA
和 reserveB
分别是 MEME 和 VIRTUAL 的数据。那么当 assetToken_
传 VIRTUAL 的时候,代表买入(因为这里的逻辑是 reserveB 增多)。传其它地址的时候,代表卖出。
这里使用 X * Y = K
的公式,通过从 pair 中获得的 reserve 数据,来计算最终所能得到的数量。
buy
的代码如下:
// FRouter.sol
function buy(
uint256 amountIn,
address tokenAddress,
address to
) public onlyRole(EXECUTOR_ROLE) nonReentrant returns (uint256, uint256) {
require(tokenAddress != address(0), "Zero addresses are not allowed.");
require(to != address(0), "Zero addresses are not allowed.");
require(amountIn > 0, "amountIn must be greater than 0");
address pair = factory.getPair(tokenAddress, assetToken);
// 目前线上是 1
uint fee = factory.buyTax();
// 也就是 1%
uint256 txFee = (fee * amountIn) / 100;
address feeTo = factory.taxVault();
uint256 amount = amountIn - txFee;
IERC20(assetToken).safeTransferFrom(to, pair, amount);
IERC20(assetToken).safeTransferFrom(to, feeTo, txFee);
uint256 amountOut = getAmountsOut(tokenAddress, assetToken, amount);
IFPair(pair).transferTo(to, amountOut);
IFPair(pair).swap(0, amountOut, amount, 0);
return (amount, amountOut);
}
购买的时候需要付 1% 的手续费。这里要注意的是,X * Y = K
公式中的 reserveA
和 reserveB
是存储在 Pair 合约中的,但实际变化数量的计算是在 router 合约的,也就是说 Pair 合约中只存储 reserve 的值,但是并没有计算过程。当变化的数量在 router 中通过 getAmountsOut
方法计算好之后被传入到 Pair 中通过 swap
方法进行增减。
与 buy
相似的逻辑。
// FRouter.sol
function addInitialLiquidity(
address token_,
uint256 amountToken_,
uint256 amountAsset_
) public onlyRole(EXECUTOR_ROLE) returns (uint256, uint256) {
require(token_ != address(0), "Zero addresses are not allowed.");
address pairAddress = factory.getPair(token_, assetToken);
IFPair pair = IFPair(pairAddress);
IERC20 token = IERC20(token_);
// 将初始的 1B 新 MEME 转移到 pair 合约
token.safeTransferFrom(msg.sender, pairAddress, amountToken_);
// 这里的初始数量就是 1b,6000,单位都是 ether
pair.mint(amountToken_, amountAsset_);
return (amountToken_, amountAsset_);
}
注意这里的添加流动性并不是在 Uniswap 中添加,而是添加到内盘中的虚拟流动性,也就是说只是为了提供了 XYK
公式的初始数量。十亿的内盘 MEME 转入 Pair 合约,但是 VIRTUAL 并没有转入,只是提供了 6000 这样一个数量而已。
mint
方法我们前面也已经看过,主要是记录 Bonding Curve
公式的初始数量 X
, Y
, K
。
前面已经介绍过,Pair 中主要是为了存储 Bonding Curve
公式值。
已经介绍过
// FPair.sol
// 1,4 为 0 -> buy
// 2,3 为 0 -> sell
function swap(
uint256 amount0In,
uint256 amount0Out,
uint256 amount1In,
uint256 amount1Out
) public onlyRouter returns (bool) {
// 这里的 in out,都是 router 计算好了,传进来直接更新
// 而不是在这里通过公式计算的
uint256 _reserve0 = (_pool.reserve0 + amount0In) - amount0Out;
uint256 _reserve1 = (_pool.reserve1 + amount1In) - amount1Out;
_pool = Pool({
reserve0: _reserve0,
reserve1: _reserve1,
k: _pool.k,
lastUpdated: block.timestamp
});
emit Swap(amount0In, amount0Out, amount1In, amount1Out);
return true;
}
我们在前面的 router 合约中看到对于 swap
方法的调用是:
也就是说 swap
的四个参数,要么 1,4 同时为空,要么 2,3 同时为空。并且这里的参数都是在 router 中已经计算好的,直接 set 即可。
factory 合约主要包含的就是一个 createPair
方法,我们之前已经介绍过。
FERC20 是内盘阶段使用的 Token,是一个简单的 ERC20,唯一多出一个功能就是可以限制用户每笔交易可以转移的数量,也就是前面的 maxTx
变量的功能。不过目前没有限制,可以不管。
外盘部分的合约比较多,我们主要研究下面这些涉及发币逻辑的合约:
前面在 Bonding
合约的 _openTradingOnUniswap
方法中,调用到了 agentFactory
合约的 initFromBondingCurve
和 executeBondingCurveApplication
方法,我们就先从这两个方法入手来看。
// AgentFactoryV3.sol
function initFromBondingCurve(
string memory name,
string memory symbol,
uint8[] memory cores,
bytes32 tbaSalt,
address tbaImplementation,
uint32 daoVotingPeriod,
uint256 daoThreshold,
uint256 applicationThreshold_
) public whenNotPaused onlyRole(BONDING_ROLE) returns (uint256) {
address sender = _msgSender();
require(
IERC20(assetToken).balanceOf(sender) >= applicationThreshold_,
"Insufficient asset token"
);
require(
IERC20(assetToken).allowance(sender, address(this)) >=
applicationThreshold_,
"Insufficient asset token allowance"
);
require(cores.length > 0, "Cores must be provided");
IERC20(assetToken).safeTransferFrom(
sender,
address(this),
applicationThreshold_
);
uint256 id = _nextId++;
uint256 proposalEndBlock = block.number; // No longer required in v2
Application memory application = Application(
name,
symbol,
"",
ApplicationStatus.Active,
applicationThreshold_,
sender,
cores,
proposalEndBlock,
0,
tbaSalt,
tbaImplementation,
daoVotingPeriod,
daoThreshold
);
_applications[id] = application;
emit NewApplication(id);
return id;
}
这里的一个主要逻辑就是将 assetToken,即 VIRTUAL 转入该合约,为后面提供 Uniswap 的流动性做准备。Application
可以理解为一个发币流程对象,每次发一个外盘的币,都会有一个 Application
生成。如果有兴趣的话可以看看这块,我们的主要目的还是来研究整个发币的逻辑。
// AgentFactoryV3.sol
function executeApplication(uint256 id, bool canStake) public noReentrant {
// This will bootstrap an Agent with following components:
// C1: Agent Token
// C2: LP Pool + Initial liquidity
// C3: Agent veToken
// C4: Agent DAO
// C5: Agent NFT
// C6: TBA
// C7: Stake liquidity token to get veToken
Application storage application = _applications[id];
require(
msg.sender == application.proposer ||
hasRole(WITHDRAW_ROLE, msg.sender),
"Not proposer"
);
_executeApplication(id, canStake, _tokenSupplyParams);
}
代码注释中已经简单介绍了该方法的功能:
接下来我们来看这几个步骤的内容。
// AgentFactoryV3.sol
address token = _createNewAgentToken(
application.name,
application.symbol,
tokenSupplyParams_
);
// AgentFactoryV3.sol
function _createNewAgentToken(
string memory name,
string memory symbol,
bytes memory tokenSupplyParams_
) internal returns (address instance) {
instance = Clones.clone(tokenImplementation);
IAgentToken(instance).initialize(
[_tokenAdmin, _uniswapRouter, assetToken],
abi.encode(name, symbol),
tokenSupplyParams_,
_tokenTaxParams
);
allTradingTokens.push(instance);
return instance;
}
首先创建一个 Token,这里使用了 EIP1167 的概念,可以使用更少的 Gas 来部署,不熟悉的话可以看我之前写过这篇介绍 EIP1167 的文章。
这里创建的 Token 就是外盘 MEME,也就是在 Uniswap 中交易的正式 Token。接着对其进行初始化,注意由于 EIP1167 的限制,初始化只能额外调用 initialize
进行,而不能使用构造方法进行。
初始化的逻辑是 mint 一定数量的 MEME,以及在 DEX 中创建交易对。具体的代码逻辑我们后面再看。
// AgentFactoryV3.sol
// 上一步创建的 Uniswap 交易对地址
address lp = IAgentToken(token).liquidityPools()[0];
IERC20(assetToken).safeTransfer(token, initialAmount);
IAgentToken(token).addInitialLiquidity(address(this));
将之前转入的 VIRTUAL 全部转入 MEME 合约中,最后在 MEME 合约中调用 Uniswap 的 addLiquidity
方法提供流动性。
// C3
// AgentFactoryV3.sol
address veToken = _createNewAgentVeToken(
string.concat("Staked ", application.name),
string.concat("s", application.symbol),
lp,
application.proposer,
canStake
);
// AgentFactoryV3.sol
function _createNewAgentVeToken(
string memory name,
string memory symbol,
address stakingAsset,
address founder,
bool canStake
) internal returns (address instance) {
instance = Clones.clone(veTokenImplementation);
IAgentVeToken(instance).initialize(
name,
symbol,
founder,
stakingAsset,
block.timestamp + maturityDuration,
address(nft),
canStake
);
allTokens.push(instance);
return instance;
}
这里与创建外盘 MEME 的逻辑相似,同样使用了 EIP1167 的概念。
// AgentFactoryV3.sol
string memory daoName = string.concat(application.name, " DAO");
address payable dao = payable(
_createNewDAO(
daoName,
IVotes(veToken),
application.daoVotingPeriod,
application.daoThreshold
)
);
// AgentFactoryV3.sol
function _createNewDAO(
string memory name,
IVotes token,
uint32 daoVotingPeriod,
uint256 daoThreshold
) internal returns (address instance) {
instance = Clones.clone(daoImplementation);
IAgentDAO(instance).initialize(
name,
token,
nft,
daoThreshold,
daoVotingPeriod
);
allDAOs.push(instance);
return instance;
}
这里是创建 Dao 合约,主要是用于治理和投票等,我们就不着重看了,感兴趣的朋友可以自行研究。
// AgentFactoryV3.sol
uint256 virtualId = IAgentNft(nft).nextVirtualId();
IAgentNft(nft).mint(
virtualId,
_vault,
application.tokenURI,
dao,
application.proposer,
application.cores,
lp,
token
);
application.virtualId = virtualId;
这里发行的 NFT 有点类似于 Uniswap V3 中提供流动性获取的 NFT。也就是说这个 NFT 记录了一些关于这个外盘 MEME 的相关信息。例如 Token 地址,LP 地址,ve Token 地址等。
// AgentFactoryV3.sol
// C6
uint256 chainId;
assembly {
chainId := chainid()
}
address tbaAddress = IERC6551Registry(tbaRegistry).createAccount(
application.tbaImplementation,
application.tbaSalt,
chainId,
nft,
virtualId
);
IAgentNft(nft).setTBA(virtualId, tbaAddress);
为上一步 mint 的 NFT 创建一个合约钱包,这里使用的是 EIP6551 的相关逻辑,可以看我之前写的这篇文章。
// AgentFactoryV3.sol
IERC20(lp).approve(veToken, type(uint256).max);
IAgentVeToken(veToken).stake(
IERC20(lp).balanceOf(address(this)),
application.proposer,
defaultDelegatee
);
将前面的初始流动性 LP 质押到 ve Token 合约中获取 veToken。
AgentToken 就是外盘 MEME,与内盘的 MEME 1: 1 兑换。当进入外盘阶段之后,只有外盘 MEME 可以进行买卖,内盘 MEME 只能兑换成外盘 MEME。
initialize 中主要来看这段代码:
// AgentToken.sol
// 内盘 pair 中的数量
uint256 lpSupply = supplyParams.lpSupply * (10 ** decimals());
// totalSupply - lpSupply
uint256 vaultSupply = supplyParams.vaultSupply * (10 ** decimals());
_mintBalances(lpSupply, vaultSupply);
// AgentToken.sol
function _mintBalances(uint256 lpMint_, uint256 vaultMint_) internal {
if (lpMint_ > 0) {
_mint(address(this), lpMint_);
}
if (vaultMint_ > 0) {
_mint(vault, vaultMint_);
}
}
根据函数传入的参数结构解析,lpSupply
是当前时刻内盘 Pair 中内盘 MEME 的数量,vaultSupply
是 totalSupply - lpSupply
,也就是在内盘阶段被买走的 MEME 数量。也就是说,lpSupply
就是仍在 Pair 中没有被买走的数量。
_mintBalances
方法是给相应的地址 mint 对应数量的外盘 MEME。给当前外盘 MEME 合约本身 mint 的数量是 lpSupply
。vault
表示内盘 Pair,给它 mint 数量为 totalSupply - lpSupply
的外盘 MEME。
这个数量是什么意思,我们来思考一下,在内盘阶段,最初始一共有十亿个内盘 MEME,即 totalSupply = 1B
,并且是全部被转给了 Pair 合约。那么在经过内盘的买卖之后,假设 Pair 合约中剩余有 lpSupply
个 Token,则意味着已经被用户买走的数量是 totalSupply - lpSupply
。
这里给当前合约 mint 的数量是 lpSupply
,实际上是合约目前拥有的 MEME 数量,后面要作为 Uniswap 的初始流动性。而给 Pair 合约 mint 的数量是 totalSupply - lpSupply
,是要给内盘阶段出售的内盘 MEME 提供 1: 1 的兑换流动性。
在 mint 相应的数量之后,调用 Uniswap 的方法创建交易对:
// AgentToken.sol
function _createPair() internal returns (address uniswapV2Pair_) {
// 创建(外盘 MEME,VIRTUAL)的 Uniswap pair
uniswapV2Pair_ = IUniswapV2Factory(_uniswapRouter.factory()).createPair(
address(this),
pairToken
);
_liquidityPools.add(uniswapV2Pair_);
emit LiquidityPoolCreated(uniswapV2Pair_);
return (uniswapV2Pair_);
}
调用了 Uniswap 的 addLiquidity
方法提供流动性:
// AgentToken.sol
// 授权外盘 Token 本身以及 VIRTUAL
_approve(address(this), address(_uniswapRouter), type(uint256).max);
// pairToken 即 VIRTUAL
IERC20(pairToken).approve(address(_uniswapRouter), type(uint256).max);
// Add the liquidity:
(uint256 amountA, uint256 amountB, uint256 lpTokens) = _uniswapRouter
.addLiquidity(
address(this),
pairToken,
balanceOf(address(this)),
IERC20(pairToken).balanceOf(address(this)),
0,
0,
address(this),
block.timestamp
);
emit InitialLiquidityAdded(amountA, amountB, lpTokens);
Luna(mini proxy token)
https://basescan.org/address/0x55cD6469F597452B5A7536e2CD98fDE4c1247ee4
原始 AgentToken
https://basescan.org/address/0x082cb6e892dd0699b5f0d22f7d2e638bbada5d94#code
Bonding
https://basescan.org/address/0xF66DeA7b3e897cD44A5a231c61B6B4423d613259
Virtuals Protocol 是 Base 链上一个类似于 Pump.fun 的发币平台。这篇文章主要来讲讲 Virtuals 的合约代码,学习他们的整个发币流程和细节。
与 Pump 类似,Virtuals 上发币也分两个阶段,分别是内盘和外盘。内盘是指在合约内的 Bonding Curve 曲线上进行交易的阶段。外盘是指在 DEX,例如 Uniswap 上交易的阶段。当内盘销售结束时(一般是达到某市值,或者是预设数量的 Token 已经售完),合约将自动在 DEX 上创建交易对,此时要交易该 Token 便只能在 DEX 中进行。
Virtuals 中发行的币在内盘阶段只能通过 VITRUAL Token 购买。在 DEX 阶段,一般也是的 VIRTUAL-TOKEN 的交易对。这段时间 Virtuals 比较火,因此 VITRUAL 需求量大,涨势很好。
内盘部分主要涉及下面这些合约:
看过 Uniswap 代码的朋友应该对这一套架构比较熟悉,Virtuals 在内盘代码的架构上借鉴了 Uniswap 的逻辑。其中一个区别是,Virtuals 的内盘和外盘发行的是两个 Token。这里的 FERC20 是内盘阶段交易的 MEME Token,仅在内盘阶段使用,当进入到外盘后,会变成另一个 Token。用户可以以 1: 1 的比例将内盘的 MEME 兑换成外盘的 MEME。
用户操作接口在 Bonding
合约中,主要有下面三个方法:
先来看 launch
方法。
// Bonding.sol
function launch(
string memory _name,
string memory _ticker,
uint8[] memory cores,
string memory desc,
string memory img,
string[4] memory urls,
uint256 purchaseAmount
) public nonReentrant returns (address, address, uint) {
传入一些基本的 Token 信息。
// Bonding.sol
// fee = 100 VIRTUAL
require(
purchaseAmount > fee,
"Purchase amount must be greater than fee"
);
// VIRTUAL
address assetToken = router.assetToken();
require(
IERC20(assetToken).balanceOf(msg.sender) >= purchaseAmount,
"Insufficient amount"
);
uint256 initialPurchase = (purchaseAmount - fee);
IERC20(assetToken).safeTransferFrom(msg.sender, _feeTo, fee);
IERC20(assetToken).safeTransferFrom(
msg.sender,
address(this),
initialPurchase
);
发币是要收费的,之前是 10 VIRTUAL,最近改成了 100 VIRTUAL。传入的 purchaseAmount
中减去 100,剩余的数量就是发币者自己购买的初始数量。
// Bonding.sol
FERC20 token = new FERC20(string.concat("fun ", _name), _ticker, initialSupply, maxTx);
uint256 supply = token.totalSupply();
initialSupply
是由管理员设置的,目前是 10 亿,也就是说目前所有通过 Virtuals 发行的 Token,总供应量都是 10 亿。此时发行的 MEME 的 owner 是当前 Bonding
合约。
这里的 maxTx
是一个限制内盘 MEME 每笔转账数量的变量,目前没有限制,可以先不管。
// Bonding.sol
address _pair = factory.createPair(address(token), assetToken);
通过 factory
合约创建一个 Pair,也就是前面说的 FPair
。
// FFactory.sol
function _createPair(
address tokenA,
address tokenB
) internal returns (address) {
require(tokenA != address(0), "Zero addresses are not allowed.");
require(tokenB != address(0), "Zero addresses are not allowed.");
require(router != address(0), "No router");
FPair pair_ = new FPair(router, tokenA, tokenB);
_pair[tokenA][tokenB] = address(pair_);
_pair[tokenB][tokenA] = address(pair_);
pairs.push(address(pair_));
uint n = pairs.length;
emit PairCreated(tokenA, tokenB, address(pair_), n);
return address(pair_);
}
类似于 Uniswap 的写法,记录 Token 对应的 Pair。
FPair
的构造方法:
// FPair.sol
constructor(address router_, address token0, address token1) {
require(router_ != address(0), "Zero addresses are not allowed.");
require(token0 != address(0), "Zero addresses are not allowed.");
require(token1 != address(0), "Zero addresses are not allowed.");
router = router_;
tokenA = token0;
tokenB = token1;
}
注意 tokenA 总是新创建的 MEME,tokenB 总是 VIRTUAL。
再回到 Bonding
合约。
// Bonding.sol
// 给 router 合约授权,后面 addInitialLiquidity 需要转入
bool approved = _approval(address(router), address(token), supply);
require(approved);
// k = 3_000_000_000_000 * 10000 / 5000 = 6_000_000_000_000
uint256 k = ((K * 10000) / assetRate);
// 6000000000000000000000 = 6000 ether
uint256 liquidity = (((k * 10000 ether) / supply) * 1 ether) / 10000;
router.addInitialLiquidity(address(token), supply, liquidity);
这里先计算出来一个 liquidity
变量,实际值是 6000 ether。然后我们再来看 addInitialLiquidity
的代码:
// FRouter.sol
function addInitialLiquidity(
address token_,
uint256 amountToken_,
uint256 amountAsset_
) public onlyRole(EXECUTOR_ROLE) returns (uint256, uint256) {
require(token_ != address(0), "Zero addresses are not allowed.");
address pairAddress = factory.getPair(token_, assetToken);
IFPair pair = IFPair(pairAddress);
IERC20 token = IERC20(token_);
// 将初始的 1b MEME 转移到 pair 合约
token.safeTransferFrom(msg.sender, pairAddress, amountToken_);
// 这里的初始数量就是 1b,6000,单位都是 ether
pair.mint(amountToken_, amountAsset_);
return (amountToken_, amountAsset_);
}
方法参数分别是:
再来看 Pair 合约的 mint
方法:
// FPair.sol
function mint(
uint256 reserve0,
uint256 reserve1
) public onlyRouter returns (bool) {
require(_pool.lastUpdated == 0, "Already minted");
// 这里的初始数量就是 1b,6000,单位都是 ether
// 那么 k 就是 6000b ether
_pool = Pool({
reserve0: reserve0,
reserve1: reserve1,
k: reserve0 * reserve1,
lastUpdated: block.timestamp
});
emit Mint(reserve0, reserve1);
return true;
}
这里的计算就是 Bonding Curve 的核心。Virtuals 采用的是曲线模型是
X * Y = K
其中 K 就是上面代码中的 reserve0 * reserve1
,即 6000B ether。
也就是说内盘阶段的买卖都是在 X * Y = K
这条曲线上进行的,这和 Uniswap 是一致的。
注意这里的 mint
其实就是第一次生成 pair 的一个操作,同时初始化 reserve0 和 reserve1,k 的数值,并不是实际意义上的 mint token。
这时我们再回到 Bonding
的这行代码:
// Bonding.sol
router.addInitialLiquidity(address(token), supply, liquidity);
其实就是提供了公式中 X
和 Y
的初始值。
接着来看 launch
方法。
// Bonding.sol
Data memory _data = Data({
token: address(token),
name: string.concat("fun ", _name),
_name: _name,
ticker: _ticker,
supply: supply,
price: supply / liquidity,
marketCap: liquidity,
liquidity: liquidity * 2,
volume: 0,
volume24H: 0,
prevPrice: supply / liquidity,
lastUpdated: block.timestamp
});
Token memory tmpToken = Token({
creator: msg.sender,
token: address(token),
agentToken: address(0),
pair: _pair,
data: _data,
description: desc,
cores: cores,
image: img,
twitter: urls[0],
telegram: urls[1],
youtube: urls[2],
website: urls[3],
trading: true, // Can only be traded once creator made initial purchase
tradingOnUniswap: false
});
tokenInfo[address(token)] = tmpToken;
tokenInfos.push(address(token));
bool exists = _checkIfProfileExists(msg.sender);
// 记录用户创建的 MEME
if (exists) {
Profile storage _profile = profile[msg.sender];
_profile.tokens.push(address(token));
} else {
bool created = _createUserProfile(msg.sender);
if (created) {
Profile storage _profile = profile[msg.sender];
_profile.tokens.push(address(token));
}
}
uint n = tokenInfos.length;
emit Launched(address(token), _pair, n);
这部分主要是记录 MEME 和用户的相关信息。
// Bonding.sol
// Make initial purchase
IERC20(assetToken).forceApprove(address(router), initialPurchase);
router.buy(initialPurchase, address(token), address(this));
token.transfer(msg.sender, token.balanceOf(address(this)));
return (address(token), _pair, n);
最后这部分,实现了部署者购买初始数量的功能。注意这里的数量是已经减去部署费用之后的 VIRTUAL 数量。
这里的 router.buy()
方法最后会将购买到的 MEME 全部转给该合约,因此最后需要再将其转给 msg.sender
,即部署者。
router 合约的内容我们后面再看,先把 Bonding
合约的其它部分看完。
来看看 buy
方法的函数签名:
// Bonding.sol
function buy(
uint256 amountIn,
address tokenAddress
)
参数分别是要购买的 MEME 和数量,也就是说在内盘阶段,所有 MEME 的购买都是通过该方法进行。
// Bonding.sol
require(tokenInfo[tokenAddress].trading, "Token not trading");
// 获取根据(VIRTUAL,MEME)创建的 pair 地址
address pairAddress = factory.getPair(
tokenAddress,
router.assetToken()
);
IFPair pair = IFPair(pairAddress);
// A 是 MEME,B 是 VIRTUAL
(uint256 reserveA, uint256 reserveB) = pair.getReserves();
(uint256 amount1In, uint256 amount0Out) = router.buy(
amountIn,
tokenAddress,
msg.sender
);
这里校验的 trading
,在内盘阶段为 true,外盘阶段为 false,也就是说这里的 buy
是只能在内盘阶段被调用。
reserveA
和 reserveB
分别是 MEME 和 VIRTUAL 的数据,类似于 Uniswap 中的 reserve。
buy
方法的两个返回值分别是实际花费的 VIRTUAL 数量和购买获得的 MEME 数量。返回值的 amount1In
与参数的 amountIn
的区别是前者扣除了手续费。
接着是更新 MEME 的相关信息,这里省略:
// Bonding.sol
tokenInfo[tokenAddress] = ...;
// Bonding.sol
// gradThreshold = 0.125 b
if (newReserveA <= gradThreshold && tokenInfo[tokenAddress].trading) {
_openTradingOnUniswap(tokenAddress);
}
最后这里,当 Pair 中 Token 的数据小于一个阈值,也就是 Pair 中的 Token 的余额已经所剩不多时,在 DEX(Uniswap)中开一个新的交易对,即开外盘。
sell
方法与 buy
方法大同小异,只是换了交易方向,大家自己看看理解就好。
整体逻辑比较简单,核心主要在于这部分:
// Bonding.sol
// graduate 的作用是将 VIRTUAL 转移到本合约
router.graduate(tokenAddress);
// 授权 VIRTUAL 到 agent factory 合约
IERC20(router.assetToken()).forceApprove(agentFactory, assetBalance);
uint256 id = IAgentFactoryV3(agentFactory).initFromBondingCurve(
string.concat(_token.data._name, " by Virtuals"),
_token.data.ticker,
_token.cores,
// 线上数据
// 0xa7647ac9429fdce477ebd9a95510385b756c757c26149e740abbab0ad1be2f16
_deployParams.tbaSalt,
// 0x55266d75d1a14e4572138116af39863ed6596e7f
_deployParams.tbaImplementation,
// 259200 = 3 days
_deployParams.daoVotingPeriod,
// 0
_deployParams.daoThreshold,
assetBalance
);
address agentToken = IAgentFactoryV3(agentFactory)
.executeBondingCurveApplication(
id,
// 每个 MEME 都一样,1B
// 1_000_000_000
_token.data.supply / (10 ** token_.decimals()),
// 不同的 MEME 不一样
tokenBalance / (10 ** token_.decimals()),
pairAddress
);
_token.agentToken = agentToken;
router.approval(
pairAddress,
agentToken,
address(this),
IERC20(agentToken).balanceOf(pairAddress)
);
token_.burnFrom(pairAddress, tokenBalance);
这里的主要难点在于调用了 agentFactory
的两个方法:
initFromBondingCurve
可以简单理解为生成了一个流程对象,并返回该对象的 id。其将内盘 Pair 中的所有 VIRTUAL 余额全部转入了 agentFactory
合约中
executeBondingCurveApplication
中包含了创建新的外盘 MEME,添加流动性等逻辑。并给 Pair 合约 mint 了一些数量的外盘 MEME。
router 合约有这几个主要方法:
我们先来看 getAmountsOut
:
// FRouter.sol
function getAmountsOut(
address token,
address assetToken_,
uint256 amountIn
) public view returns (uint256 _amountOut) {
require(token != address(0), "Zero addresses are not allowed.");
address pairAddress = factory.getPair(token, assetToken);
IFPair pair = IFPair(pairAddress);
(uint256 reserveA, uint256 reserveB) = pair.getReserves();
uint256 k = pair.kLast();
uint256 amountOut;
if (assetToken_ == assetToken) {
uint256 newReserveB = reserveB + amountIn;
uint256 newReserveA = k / newReserveB;
amountOut = reserveA - newReserveA;
} else {
uint256 newReserveA = reserveA + amountIn;
uint256 newReserveB = k / newReserveA;
amountOut = reserveB - newReserveB;
}
return amountOut;
}
该方法的功能是计算买卖某 Token 时,能够获得多少数量。我们前面说过, reserveA
和 reserveB
分别是 MEME 和 VIRTUAL 的数据。那么当 assetToken_
传 VIRTUAL 的时候,代表买入(因为这里的逻辑是 reserveB 增多)。传其它地址的时候,代表卖出。
这里使用 X * Y = K
的公式,通过从 pair 中获得的 reserve 数据,来计算最终所能得到的数量。
buy
的代码如下:
// FRouter.sol
function buy(
uint256 amountIn,
address tokenAddress,
address to
) public onlyRole(EXECUTOR_ROLE) nonReentrant returns (uint256, uint256) {
require(tokenAddress != address(0), "Zero addresses are not allowed.");
require(to != address(0), "Zero addresses are not allowed.");
require(amountIn > 0, "amountIn must be greater than 0");
address pair = factory.getPair(tokenAddress, assetToken);
// 目前线上是 1
uint fee = factory.buyTax();
// 也就是 1%
uint256 txFee = (fee * amountIn) / 100;
address feeTo = factory.taxVault();
uint256 amount = amountIn - txFee;
IERC20(assetToken).safeTransferFrom(to, pair, amount);
IERC20(assetToken).safeTransferFrom(to, feeTo, txFee);
uint256 amountOut = getAmountsOut(tokenAddress, assetToken, amount);
IFPair(pair).transferTo(to, amountOut);
IFPair(pair).swap(0, amountOut, amount, 0);
return (amount, amountOut);
}
购买的时候需要付 1% 的手续费。这里要注意的是,X * Y = K
公式中的 reserveA
和 reserveB
是存储在 Pair 合约中的,但实际变化数量的计算是在 router 合约的,也就是说 Pair 合约中只存储 reserve 的值,但是并没有计算过程。当变化的数量在 router 中通过 getAmountsOut
方法计算好之后被传入到 Pair 中通过 swap
方法进行增减。
与 buy
相似的逻辑。
// FRouter.sol
function addInitialLiquidity(
address token_,
uint256 amountToken_,
uint256 amountAsset_
) public onlyRole(EXECUTOR_ROLE) returns (uint256, uint256) {
require(token_ != address(0), "Zero addresses are not allowed.");
address pairAddress = factory.getPair(token_, assetToken);
IFPair pair = IFPair(pairAddress);
IERC20 token = IERC20(token_);
// 将初始的 1B 新 MEME 转移到 pair 合约
token.safeTransferFrom(msg.sender, pairAddress, amountToken_);
// 这里的初始数量就是 1b,6000,单位都是 ether
pair.mint(amountToken_, amountAsset_);
return (amountToken_, amountAsset_);
}
注意这里的添加流动性并不是在 Uniswap 中添加,而是添加到内盘中的虚拟流动性,也就是说只是为了提供了 XYK
公式的初始数量。十亿的内盘 MEME 转入 Pair 合约,但是 VIRTUAL 并没有转入,只是提供了 6000 这样一个数量而已。
mint
方法我们前面也已经看过,主要是记录 Bonding Curve
公式的初始数量 X
, Y
, K
。
前面已经介绍过,Pair 中主要是为了存储 Bonding Curve
公式值。
已经介绍过
// FPair.sol
// 1,4 为 0 -> buy
// 2,3 为 0 -> sell
function swap(
uint256 amount0In,
uint256 amount0Out,
uint256 amount1In,
uint256 amount1Out
) public onlyRouter returns (bool) {
// 这里的 in out,都是 router 计算好了,传进来直接更新
// 而不是在这里通过公式计算的
uint256 _reserve0 = (_pool.reserve0 + amount0In) - amount0Out;
uint256 _reserve1 = (_pool.reserve1 + amount1In) - amount1Out;
_pool = Pool({
reserve0: _reserve0,
reserve1: _reserve1,
k: _pool.k,
lastUpdated: block.timestamp
});
emit Swap(amount0In, amount0Out, amount1In, amount1Out);
return true;
}
我们在前面的 router 合约中看到对于 swap
方法的调用是:
也就是说 swap
的四个参数,要么 1,4 同时为空,要么 2,3 同时为空。并且这里的参数都是在 router 中已经计算好的,直接 set 即可。
factory 合约主要包含的就是一个 createPair
方法,我们之前已经介绍过。
FERC20 是内盘阶段使用的 Token,是一个简单的 ERC20,唯一多出一个功能就是可以限制用户每笔交易可以转移的数量,也就是前面的 maxTx
变量的功能。不过目前没有限制,可以不管。
外盘部分的合约比较多,我们主要研究下面这些涉及发币逻辑的合约:
前面在 Bonding
合约的 _openTradingOnUniswap
方法中,调用到了 agentFactory
合约的 initFromBondingCurve
和 executeBondingCurveApplication
方法,我们就先从这两个方法入手来看。
// AgentFactoryV3.sol
function initFromBondingCurve(
string memory name,
string memory symbol,
uint8[] memory cores,
bytes32 tbaSalt,
address tbaImplementation,
uint32 daoVotingPeriod,
uint256 daoThreshold,
uint256 applicationThreshold_
) public whenNotPaused onlyRole(BONDING_ROLE) returns (uint256) {
address sender = _msgSender();
require(
IERC20(assetToken).balanceOf(sender) >= applicationThreshold_,
"Insufficient asset token"
);
require(
IERC20(assetToken).allowance(sender, address(this)) >=
applicationThreshold_,
"Insufficient asset token allowance"
);
require(cores.length > 0, "Cores must be provided");
IERC20(assetToken).safeTransferFrom(
sender,
address(this),
applicationThreshold_
);
uint256 id = _nextId++;
uint256 proposalEndBlock = block.number; // No longer required in v2
Application memory application = Application(
name,
symbol,
"",
ApplicationStatus.Active,
applicationThreshold_,
sender,
cores,
proposalEndBlock,
0,
tbaSalt,
tbaImplementation,
daoVotingPeriod,
daoThreshold
);
_applications[id] = application;
emit NewApplication(id);
return id;
}
这里的一个主要逻辑就是将 assetToken,即 VIRTUAL 转入该合约,为后面提供 Uniswap 的流动性做准备。Application
可以理解为一个发币流程对象,每次发一个外盘的币,都会有一个 Application
生成。如果有兴趣的话可以看看这块,我们的主要目的还是来研究整个发币的逻辑。
// AgentFactoryV3.sol
function executeApplication(uint256 id, bool canStake) public noReentrant {
// This will bootstrap an Agent with following components:
// C1: Agent Token
// C2: LP Pool + Initial liquidity
// C3: Agent veToken
// C4: Agent DAO
// C5: Agent NFT
// C6: TBA
// C7: Stake liquidity token to get veToken
Application storage application = _applications[id];
require(
msg.sender == application.proposer ||
hasRole(WITHDRAW_ROLE, msg.sender),
"Not proposer"
);
_executeApplication(id, canStake, _tokenSupplyParams);
}
代码注释中已经简单介绍了该方法的功能:
接下来我们来看这几个步骤的内容。
// AgentFactoryV3.sol
address token = _createNewAgentToken(
application.name,
application.symbol,
tokenSupplyParams_
);
// AgentFactoryV3.sol
function _createNewAgentToken(
string memory name,
string memory symbol,
bytes memory tokenSupplyParams_
) internal returns (address instance) {
instance = Clones.clone(tokenImplementation);
IAgentToken(instance).initialize(
[_tokenAdmin, _uniswapRouter, assetToken],
abi.encode(name, symbol),
tokenSupplyParams_,
_tokenTaxParams
);
allTradingTokens.push(instance);
return instance;
}
首先创建一个 Token,这里使用了 EIP1167 的概念,可以使用更少的 Gas 来部署,不熟悉的话可以看我之前写过这篇介绍 EIP1167 的文章。
这里创建的 Token 就是外盘 MEME,也就是在 Uniswap 中交易的正式 Token。接着对其进行初始化,注意由于 EIP1167 的限制,初始化只能额外调用 initialize
进行,而不能使用构造方法进行。
初始化的逻辑是 mint 一定数量的 MEME,以及在 DEX 中创建交易对。具体的代码逻辑我们后面再看。
// AgentFactoryV3.sol
// 上一步创建的 Uniswap 交易对地址
address lp = IAgentToken(token).liquidityPools()[0];
IERC20(assetToken).safeTransfer(token, initialAmount);
IAgentToken(token).addInitialLiquidity(address(this));
将之前转入的 VIRTUAL 全部转入 MEME 合约中,最后在 MEME 合约中调用 Uniswap 的 addLiquidity
方法提供流动性。
// C3
// AgentFactoryV3.sol
address veToken = _createNewAgentVeToken(
string.concat("Staked ", application.name),
string.concat("s", application.symbol),
lp,
application.proposer,
canStake
);
// AgentFactoryV3.sol
function _createNewAgentVeToken(
string memory name,
string memory symbol,
address stakingAsset,
address founder,
bool canStake
) internal returns (address instance) {
instance = Clones.clone(veTokenImplementation);
IAgentVeToken(instance).initialize(
name,
symbol,
founder,
stakingAsset,
block.timestamp + maturityDuration,
address(nft),
canStake
);
allTokens.push(instance);
return instance;
}
这里与创建外盘 MEME 的逻辑相似,同样使用了 EIP1167 的概念。
// AgentFactoryV3.sol
string memory daoName = string.concat(application.name, " DAO");
address payable dao = payable(
_createNewDAO(
daoName,
IVotes(veToken),
application.daoVotingPeriod,
application.daoThreshold
)
);
// AgentFactoryV3.sol
function _createNewDAO(
string memory name,
IVotes token,
uint32 daoVotingPeriod,
uint256 daoThreshold
) internal returns (address instance) {
instance = Clones.clone(daoImplementation);
IAgentDAO(instance).initialize(
name,
token,
nft,
daoThreshold,
daoVotingPeriod
);
allDAOs.push(instance);
return instance;
}
这里是创建 Dao 合约,主要是用于治理和投票等,我们就不着重看了,感兴趣的朋友可以自行研究。
// AgentFactoryV3.sol
uint256 virtualId = IAgentNft(nft).nextVirtualId();
IAgentNft(nft).mint(
virtualId,
_vault,
application.tokenURI,
dao,
application.proposer,
application.cores,
lp,
token
);
application.virtualId = virtualId;
这里发行的 NFT 有点类似于 Uniswap V3 中提供流动性获取的 NFT。也就是说这个 NFT 记录了一些关于这个外盘 MEME 的相关信息。例如 Token 地址,LP 地址,ve Token 地址等。
// AgentFactoryV3.sol
// C6
uint256 chainId;
assembly {
chainId := chainid()
}
address tbaAddress = IERC6551Registry(tbaRegistry).createAccount(
application.tbaImplementation,
application.tbaSalt,
chainId,
nft,
virtualId
);
IAgentNft(nft).setTBA(virtualId, tbaAddress);
为上一步 mint 的 NFT 创建一个合约钱包,这里使用的是 EIP6551 的相关逻辑,可以看我之前写的这篇文章。
// AgentFactoryV3.sol
IERC20(lp).approve(veToken, type(uint256).max);
IAgentVeToken(veToken).stake(
IERC20(lp).balanceOf(address(this)),
application.proposer,
defaultDelegatee
);
将前面的初始流动性 LP 质押到 ve Token 合约中获取 veToken。
AgentToken 就是外盘 MEME,与内盘的 MEME 1: 1 兑换。当进入外盘阶段之后,只有外盘 MEME 可以进行买卖,内盘 MEME 只能兑换成外盘 MEME。
initialize 中主要来看这段代码:
// AgentToken.sol
// 内盘 pair 中的数量
uint256 lpSupply = supplyParams.lpSupply * (10 ** decimals());
// totalSupply - lpSupply
uint256 vaultSupply = supplyParams.vaultSupply * (10 ** decimals());
_mintBalances(lpSupply, vaultSupply);
// AgentToken.sol
function _mintBalances(uint256 lpMint_, uint256 vaultMint_) internal {
if (lpMint_ > 0) {
_mint(address(this), lpMint_);
}
if (vaultMint_ > 0) {
_mint(vault, vaultMint_);
}
}
根据函数传入的参数结构解析,lpSupply
是当前时刻内盘 Pair 中内盘 MEME 的数量,vaultSupply
是 totalSupply - lpSupply
,也就是在内盘阶段被买走的 MEME 数量。也就是说,lpSupply
就是仍在 Pair 中没有被买走的数量。
_mintBalances
方法是给相应的地址 mint 对应数量的外盘 MEME。给当前外盘 MEME 合约本身 mint 的数量是 lpSupply
。vault
表示内盘 Pair,给它 mint 数量为 totalSupply - lpSupply
的外盘 MEME。
这个数量是什么意思,我们来思考一下,在内盘阶段,最初始一共有十亿个内盘 MEME,即 totalSupply = 1B
,并且是全部被转给了 Pair 合约。那么在经过内盘的买卖之后,假设 Pair 合约中剩余有 lpSupply
个 Token,则意味着已经被用户买走的数量是 totalSupply - lpSupply
。
这里给当前合约 mint 的数量是 lpSupply
,实际上是合约目前拥有的 MEME 数量,后面要作为 Uniswap 的初始流动性。而给 Pair 合约 mint 的数量是 totalSupply - lpSupply
,是要给内盘阶段出售的内盘 MEME 提供 1: 1 的兑换流动性。
在 mint 相应的数量之后,调用 Uniswap 的方法创建交易对:
// AgentToken.sol
function _createPair() internal returns (address uniswapV2Pair_) {
// 创建(外盘 MEME,VIRTUAL)的 Uniswap pair
uniswapV2Pair_ = IUniswapV2Factory(_uniswapRouter.factory()).createPair(
address(this),
pairToken
);
_liquidityPools.add(uniswapV2Pair_);
emit LiquidityPoolCreated(uniswapV2Pair_);
return (uniswapV2Pair_);
}
调用了 Uniswap 的 addLiquidity
方法提供流动性:
// AgentToken.sol
// 授权外盘 Token 本身以及 VIRTUAL
_approve(address(this), address(_uniswapRouter), type(uint256).max);
// pairToken 即 VIRTUAL
IERC20(pairToken).approve(address(_uniswapRouter), type(uint256).max);
// Add the liquidity:
(uint256 amountA, uint256 amountB, uint256 lpTokens) = _uniswapRouter
.addLiquidity(
address(this),
pairToken,
balanceOf(address(this)),
IERC20(pairToken).balanceOf(address(this)),
0,
0,
address(this),
block.timestamp
);
emit InitialLiquidityAdded(amountA, amountB, lpTokens);
Luna(mini proxy token)
https://basescan.org/address/0x55cD6469F597452B5A7536e2CD98fDE4c1247ee4
原始 AgentToken
https://basescan.org/address/0x082cb6e892dd0699b5f0d22f7d2e638bbada5d94#code
Bonding
https://basescan.org/address/0xF66DeA7b3e897cD44A5a231c61B6B4423d613259
FFactory
https://basescan.org/address/0x158d7CcaA23DC3c8861c3323eD546E3d25e74309
FRouter
https://basescan.org/address/0x8292B43aB73EfAC11FAF357419C38ACF448202C5
VIRTUAL Token
https://basescan.org/address/0x0b3e328455c4059EEb9e3f84b5543F74E24e7E1b
AgentFactory
https://basescan.org/address/0x71B8EFC8BCaD65a5D9386D07f2Dff57ab4EAf533
launch 的交易
https://basescan.org/tx/0x0e1c3d6cc217e77843789a1e52e39743bc61339778d2749a23ec3cd98bb76412
内盘买满,进入 uni 的交易
https://basescan.org/tx/0x9c29666227548fcdffe4f4fbad9a9e9e682790b2c9d4f7cd14030b2625fadf35
到此,我们学习了 Virtuals 的发币逻辑。从内盘到外盘,整体逻辑比较简单,希望大家读完之后能对其合约部分有更加深入的了解。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!