Virtuals Protocol 合约代码简析

  • xyyme
  • 更新于 13小时前
  • 阅读 26

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 需求量大,涨势很好。

内盘

内盘部分主要涉及下面这些合约:

  • Bonding
  • FERC20
  • FFactory
  • FPair
  • FRouter

看过 Uniswap 代码的朋友应该对这一套架构比较熟悉,Virtuals 在内盘代码的架构上借鉴了 Uniswap 的逻辑。其中一个区别是,Virtuals 的内盘和外盘发行的是两个 Token。这里的 FERC20 是内盘阶段交易的 MEME Token,仅在内盘阶段使用,当进入到外盘后,会变成另一个 Token。用户可以以 1: 1 的比例将内盘的 MEME 兑换成外盘的 MEME。

Bonding

用户操作接口在 Bonding 合约中,主要有下面三个方法:

  • launch,发币
  • buy,买币
  • sell,卖币
  • unwrapToken,将内盘的 MEME 兑换成外盘的 MEME

launch

先来看 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_);
}

方法参数分别是:

  • amountToken_ = 1B ether
  • amountAsset_ = 6000 ether

再来看 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);

其实就是提供了公式中 XY 的初始值。

接着来看 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

来看看 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 是只能在内盘阶段被调用。

reserveAreserveB 分别是 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

sell 方法与 buy 方法大同小异,只是换了交易方向,大家自己看看理解就好。

_openTradingOnUniswap

整体逻辑比较简单,核心主要在于这部分:

// 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
  • executeBondingCurveApplication

initFromBondingCurve 可以简单理解为生成了一个流程对象,并返回该对象的 id。其将内盘 Pair 中的所有 VIRTUAL 余额全部转入了 agentFactory 合约中

executeBondingCurveApplication 中包含了创建新的外盘 MEME,添加流动性等逻辑。并给 Pair 合约 mint 了一些数量的外盘 MEME。

FRouter

router 合约有这几个主要方法:

  • buy
  • sell
  • addInitialLiquidity
  • getAmountsOut

getAmountsOut

我们先来看 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 时,能够获得多少数量。我们前面说过, reserveAreserveB 分别是 MEME 和 VIRTUAL 的数据。那么当 assetToken_ 传 VIRTUAL 的时候,代表买入(因为这里的逻辑是 reserveB 增多)。传其它地址的时候,代表卖出。

这里使用 X * Y = K 的公式,通过从 pair 中获得的 reserve 数据,来计算最终所能得到的数量。

buy

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 公式中的 reserveAreserveB 是存储在 Pair 合约中的,但实际变化数量的计算是在 router 合约的,也就是说 Pair 合约中只存储 reserve 的值,但是并没有计算过程。当变化的数量在 router 中通过 getAmountsOut 方法计算好之后被传入到 Pair 中通过 swap 方法进行增减。

sell

buy 相似的逻辑。

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_);
}

注意这里的添加流动性并不是在 Uniswap 中添加,而是添加到内盘中的虚拟流动性,也就是说只是为了提供了 XYK 公式的初始数量。十亿的内盘 MEME 转入 Pair 合约,但是 VIRTUAL 并没有转入,只是提供了 6000 这样一个数量而已。

mint 方法我们前面也已经看过,主要是记录 Bonding Curve 公式的初始数量 X, Y, K

FPair

前面已经介绍过,Pair 中主要是为了存储 Bonding Curve 公式值。

mint

已经介绍过

swap

// 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 方法的调用是:

  • buy -> pair.swap(0, amountOut, amount, 0);
  • sell -> pair.swap(amountIn, 0, 0, amountOut);

也就是说 swap 的四个参数,要么 1,4 同时为空,要么 2,3 同时为空。并且这里的参数都是在 router 中已经计算好的,直接 set 即可。

FFactory

factory 合约主要包含的就是一个 createPair 方法,我们之前已经介绍过。

FERC20

FERC20 是内盘阶段使用的 Token,是一个简单的 ERC20,唯一多出一个功能就是可以限制用户每笔交易可以转移的数量,也就是前面的 maxTx 变量的功能。不过目前没有限制,可以不管。

外盘

外盘部分的合约比较多,我们主要研究下面这些涉及发币逻辑的合约:

  • AgentFactoryV3
  • AgentToken

AgentFactoryV3

前面在 Bonding 合约的 _openTradingOnUniswap 方法中,调用到了 agentFactory 合约的 initFromBondingCurveexecuteBondingCurveApplication 方法,我们就先从这两个方法入手来看。

initFromBondingCurve

// 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 生成。如果有兴趣的话可以看看这块,我们的主要目的还是来研究整个发币的逻辑。

executeApplication

// 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);
}

代码注释中已经简单介绍了该方法的功能:

  • C1: 发行 Agent Token,即外盘 MEME
  • C2: 在 DEX 中提供初始流动性
  • C3: 创建 ve Token,即一种支持锁仓投票功能的 token,熟悉 Curve 的朋友应该比较了解
  • C4: 创建 Dao 合约,可以支持治理等功能
  • C5: 发行一个 NFT
  • C6: 为上一步发行的 NFT 绑定一个钱包
  • C7: 将初始流动性 LP stake 为 ve Token

接下来我们来看这几个步骤的内容。

C1
// 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 中创建交易对。具体的代码逻辑我们后面再看。

C2
// 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
// 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 的概念。

C4
// 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 合约,主要是用于治理和投票等,我们就不着重看了,感兴趣的朋友可以自行研究。

C5
// 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 地址等。

C6
// 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 的相关逻辑,可以看我之前写的这篇文章

C7
// 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

AgentToken 就是外盘 MEME,与内盘的 MEME 1: 1 兑换。当进入外盘阶段之后,只有外盘 MEME 可以进行买卖,内盘 MEME 只能兑换成外盘 MEME。

initialize

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 的数量,vaultSupplytotalSupply - lpSupply,也就是在内盘阶段被买走的 MEME 数量。也就是说,lpSupply 就是仍在 Pair 中没有被买走的数量。

_mintBalances 方法是给相应的地址 mint 对应数量的外盘 MEME。给当前外盘 MEME 合约本身 mint 的数量是 lpSupplyvault 表示内盘 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 的兑换流动性。

_createPair

在 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_);
}

addInitialLiquidity

调用了 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 需求量大,涨势很好。

内盘

内盘部分主要涉及下面这些合约:

  • Bonding
  • FERC20
  • FFactory
  • FPair
  • FRouter

看过 Uniswap 代码的朋友应该对这一套架构比较熟悉,Virtuals 在内盘代码的架构上借鉴了 Uniswap 的逻辑。其中一个区别是,Virtuals 的内盘和外盘发行的是两个 Token。这里的 FERC20 是内盘阶段交易的 MEME Token,仅在内盘阶段使用,当进入到外盘后,会变成另一个 Token。用户可以以 1: 1 的比例将内盘的 MEME 兑换成外盘的 MEME。

Bonding

用户操作接口在 Bonding 合约中,主要有下面三个方法:

  • launch,发币
  • buy,买币
  • sell,卖币
  • unwrapToken,将内盘的 MEME 兑换成外盘的 MEME

launch

先来看 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_);
}

方法参数分别是:

  • amountToken_ = 1B ether
  • amountAsset_ = 6000 ether

再来看 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);

其实就是提供了公式中 XY 的初始值。

接着来看 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

来看看 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 是只能在内盘阶段被调用。

reserveAreserveB 分别是 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

sell 方法与 buy 方法大同小异,只是换了交易方向,大家自己看看理解就好。

_openTradingOnUniswap

整体逻辑比较简单,核心主要在于这部分:

// 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
  • executeBondingCurveApplication

initFromBondingCurve 可以简单理解为生成了一个流程对象,并返回该对象的 id。其将内盘 Pair 中的所有 VIRTUAL 余额全部转入了 agentFactory 合约中

executeBondingCurveApplication 中包含了创建新的外盘 MEME,添加流动性等逻辑。并给 Pair 合约 mint 了一些数量的外盘 MEME。

FRouter

router 合约有这几个主要方法:

  • buy
  • sell
  • addInitialLiquidity
  • getAmountsOut

getAmountsOut

我们先来看 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 时,能够获得多少数量。我们前面说过, reserveAreserveB 分别是 MEME 和 VIRTUAL 的数据。那么当 assetToken_ 传 VIRTUAL 的时候,代表买入(因为这里的逻辑是 reserveB 增多)。传其它地址的时候,代表卖出。

这里使用 X * Y = K 的公式,通过从 pair 中获得的 reserve 数据,来计算最终所能得到的数量。

buy

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 公式中的 reserveAreserveB 是存储在 Pair 合约中的,但实际变化数量的计算是在 router 合约的,也就是说 Pair 合约中只存储 reserve 的值,但是并没有计算过程。当变化的数量在 router 中通过 getAmountsOut 方法计算好之后被传入到 Pair 中通过 swap 方法进行增减。

sell

buy 相似的逻辑。

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_);
}

注意这里的添加流动性并不是在 Uniswap 中添加,而是添加到内盘中的虚拟流动性,也就是说只是为了提供了 XYK 公式的初始数量。十亿的内盘 MEME 转入 Pair 合约,但是 VIRTUAL 并没有转入,只是提供了 6000 这样一个数量而已。

mint 方法我们前面也已经看过,主要是记录 Bonding Curve 公式的初始数量 X, Y, K

FPair

前面已经介绍过,Pair 中主要是为了存储 Bonding Curve 公式值。

mint

已经介绍过

swap

// 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 方法的调用是:

  • buy -> pair.swap(0, amountOut, amount, 0);
  • sell -> pair.swap(amountIn, 0, 0, amountOut);

也就是说 swap 的四个参数,要么 1,4 同时为空,要么 2,3 同时为空。并且这里的参数都是在 router 中已经计算好的,直接 set 即可。

FFactory

factory 合约主要包含的就是一个 createPair 方法,我们之前已经介绍过。

FERC20

FERC20 是内盘阶段使用的 Token,是一个简单的 ERC20,唯一多出一个功能就是可以限制用户每笔交易可以转移的数量,也就是前面的 maxTx 变量的功能。不过目前没有限制,可以不管。

外盘

外盘部分的合约比较多,我们主要研究下面这些涉及发币逻辑的合约:

  • AgentFactoryV3
  • AgentToken

AgentFactoryV3

前面在 Bonding 合约的 _openTradingOnUniswap 方法中,调用到了 agentFactory 合约的 initFromBondingCurveexecuteBondingCurveApplication 方法,我们就先从这两个方法入手来看。

initFromBondingCurve

// 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 生成。如果有兴趣的话可以看看这块,我们的主要目的还是来研究整个发币的逻辑。

executeApplication

// 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);
}

代码注释中已经简单介绍了该方法的功能:

  • C1: 发行 Agent Token,即外盘 MEME
  • C2: 在 DEX 中提供初始流动性
  • C3: 创建 ve Token,即一种支持锁仓投票功能的 token,熟悉 Curve 的朋友应该比较了解
  • C4: 创建 Dao 合约,可以支持治理等功能
  • C5: 发行一个 NFT
  • C6: 为上一步发行的 NFT 绑定一个钱包
  • C7: 将初始流动性 LP stake 为 ve Token

接下来我们来看这几个步骤的内容。

C1
// 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 中创建交易对。具体的代码逻辑我们后面再看。

C2
// 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
// 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 的概念。

C4
// 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 合约,主要是用于治理和投票等,我们就不着重看了,感兴趣的朋友可以自行研究。

C5
// 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 地址等。

C6
// 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 的相关逻辑,可以看我之前写的这篇文章

C7
// 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

AgentToken 就是外盘 MEME,与内盘的 MEME 1: 1 兑换。当进入外盘阶段之后,只有外盘 MEME 可以进行买卖,内盘 MEME 只能兑换成外盘 MEME。

initialize

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 的数量,vaultSupplytotalSupply - lpSupply,也就是在内盘阶段被买走的 MEME 数量。也就是说,lpSupply 就是仍在 Pair 中没有被买走的数量。

_mintBalances 方法是给相应的地址 mint 对应数量的外盘 MEME。给当前外盘 MEME 合约本身 mint 的数量是 lpSupplyvault 表示内盘 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 的兑换流动性。

_createPair

在 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_);
}

addInitialLiquidity

调用了 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 的发币逻辑。从内盘到外盘,整体逻辑比较简单,希望大家读完之后能对其合约部分有更加深入的了解。

原文: https://hackmd.io/@xyymeeth/rJ3zxJJEyx

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

0 条评论

请先 登录 后评论
xyyme
xyyme
Solidity 智能合约开发者 Telegram: https://t.me/wengood EVM 技术讨论小组: http://t.me/CoolSolidity