Pool Launch — 集成指南

Pool Launch 在单笔交易内把税币直接上线到 V2 DEX 池。没有联合曲线阶段:部署代币、注入并锁定池子、处理 dev 余量、可选的创建者首买,全部原子完成。

目录

一图看懂 Pool Launch

没有曲线:直接进入真实池子

联合曲线(现有)
已售出价格毕业DEX协议托管的曲线
  • 固定总量、固定曲线
  • 毕业 = 曲线被买满
Pool Launch(新)
创建者报价币 + 代币DEX pool第 1 个区块起LP 销毁 🔒注入无曲线 · 自定义总量 · 自定义初始资金
  • 创建者决定总量与初始资金
  • 毕业 = 流动性相对总量足够深

联合曲线发射固定了总量和曲线;代币要等曲线被买满才会上 DEX。Pool Launch 跳过曲线,由创建者决定:

  • 总供应量。 100 到 10¹⁵ 枚(18 位小数)之间任意取值 —— 不是协议固定的总量。
  • 多少代币注入池子(poolToken)以及配多少 quote(poolQuote)。两者共同决定开盘价 poolQuote / poolToken。

LP 在同一笔交易内销毁,池子无法被撤走。代币从第一个区块起就在 DEX 上交易。

毕业:发射时只检查一次

Pool Launch 代币从第一个区块起就在 DEX 上,没有迁移步骤。当前 V2 kind 的「毕业」指:发射时为池子注入了足够的 quote。

发射交易池子已注入立即可交易只检查一次池子价值≥ floor ?发射时是否🎓 已毕业FlapTokenGraduated🆕 新币无事件 · 永远留在这里没有联合曲线 → 无需迁移 · 之后价格上涨也不会让代币进入已毕业
  • 检查只在发射交易内执行一次,紧接在池子注入之后。
  • 达到门槛 → 发出 FlapTokenGraduated,终端可以把代币显示为已毕业。
  • 未达到 → 不发出事件。当前 kind 下,代币留在新币栏目,之后不会补发。

公式见 Pool Launch 中的毕业。

Pool Launch 本身看起来很简单:注入池子、销毁 LP,就结束了。真正让它不止于此的是新的 Pool Launch Vault。它为直接构建在发射之上的大量新应用打开了大门 —— 挖矿、空投,以及更复杂的机制。

既处理收入、也分发代币的 Vault

联合曲线 Vault
税收Vault只处理收入
Pool Launch Vault
税收预留代币Vault= 分发策略收入 + 代币分发

Pool Launch Vault 做两件事:

  • 收入。 和联合曲线 Vault 一样,接收代币交易税的一部分(VAULT fee slot)。
  • 预留代币。 还可以在发射交易内直接接收未注入池子的代币,并按写进合约的规则分发 —— 线性释放、空投、质押奖励、回购等。factory 会在 vault 创建之前校验整个发射,所以这些规则属于审计范围。

这样创建者可以围绕预留代币构建机制,而不必把它放在一个随时可以砸盘的钱包里。

示例 Vault:注入流动性,再逐步收回成本

这只是一种可能的 Vault,而非规范本身。以 BNB 为报价币的税币为例:创建者把 12.5 BNB 与 20% 的代币注入池子。Vault 把 BNB 税收的 20% 发给创建者,直到创建者收到初始资金的 1.5 倍(18.75 BNB),其余存入分红合约给持有者;达到上限后 100% 归持有者。另有 1% 代币在 30 天内线性释放给创建者。下文的 三个示例 Vault 都基于同样的设置。

创建者注入 12.5 BNBTOKEN / BNBPancakeSwap 池BNB 税收Vault分配收入创建者:20% 税收,最高初始资金 1.5 倍12.5 BNB上限 1.5× = 18.75 BNB持有者:BNB 分红现在 80% · 达到上限后 100%预留 1%30 天线性释放→ 创建者其余预留代币?开放问题
  • 没有 Vault 时: 创建者想收回 12.5 BNB,只能自己囤一部分代币再砸盘。价格崩溃,持有者亏损,创建者转去发下一个币。
  • 使用这个 Vault: 80% 的预留代币按固定规则存放在 Vault 中,创建者手里没有可砸的筹码(只有在 30 天内线性释放的 1%)。创建者从交易税中以 BNB 收回成本,最高为初始资金的 1.5 倍。交易越活跃,回本越快,所以创建者的利益在于让代币持续活跃,而不是砸死它。
  • 创建者有了新的收益方式: 砸盘不再是唯一的回报途径。Pool Launch 给了创建者第二份收入 —— 通过 Vault、按任何人都能查看的规则发放的交易税,所以让代币持续活跃是有回报的。

可以构建什么:三个示例 Vault

三个示例都沿用上面的设置:总量的 80% 进入 vault,1% 在 30 天内线性释放给创建者,quote 收入的 20% 给创建者直到初始资金的 1.5 倍,其余作为分红给持有者。它们的区别在于剩余 79% 的用法。各自的伪代码见 示例 Vault。

质押挖矿

79% 作为挖矿池,线性释放给质押者。质押者选择锁定期;锁得越久,权重倍数越高。

TOKEN / QUOTEV2 池 · 交易税80% 总量发射交易 → vaultVaultreceive()预留 80%= 1% + 79%20%开发者:直到初始资金 1.5 倍上限 1.5×80%分红合约deposit()开发者 1%:30 天线性释放挖矿池 79% · 线性释放30d ×190d ×1.5180d ×2质押越久 → 权重越高 → 分得越多的 79% 释放

随市值上涨释放预留代币

Vault 在递增的市值档位挂出固定价格卖单:20 万美元市值卖出总量的 1%,30 万美元再卖 1%,以此类推。只有市场涨到对应档位时才会释放代币。卖单收入作为分红给持有者,并逐步释放。

TOKEN / QUOTEV2 池 · 交易税79% 预留+1% 开发者释放Vault分账同示例 A固定价格卖单每档 1%卖单收入 →持有者,逐步释放1% @ 200K1% @ 300K1% @ 400K1% @ 500K…市值时间 →每个固定价格订单只有在市场涨到该价位时才会成交

Vault 作为众多单边池的 hook

Vault 作为 PancakeSwap Infinity 或 Uniswap v4 池的 hook。它用预留代币对多种 quote 开设单边池,价格由 hook 内的自定义曲线决定。开池不需要买入任何 quote,因此代币可以与任意多个 quote 配对。

TOKEN / QUOTEV2 池 · 交易税79% 预留无需任何 quoteVault = Hook自定义曲线beforeSwap()分账同示例 ATOKEN / BNBTOKEN / USDTTOKEN / USD1TOKEN / ETHTOKEN / CAKE买家可用任意 quotePCS Infinity / Uni v4 池:单边、由 hook 定价、quote 数量不受限

为什么需要代币驿站

预留在自定义 Vault 中
前排持有者%
0x9f3a…c2e178%
0x51b0…77aa4%
0xa3c9…0d143%
0x0e72…b9f02%
未知筹码分配合约,需自行判断风险
预留在代币驿站中
前排持有者%
🔖 代币驿站 · 预留78%
0x51b0…77aa4%
0xa3c9…0d143%
0x0e72…b9f02%
✓ 已知合约,按 Vault 规则预留

任何预留代币的机制在链上都表现为一个巨大的持有者。交易者看到「78% 在一个地址里」,就会以为要跑路。代币驿站(Token Dock)解决这个问题:各个 Vault 把预留代币托管到同一个公开、可识别的合约里,终端可以把它标记为「预留」,就像锁仓合约让被锁的 LP 一眼可辨一样。

低风险 Vault已白名单 (vault, token)未验证 Vault未加入白名单已验证代币驿站🔖 被终端标记存入:仅白名单开放代币驿站无标记存入:任何人升为低风险 → 自动迁移提取永不受限 · 所有终端只需标记一个地址

所有 Vault 都通过 TokenDockRouter 存入。尚未验证的 Vault 使用开放驿站;Flap 认证其为低风险后,Portal 把 (vault, token) 加入白名单,余额会自动迁移到已验证驿站。提款永远不受限制。接口见 余量去向。

本指南面向谁

  • Terminal、bot 与钱包 —— 发射代币、预览结果、索引发射事件、读取状态。
  • Vault 开发者 —— 通过 VAULT fee slot 把自定义 vault 接入发射。

已部署地址(BNB Chain 测试网)

合约地址
Portal(代理)0x000042108465206185A077Edc3F3fd2b49060000
FlapDirectTaxToken 实现0x7777c57ff7dc441c1715b2e36fa581c48B782222
TaxTokenHelper0xd64441e5fcd02d342b8cf6eba10ef6e40d0da90f
AllocationStrategy0x000051353ef814E4779B692cb2Bb3A6Cb3710000
TokenDockRouter0x000048E888aBf09f690a952E1b386b2D031a0000
OpenTokenDock0x0000E34C74b9eC7b52F35528249Aa9C4AdE00000
VerifiedTokenDock0x00004B06D90Df7a39491264744BF187D90Df0000

已部署地址(BNB Chain 主网)

合约地址
Portal(代理)0x000042108465206185A077Edc3F3fd2b49060000
FlapDirectTaxToken 实现0x7777c57ff7dc441c1715b2e36fa581c48B782222
TaxTokenHelper0x53841c73217735F37BC1775538b03b23feFD8346
AllocationStrategy0x000051353ef814E4779B692cb2Bb3A6Cb3710000
TokenDockRouter0x000048E888aBf09f690a952E1b386b2D031a0000
OpenTokenDock0x0000E34C74b9eC7b52F35528249Aa9C4AdE00000
VerifiedTokenDock0x00004B06D90Df7a39491264744BF187D90Df0000

Pool Launch 做了什么

调用 launchTaxTokenV2 会在同一笔交易内完成整个发射:

  1. 创建税币、其 dividend tracker 与 tax processor。若发射带有 VAULT fee slot,配置的 vault factory 会在这一步校验发射并创建该代币的 vault。
  2. 注入 V2 池:poolToken + poolQuote,LP 销毁到 dead 地址。
  3. 标记代币 status = DEX,并记录 dev 预留余量。
  4. 若 distribution.amount > 0,分发余量。
  5. 若 creatorBuyQuote > 0,执行创建者首买。

交易完成后代币立即可交易。

Pool Launch 中的毕业

当前 DIRECT_V2_POOL_TAX kind 的毕业是发射时的一次性检查:代币已经在 DEX 上,没有迁移步骤。池子 quote 边的 USD 价值达到本次进池比例对应的门槛,就算毕业。

quoteUsd       = poolQuote × quotePriceUsd
floorUsd       = 1500 × multiplierBps / 10000 × totalSupply / poolToken
createFloorUsd = 750  × multiplierBps / 10000 × totalSupply / poolToken
已毕业          ⇔ quoteUsd ≥ floorUsd
允许创建        ⇔ quoteUsd ≥ createFloorUsd

毕业门槛以 100% 进池时的 $1,500 为基准,按 1 / 进池比例 放大:1× 时,50% → $3,000,20% → $7,500。multiplierBps 默认为 10000(1×),除非 vault factory 调低;同一个倍数也会调低创建门槛(100% 进池时基准 $750):50% → $1,500,20% → $3,750,0.5× 时再减半。低于创建门槛会 revert LaunchLiquidityTooLow;通过创建门槛不代表已经毕业。

达标时,FlapTokenGraduated 在发射交易内发出。当前 kind 不会重复检查;发射时没有事件就意味着不会毕业。发射前用 graduationFloorV2() 同时预览两个门槛 —— 见 发射前预览。旧的 graduationFloor() 仍保留给只关心毕业门槛的调用方。

毕业规则取决于 kind,详见 需要索引的事件。

面向 Terminal 与 Bot

入口

/// @notice 一笔交易完成税币发射:创建代币、注入并锁定 V2 池子、分发 dev 余量,
///         并执行可选的创建者首买。
/// @dev    原生 quote:`msg.value` 必须等于 `p.poolQuote + p.creatorBuyQuote`。
///         ERC-20 quote:`msg.value` 必须为 0;先给 Portal approve `p.poolQuote + p.creatorBuyQuote`。
/// @param p            创建参数。`poolQuote` / `poolToken` / `creatorBuyQuote` 只从 `p` 读取,
///                     调用者无需重复传入,实际注入的池子也一定与创建代币时提交的数值一致。
/// @param distribution dev 预留余量的交接。`distribution.amount > 0` 时,池子注入后立即把这部分
///                     发给解析出的 strategy。发射之后**没有**单独的分发入口:发射要么把全部供应
///                     注入池子(`poolToken == totalSupply`),要么在这里交出余量。余量策略对**每次**
///                     发射都会执行 —— `pendingRemainder` 超过 1 token 会 revert `ExcessiveRemainder`;
///                     不超过 1 token 的残余作为 dust 销毁到 DEAD。
/// @param creatorBuy   可选。`p.creatorBuyQuote > 0` 时,在池子注入、余量分发**之后**执行创建者首买;
///                     `minOutputAmount` / `permitData` 是这次 swap 自己的滑点下限与 permit。
///                     `p.creatorBuyQuote == 0` 时忽略。
/// @return token 新部署的税币。
/// @return pool  已注入并永久锁定的 V2 池子。
function launchTaxTokenV2(
    CreateTaxTokenParams calldata p,
    Distribution calldata distribution,
    CreatorBuyParams calldata creatorBuy
) external payable returns (address token, address pool);
  • 原生 quote(quoteToken == address(0)):msg.value = poolQuote + creatorBuyQuote。
  • ERC-20 quote:msg.value = 0;先给 Portal approve poolQuote + creatorBuyQuote。
  • 该 (DIRECT_V2_POOL_TAX, quoteToken) 组合必须已被协议启用;否则 revert LaunchQuotePairNotEnabled。

发射参数

CreateTaxTokenParams

/// @notice Pool Launch 的参数(即 `launchTaxTokenV2` 的 `p`)。
/// @dev `poolQuote` / `poolToken` 在创建代币时提交,注入池子时再次校验。创建阶段不拉取任何资金。
struct CreateTaxTokenParams {
    string name;
    string symbol;
    string meta;                // 代币元数据(IPFS CID)
    uint256 totalSupply;        // 由创建者自定义,范围 [100 ether, 1e15 ether],18 位小数
    bytes32 salt;               // CREATE2 salt;代币地址必须以靓号后缀结尾
    address quoteToken;         // address(0) = 原生币;池子身份在 initialize() 时写入代币
    uint8 dexId;                // DEX 提供方
    uint256 poolQuote;          // 将注入池子的 quote(> 0)
    uint256 poolToken;          // 将注入池子的代币(> 0 且 <= totalSupply)
    uint16 buyTaxRate;          // bps
    uint16 sellTaxRate;         // bps
    uint32 taxDuration;         // 秒;按协议全局上下限校验
    uint32 antiFarmerDuration;  // 秒
    FeeConfig[] feeConfigs;     // 激活槽的 bps 之和必须恰好 10000
    // ---- 本版本不支持:保留在 ABI 中,但需传 0 / 0 / [] ----
    uint8 antiSniperMode;       // 必须为 0
    uint32 exemptWindow;        // 必须为 0
    address[] exemptAddrs;      // 必须为空
    // ---- 可选的创建者首买 ----
    // > 0 时让 launchTaxTokenV2 用这么多 quote 执行创建者首买。
    uint256 creatorBuyQuote;
    // ---- 本版本不支持:保留在 ABI 中,但传 0 / 0x ----
    bytes32 extensionID;
    bytes extensionData;
    // ---- 可选佣金 ----
    // address(0) = 关闭。设置后,佣金 bps 由本次发射的有效税率(max(buyTaxRate, sellTaxRate))推导,
    // 写入代币的 tax processor;分发时从协议费份额中支付给该地址。
    address commissionReceiver;
}
字段类型说明
name、symbol、metastring代币标识。meta 是 IPFS CID。
totalSupplyuint256由创建者自定义 —— [100 ether, 1e15 ether] 内任意值(100 到 10¹⁵ 枚,18 位小数)。
saltbytes32CREATE2 salt。生成的地址必须以税币靓号后缀(0x7777)结尾。
quoteTokenaddressaddress(0) = 原生币。
dexIduint8目标 DEX。所有支持链上都是 0。
poolQuoteuint256注入池中的 quote。在 create 时提交,注池时再校验。
poolTokenuint256注入池中的代币。必须 > 0 且 <= totalSupply。
buyTaxRate、sellTaxRateuint16bps。最大 1000(10%)。
taxDurationuint32秒。
antiFarmerDurationuint32秒。
feeConfigsFeeConfig[]最多 4 项。激活槽 bps 之和必须恰好 10000。见 Fee slots。
antiSniperMode、exemptWindow、exemptAddrsuint8、uint32、address[]本版本不支持。 传 0、0、[]。
creatorBuyQuoteuint256创建者首买金额。0 = 不首买。
extensionID、extensionDatabytes32、bytes本版本不支持。 传 bytes32(0) / 0x。
commissionReceiveraddress可选佣金接收地址。address(0) = 关闭。

创建者首买前的开盘价为:

P0 = poolQuote / poolToken

发射必须把全部供应注入池子(poolToken == totalSupply),或通过 distribution 处理余量。

Salt 锁定(防抢跑)

代币地址是 CREATE2(tokenImplDirectTaxed, salt, Portal)。知道 salt 的人都能提前算出地址并抢先发射,所以创建者可以在发射前用原生币支付 SALT_LOCK_FEE() 预留 salt。锁定后,只有 locker 能用该 salt 发射代币。

function lockSalt(bytes32 salt, uint8 kind) external payable;
  • kind = DIRECT_V2_POOL_TAX(0)。
  • msg.value 必须精确等于 SALT_LOCK_FEE();费用转给 fee receiver,不退还。
  • 预测地址必须满足靓号后缀(0x7777);否则 VanityAddressRequirementNotMet。
  • 一个 salt 只能锁定一次;重复锁定会 revert。已经部署了代币的 salt 不能锁定。
  • 锁定没有过期时间;第一次成功发射后会被消耗,entry 保留在链上作为审计记录。
  • 未锁定的 salt 仍然任何人都能发射 —— 锁定是可选操作。
const fee = await portal.SALT_LOCK_FEE();
await portal.lockSalt(salt, 0, { value: fee }); // 0 = DIRECT_V2_POOL_TAX

// 之后由 locker 用同一个 salt 发射
await portal.launchTaxTokenV2({ ...p, salt }, distribution, creatorBuy);

查询锁定状态:

const lock = await portal.getSaltLock(salt);
// lock.locker / lock.launchKind / lock.isUsed

事件:

event FlapSaltLocked(address locker, bytes32 salt, address tokenAddress, uint8 launchKind, uint256 ts);
event FlapSaltUsed(address locker, bytes32 salt, address tokenAddress, uint8 launchKind, uint256 ts);

FlapSaltLocked 在预留时发出;FlapSaltUsed 在被发射消耗时发出。

错误:FeatureDisabled(未开启 salt locking —— fee 为 0)、SaltLockFeeMismatch(expected, provided)、SaltAlreadyLockedByAnotherUser(salt, locker)、SaltAlreadyLockedBySelf(salt)、SaltLockTokenAlreadyDeployed(salt, token)、UnsupportedLaunchKind(kind)、VanityAddressRequirementNotMet(token)。发射时:FlapSaltLockedByAnotherUser(salt, locker)、SaltLockKindMismatch(salt, locked, provided)。

Fee slots

/// @dev `FeeConfigDividend.dividendToken` 哨兵值:分红发放本次发射的代币本身。
address constant MAGIC_DIVIDEND_SELF = 0xfEEDFEEDfeEDFEedFEEdFEEDFeEdfEEdFeEdFEEd;

/// @dev `FeeConfigDividend.dividendToken` 哨兵值:由本次发射的 vault factory 计算分红代币。
address constant MAGIC_DIVIDEND_COMPUTED = 0xC0Dec0dec0DeC0Dec0dEc0DEC0DEC0DEC0DEC0dE;

/// @notice Fee slot 类型;决定 `FeeConfig.configData` 的解码方式。只追加、不改序。
///           NONE      → 忽略 configData(空槽)
///           DEFLATION → configData 为空(销毁到 DEAD;无参数)
///           LP_BPS    → configData 为空(加入 LP;无参数)
///           MARKETING → configData = abi.encode(FeeConfigMarket)
///           VAULT     → configData = abi.encode(FeeConfigVault)
///           DIVIDEND  → configData = abi.encode(FeeConfigDividend)
enum FeeSlotType {
    NONE,        // 0
    DEFLATION,   // 1: 销毁到 DEAD
    LP_BPS,      // 2: 加入 LP
    MARKETING,   // 3: 发给营销钱包
    VAULT,       // 4: 发给本次发射的 vault
    DIVIDEND     // 5: 作为分红分发
}

/// @notice 一个 fee slot:类型标签、bps 份额,以及按 `feeType` 解码的不透明数据。
/// @dev 新增 fee 类型不会改变此结构体 —— 只需给 `FeeSlotType` 加一个值并定义新的 `FeeConfigXxx`
///      解码目标。所有非 NONE 槽的 bps 之和必须恰好为 10000。
struct FeeConfig {
    FeeSlotType feeType;
    uint16 bps;
    bytes configData;
}

/// @notice `FeeSlotType.MARKETING` 的 `FeeConfig.configData` 内容。
struct FeeConfigMarket {
    address marketingAddress;
}

/// @notice `FeeSlotType.VAULT` 的 `FeeConfig.configData` 内容。
/// @param vaultFactory 校验发射并创建 vault 的 `IDirectLaunchVaultFactory`。
/// @param vaultData    factory 自定义参数,布局由 `vaultDataSchema()` 描述。
struct FeeConfigVault {
    address vaultFactory;
    bytes vaultData;
}

/// @notice `FeeSlotType.DIVIDEND` 的 `FeeConfig.configData` 内容。
/// @dev `dividendToken`:0 = quote,MAGIC_DIVIDEND_SELF = 本次发射的代币,MAGIC_DIVIDEND_COMPUTED = 由
///      本次发射的 vault factory 计算(需要 VAULT 槽),否则为具体的 ERC-20(quote、本次发射的代币,
///      或 swap registry 支持的资产)。
///      `minimumShareBalance` 必须在本次发射 totalSupply 的 [0.001%, 0.1%] 区间内。
struct FeeConfigDividend {
    address dividendToken;
    uint256 minimumShareBalance;
}

configData 编码:

槽configData
MARKETINGabi.encode(address marketingAddress)
VAULTabi.encode(address vaultFactory, bytes vaultData)
DIVIDENDabi.encode(address dividendToken, uint256 minimumShareBalance)
DEFLATION / LP_BPS空

规则:

  • 所有激活(非 NONE)槽的 bps 之和必须恰好 10000。
  • feeConfigs 最多 4 项。每种类型(DEFLATION、LP_BPS、DIVIDEND、MARKETING、VAULT)最多出现一次。MARKETING 与 VAULT 互斥。
  • DIVIDEND 的 minimumShareBalance 必须在 totalSupply 的 [0.001%, 0.1%] 区间内。
  • VAULT 的地址不由调用者提供。Portal 通过 vaultFactory 解析,并把它放在本次发射的 marketing-or-vault 槽。见 面向 Vault 开发者。

Dev 余量

/// @notice 选择本次发射 dev 预留余量的去向。
///           NONE     - 不分发(余量策略仍会执行;`amount` 必须为 0)。
///           EXPLICIT - 直接使用 `Distribution.strategy`(调用者指定的 IStrategy,必须非零)。
///           VAULT    - 发给**本次**发射的 provenance vault;`strategy` 字段被忽略,
///                      并被解析出的 vault 覆盖。
enum DistributionTarget {
    NONE,      // 0
    EXPLICIT,  // 1
    VAULT      // 2
}

/// @notice 一条分发指令:使用哪个目标 / strategy、分发多少代币,以及 strategy 自定义数据。
/// @param target     余量去向(见 `DistributionTarget`)。
/// @param strategy   EXPLICIT 时调用的 `IStrategy`;NONE 与 VAULT 时忽略。
/// @param amount     交给 strategy 的代币数量。不得超过记录的 `pendingRemainder`。
/// @param configData strategy 自定义的不透明参数,原样传给 `initializeDistribution`。
struct Distribution {
    DistributionTarget target;
    address strategy;
    uint128 amount;
    bytes configData;
}
  • NONE:不分发。amount 必须为 0。
  • EXPLICIT:把 amount 发给调用者指定的 strategy(必须非零)。
  • VAULT:把 amount 发给本次发射的 provenance vault。strategy 被忽略。
  • amount 是 dev 预留余量,通常是 totalSupply - poolToken。

余量策略在每次发射都会执行,无论是否指定 distribution:

  • pendingRemainder == 0:无需处理。
  • pendingRemainder 在 (0, 1 token]:作为 dust 销毁到 DEAD。
  • pendingRemainder > 1 token:发射 revert ExcessiveRemainder。

创建者首买

/// @notice 可选的创建者首买 swap 参数。仅在 `p.creatorBuyQuote > 0` 时执行。
///         此结构体只携带 swap **自身**的参数 ——
///         不含数量(== creatorBuyQuote),也不含方向(隐含为 quote → 刚发射的代币)。
/// @param minOutputAmount 最少获得的代币数量(滑点下限)。
/// @param permitData      ERC-20 quote 的可选 EIP-2612 permit;为空则依赖事先的 approve。
struct CreatorBuyParams {
    uint256 minOutputAmount;
    bytes permitData;
}

仅在 creatorBuyQuote > 0 时使用。池子注入且余量分发完成后,Portal 会用 creatorBuyQuote 的 quote 执行一次普通买入,输出发给调用者。minOutputAmount 是滑点下限;permitData 是 ERC-20 quote 的可选 EIP-2612 permit。

发射前预览

新集成调用 graduationFloorV2 同时预览创建门槛和毕业门槛。旧的 graduationFloor 仍只返回原来的四个毕业字段。

const preview = await portal.graduationFloorV2({
  quoteToken,          // address(0) = 原生币
  quotePriceUsd,       // 18 位 WAD
  poolQuote,
  poolToken,
  totalSupply,
  vaultFactory,        // 没有 VAULT 槽时传 address(0)
});

// preview.meetsCreateFloor, preview.createFloorUsdWad
// preview.meetsFloor, preview.floorUsdWad
// preview.quoteUsdWad, preview.multiplierBps
/// @notice 输入与旧版预览完全相同。
struct GraduationFloorParams {
    address quoteToken;     // address(0) = 原生币
    uint256 quotePriceUsd;  // quote 代币 USD 价格,18 位 WAD
    uint256 poolQuote;      // 注入 V2 池子的 quote
    uint256 poolToken;      // 注入 V2 池子的代币
    uint256 totalSupply;    // 发射总供应量
    address vaultFactory;   // 本次发射 VAULT 槽的 factory(address(0) = 无)
}

/// @notice V2 结果:原有四个字段 + 创建门槛字段。所有 USD 数值都是 18 位 WAD。
struct GraduationFloorResultV2 {
    bool meetsFloor;          // quoteUsdWad >= floorUsdWad
    uint256 floorUsdWad;      // 毕业所需 pool quote USD(向上取整)
    uint256 quoteUsdWad;      // 池子 quote 的 USD 价值 = toWad(poolQuote) * quotePriceUsd
    uint16 multiplierBps;     // 实际倍数(10000 = 1x)
    bool meetsCreateFloor;    // quoteUsdWad >= createFloorUsdWad
    uint256 createFloorUsdWad; // 创建所需 pool quote USD(向上取整)
}

function graduationFloorV2(GraduationFloorParams calldata params)
    external
    view
    returns (GraduationFloorResultV2 memory result);
字段公式
quoteUsdWadtoWad(poolQuote) * quotePriceUsd
createFloorUsdWadceil(750 * multiplierBps / 10000 * totalSupply / poolToken)
floorUsdWadceil(1500 * multiplierBps / 10000 * totalSupply / poolToken)
meetsCreateFloorquoteUsdWad >= createFloorUsdWad
meetsFloorquoteUsdWad >= floorUsdWad
  • meetsCreateFloor == false 表示 launchTaxTokenV2 会 revert LaunchLiquidityTooLow(quoteUsdWad, createFloorUsdWad)。
  • 位于两个门槛之间是合法状态:允许创建,但不会毕业。
  • createFloorUsdWad / floorUsdWad 是整个池子需要的 quote USD,不是单价。
  • 距离毕业还差多少:
    const graduationShortfallUsd = max(0n, preview.floorUsdWad - preview.quoteUsdWad);
    const graduationShortfallQuoteWad = graduationShortfallUsd * 10n ** 18n / quotePriceUsd;
    再按 quote token 的 decimals 转成实际数量。
  • 例子:BNB $600、3 BNB、50% 进池 → quoteUsd = $1,800,createFloor = $1,500,floor = $3,000;允许创建但不毕业,距离毕业还差 $1,200(2 BNB)。

旧的 graduationFloor() 和它的四字段 GraduationFloorResult 保持不变;新前端/终端请调用 graduationFloorV2()。

其余数字都是发射入参的纯四则运算:

指标公式
Initial pricepoolQuote / poolToken
Initial liquiditypoolQuote * 2
Pool allocationpoolToken
Reserved (Dev or Vault)totalSupply - poolToken
FDVtotalSupply * initialPrice

创建者首买的输出:池子是标准 V2 pair。可用目标 DEX 的 LP fee 按 V2 恒定乘积公式计算,或用 creatorBuy.minOutputAmount 做滑点保护。

发射示例

import { ethers } from "ethers";

const portal = new ethers.Contract(PORTAL, PORTAL_ABI, signer);

const feeConfigs = [
  {
    feeType: 3, // MARKETING
    bps: 9000,
    configData: ethers.AbiCoder.defaultAbiCoder().encode(["address"], [marketing]),
  },
  { feeType: 2, bps: 1000, configData: "0x" }, // LP_BPS
];

const p = {
  name: "My Token",
  symbol: "MTK",
  meta: "bafkrei...",
  totalSupply: 1_000_000_000n * 10n ** 18n,
  salt,
  quoteToken: ethers.ZeroAddress, // 原生币
  dexId: 0,
  poolQuote: ethers.parseEther("20"),
  poolToken: ethers.parseEther("800000000"),
  buyTaxRate: 500,
  sellTaxRate: 500,
  taxDuration: 86400n,
  antiFarmerDuration: 0n,
  feeConfigs,
  antiSniperMode: 0,
  exemptWindow: 0,
  exemptAddrs: [],
  creatorBuyQuote: 0n,
  extensionID: ethers.ZeroHash,
  extensionData: "0x",
  commissionReceiver: ethers.ZeroAddress,
};

// 无余量:全部注入池子。
const distribution = {
  target: 0, // NONE
  strategy: ethers.ZeroAddress,
  amount: 0n,
  configData: "0x",
};

const creatorBuy = { minOutputAmount: 0n, permitData: "0x" };

const tx = await portal.launchTaxTokenV2(p, distribution, creatorBuy, {
  value: p.poolQuote + p.creatorBuyQuote,
});
await tx.wait();

把 dev 余量交给本次发射的 vault:

const distribution = {
  target: 2, // VAULT
  strategy: ethers.ZeroAddress,
  amount: totalSupply - poolToken,
  configData: "0x",
};

ERC-20 quote:把 quoteToken 设为该代币,不发送 value,并先给 Portal approve poolQuote + creatorBuyQuote。

读取发射状态

const state = await portal.getLaunchToken(token);
// kind              → 0 (DIRECT_V2_POOL_TAX)
// tokenVersion      → 0 (TOKEN_DIRECT_TAXED)
// externalVenue     → migrator 类型
// dexId, quoteTokenType, quoteTokenAddress
// status            → 6 = Created, 4 = DEX
// circulatingSupply, pendingRemainder
// pool, poolType, venueData
// totalSupply

launchQuoteConfig(kind, quoteToken) 返回 allowlist 开关与配置的 quote 美元价:

const [enabled, quotePriceUsd] = await portal.launchQuoteConfig(0, quoteToken);
/// @notice 扁平化的发射状态 = LaunchState + LaunchDexPool + 若干代币信息,一次调用取回。
/// @dev 枚举字段以 uint8 返回,这样即使调用方 ABI 还不认识新的枚举值,读取也不会 revert。
struct LaunchTokenState {
    // ── 不可变的发射身份 ──
    uint8 kind;                 // LaunchKind(0 = DIRECT_V2_POOL_TAX)
    uint8 tokenVersion;         // TokenVersion(0 = TOKEN_DIRECT_TAXED)
    uint8 externalVenue;        // 代币上架的 MigratorType
    // ── 不可变的发射参数 ──
    uint8 dexId;                // DEX 提供方 id
    uint8 quoteTokenType;       // QuoteTokenType(原生币或 ERC-20)
    address quoteTokenAddress;  // quote 代币(原生币为 address(0))
    // ── 可变的运行时状态 ──
    uint8 status;               // TokenStatus(池子创建前为 Created,之后为 DEX)
    uint256 circulatingSupply;  // 流通量
    uint256 pendingRemainder;   // 仍可通过分发交出的余量;Created 状态下为 0
    // ── 外部池子 ──
    address pool;               // 池子地址
    uint8 poolType;             // PoolType 场所类型
    bytes venueData;            // 场所参数(V2 为空)
    // ── 代币信息(实时读取)──
    uint256 totalSupply;        // 代币的 ERC-20 totalSupply()
}

/// @notice 代币的完整发射状态。若 `token` 不是通过 Pool Launch 发射的,revert `LaunchTokenNotFound`。
function getLaunchToken(address token) external view returns (LaunchTokenState memory state);

/// @notice 读取某个 (kind, quote token) 组合的发射配置。
/// @return enabled       该组合的发射白名单开关。
/// @return quotePriceUsd quote 代币 USD 价格,18 位 WAD。
function launchQuoteConfig(LaunchKind kind, address quoteToken)
    external
    view
    returns (bool enabled, uint256 quotePriceUsd);

/// @notice 发射 VAULT fee slot 的 vault provenance。若发射没有 VAULT 槽(或 `token` 不是发射代币),
///         两个返回值都是 address(0)。
function getLaunchVault(address token) external view returns (address vault, address vaultFactory);

/// @notice vault provenance 及其认证元数据。
struct LaunchVaultInfo {
    address vault;
    address vaultFactory;
    bool isOfficial;
    uint8 riskLevel;
    uint8 category;
}

/// @notice 代币 vault provenance 与元数据的兼容视图(见 `LaunchVaultInfo`)。
function getLaunchVaultInfo(address token) external view returns (LaunchVaultInfo memory info);

需要索引的事件

/// @notice 本发射路径下 `FlapDirectLaunched.data` 的 schema id。
/// @dev `kind` 表示**哪种**发射形态(稳定的业务分类);`schemaId` 表示 `data` 中打包的是**哪种**结构体
///      布局,二者可独立演进。形态新增字段不会改变 `kind`,但会升级 schema id(_V1 → _V2)。
///      索引器从解码后的日志读取 `kind`、按 `schemaId` 选择解码器;遇到未知的 schema id,说明 `data` 使用了比当前实现更新的布局。
bytes32 constant V2_POOL_LAUNCH_SCHEMA_V1 = keccak256("DirectLaunchV2(DirectLaunchV2Data)");

/// @notice 所有 Pool Launch 唯一的发射事件。携带代币、形态(`kind`)、创建者、布局 id(`schemaId`)
///         以及 abi 编码的形态结构体(`data`)。
/// @dev    topic0 = keccak256("FlapDirectLaunched(address,uint8,address,bytes32,bytes)")。
///         V2_POOL_LAUNCH_SCHEMA_V1 → abi.decode(data, (DirectLaunchV2Data))。
/// @param token    发射的代币(关联键)。
/// @param kind     发射形态(DIRECT_V2_POOL_TAX)。
/// @param creator  发射调用者(msg.sender)。
/// @param schemaId 编码结构体规范标签的 keccak256。
/// @param data     abi.encode(<schemaId 对应的形态结构体>)。
event FlapDirectLaunched(
    address token,
    LaunchKind kind,
    address creator,
    bytes32 schemaId,
    bytes data
);

/// @notice 发射达到毕业门槛时,在发射交易内发出一次。之后永不发出;发射时未达标的代币永远不会毕业。
event FlapTokenGraduated(address token);

/// @notice 通过 `lockSalt` 预留 CREATE2 salt 时发出。
event FlapSaltLocked(address locker, bytes32 salt, address tokenAddress, uint8 launchKind, uint256 ts);

/// @notice 被锁定的 salt 被成功发射消耗时发出。
event FlapSaltUsed(address locker, bytes32 salt, address tokenAddress, uint8 launchKind, uint256 ts);

/// @notice 发射为其 VAULT 槽解析出 vault 与 provenance 时发出。
/// @dev 不是分发标记:发射可以有 VAULT 槽,但不把余量交给它。用 `FlapDistributionExecuted`
///      (target == VAULT)判断是否真的分发了余量。
event FlapLaunchVaultResolved(address token, address vault, address vaultFactory);

/// @notice 代币的 dev 余量通过 strategy 分发时发出。
/// @param token    被分发的代币。
/// @param target   调用者意图(`NONE` / `EXPLICIT` / `VAULT`)。`VAULT` 表示本次发射把余量交给了
///                 provenance vault。
/// @param strategy 实际拉取代币的已解析 strategy(`VAULT` 时就是该 vault)。
/// @param amount   分发数量。
event FlapDistributionExecuted(
    address token,
    DistributionTarget target,
    address strategy,
    uint256 amount
);

/// @notice 与 ERC-20 `Transfer` 相同,另附双方变动后的 `balanceOf`,索引器无需对每笔转账单独查余额。
event FlapTokenBalanceChanged(
    address from,
    address to,
    uint256 value,
    uint256 fromBalance,
    uint256 toBalance
);
  • FlapDirectLaunched 是标准发射记录;schemaId 决定 data 的布局。
  • 本路径 schemaId = V2_POOL_LAUNCH_SCHEMA_V1;data 按 DirectLaunchV2Data 解码。
  • FlapTokenGraduated 表示毕业。DIRECT_V2_POOL_TAX 在发射交易内、达到门槛时发出;注意下方的 kind 说明。
  • FlapSaltLocked 在 salt 预留时发出;FlapSaltUsed 在发射消耗它时发出。
  • FlapLaunchVaultResolved 在创建阶段解析出 VAULT 槽时发出;是索引 vault provenance 最快的入口。它不是分发标记 —— 是否真的把余量交给 vault,要看 FlapDistributionExecuted(target == VAULT)。
  • FlapDistributionExecuted 在 dev 余量交给 strategy 时发出;target == VAULT 表示本次发射的 provenance vault 收到了余量。
  • FlapTokenBalanceChanged 在代币每次余额变动时发出,携带变动后的双边余额(address(0) = mint/burn)。它不是 indexed —— 在应用层按地址过滤。

毕业规则取决于 kind。 当前仅支持 DIRECT_V2_POOL_TAX,其毕业检查在发射交易内执行一次;若发射事件中没有 FlapTokenGraduated,则该代币在此 kind 下未毕业。其他 kind 可能采用不同规则:未来的 kind 可以引入进度机制,仅在进度达到阈值后发出 FlapTokenGraduated。索引时请以 (token, kind) 为键(kind 可从 FlapDirectLaunched 或 getLaunchToken 获取),并在发射交易结束后继续监听该事件。

DirectLaunchV2Data:

/// @notice DIRECT_V2_POOL_TAX 的完整 `FlapDirectLaunched.data` 内容(schema V2_POOL_LAUNCH_SCHEMA_V1)。
/// @param nonce           全局创建 nonce(与联合曲线发射的代币同一序列)。
/// @param ts              创建时的区块时间戳。
/// @param tokenVersion    代币实现(TOKEN_DIRECT_TAXED)。
/// @param quoteToken      池子的 quote 侧(原生币为 address(0))。
/// @param migratorType    代币上架的外部场所 / 池子类型(此路径恒为 V2_MIGRATOR)。
/// @param initialPriceWad 池子开盘价,1e18 定点的 quote-per-token(已按精度归一化)。
/// @param name            代币名称。
/// @param symbol          代币符号。
/// @param meta            代币元数据 URI。
/// @param pool            V2 池子地址(mainPool)。
/// @param totalSupply     代币总供应量(18 位小数)。
/// @param antiSniperMode  恒为 0(本版本不支持反狙击)。
/// @param maxBuyBps       恒为 0(没有按 tx.origin 的买入上限)。
struct DirectLaunchV2Data {
    uint256 nonce;
    uint256 ts;
    TokenVersion tokenVersion;
    address quoteToken;
    MigratorType migratorType;
    uint256 initialPriceWad;
    string name;
    string symbol;
    string meta;
    address pool;
    uint256 totalSupply;
    uint8 antiSniperMode;
    uint16 maxBuyBps;
}
字段含义
nonce全局发射 nonce。
ts发射区块时间戳。
tokenVersionTOKEN_DIRECT_TAXED(0)。
quoteTokenquote 侧(address(0) = 原生币)。
migratorType恒为 V2_MIGRATOR。
initialPriceWad开盘价,18 位 quote-per-token。
name、symbol、meta代币标识。
poolV2 pool 地址。
totalSupply总供应量,18 位小数。
antiSniperMode本版本恒为 0。
maxBuyBps本路径恒为 0。

面向 Mini App 开发者

游戏发售、Mint、预售等机制需要先完成前置流程,方可上池。Pool Launch 通过 Preparation 支持此类场景,涉及两个合约:

  • PreparationFactory:由 Mini App 提供,须先被设置为低风险。Portal 调用该合约部署 Preparation,并获取该 Preparation 的 multiplier。
  • Preparation:承载前置流程的合约,用户直接与其交互。条件满足后,由其以自身地址调用 Portal 的 launchTaxTokenV2 完成发射。每个 Preparation 仅可发射一次。

multiplier 同时决定创建门槛与毕业门槛,取值范围为 1 至 10000 bps,10000 表示不下调。该值由 PreparationFactory 返回,须经 Flap 确认后方可使用。

没有前置步骤

不涉及前置步骤的场景,建议以 Vault 的方式接入,详见 面向 Vault 开发者。

涉及前置步骤的场景,请使用 Preparation,详见下文。

Preparation

Preparation 是执行 Mini App 自有机制、并完成一次发射的合约,不是 ERC20。

Portal 不提供具体实现:机制由 Mini App 自行编写,由 factory 负责部署,并由 Flap 将该 factory 设置为低风险。官方提供两个基类用于处理通用接线逻辑,详见 基类。

机制完全位于 Preparation 合约内:游戏内的发售与认购、Mint、预售、白名单、份额、价格及领取状态均由其自行管理。Portal 不读取这些状态,仅要求 Preparation 在条件满足后以自身地址调用 launchTaxTokenV2。

Portal 为每个 Preparation 仅存储一条记录:

struct PreparationRecord {
    address preFactory;   // 创建它的低风险 factory
    uint16 multiplierBps; // 1..10000,按比例缩放创建门槛与毕业门槛
}
/// @notice 通过已设置为低风险的 factory 部署 Preparation,并在 Portal 中记录。
/// @dev 可支付:`msg.value` 会转给 factory。
function newPreparation(address preFactory, bytes calldata data)
    external payable returns (address preparation, uint16 multiplierBps);

function getPreparation(address preparation) external view returns (PreparationRecord memory);

Portal 仅调用 factory 的以下函数:

/// @dev 仅允许 Portal 调用。返回 Preparation 及其 multiplier(1..10000,10000 表示不下调)。
function newPreparation(bytes calldata data) external payable returns (address preparation, uint16 multiplierBps);

data 的格式由 Mini App 自行定义:factory 通过 abi.decode 解析参数并据此配置 Preparation,协议不要求特定的初始化函数。factory 只需返回已部署的 Preparation 及其 multiplier,部署、克隆与注资方式由 factory 自行决定。

条件满足后,Preparation 通过 Portal 完成发射:

interface IPreparationPortal {
    function launchTaxTokenV2(
        CreateTaxTokenParams calldata p,
        Distribution calldata distribution,
        CreatorBuyParams calldata creatorBuy
    ) external payable returns (address token, address pool);
}

调用流程:

  1. Portal.setTrustedPreparationFactory(factory, true):由 Flap 执行,将 factory 设置为低风险,每个 factory 执行一次(需 admin 或 auditor 角色)。
  2. Portal.newPreparation{value}(factory, data):由 Mini App 针对每场销售调用一次。Portal 将 msg.value 转发给 factory.newPreparation{value}(data),校验返回地址为合约且 multiplier 合法,写入 {preFactory, multiplierBps} 并发出 FlapPreparationCreated。
  3. 用户与 Preparation 交互:游戏内认购、Mint、预售买入、白名单领取。条件、价格与状态均由 Mini App 定义。
  4. Preparation 调用 Portal.launchTaxTokenV2{value}(p, distribution, creatorBuy),仅可调用一次。Portal 将该 Preparation 标记为已发射(再次调用会 revert),并记录 token 到 Preparation 的溯源关系。

事件:FlapPreparationCreated、FlapPreparationFactorySet,所有字段均未设置 indexed。

规则行为
每个 Preparation 只能发射一次重复发射将 revert PreparationAlreadyLaunched。每场销售应部署新的 Preparation。
Preparation multiplier 优先调用方为 Preparation 时,门槛计算使用其 multiplierBps,而非 vault factory 的 multiplier。
取消低风险设置不会锁死发射factory 被取消低风险设置后不能再创建 Preparation,已创建的仍可发射,但门槛改按 vault factory 的 multiplier 计算。
salt 须由 Preparation 自行锁定lockSalt 以 msg.sender 作为锁定方;若由 factory 执行锁定,Preparation 的发射将失败。

基类

两个基类均不存储状态,仅处理通用接线,机制仍由 Mini App 自行实现。源码:src/vaults/PreparationFactoryBase.sol、src/vaults/PreparationBase.sol;接口:src/interfaces/IPreparation.sol。

Factory

Portal 仅调用 IPreparationFactory 中的以下函数:

/// @title IPreparationFactory
/// @notice Interface a trusted PreparationFactory exposes to the Portal. `newPreparation` is called by
///         the Portal (which records the returned address + multiplier); a factory must only accept
///         calls from its configured Portal.
interface IPreparationFactory {
    /// @notice Deploy a new Preparation (or mechanism/fundraising contract) and return it with its
    ///         floor multiplier. `msg.value` is forwarded by the Portal, so a factory can use it for
    ///         deployment, salt locking, or initial funding.
    /// @dev If the value is used to lock a launch salt, the lock MUST be executed by the Preparation
    ///      itself: `Portal.lockSalt` records `msg.sender` as the locker, and the Preparation's later
    ///      launch requires `locker == Preparation`. A lock executed by the factory records the factory
    ///      as locker and makes the Preparation's launch revert with FlapSaltLockedByAnotherUser.
    /// @param data Mechanism-specific payload. Its layout and use are entirely up to the factory; the
    ///        Portal never decodes it. There is no required initializer function.
    /// @return preparation The deployed Preparation (must be a contract).
    /// @return multiplierBps Floor multiplier in bps (1..10_000; 10_000 = no reduction).
    function newPreparation(bytes calldata data) external payable returns (address preparation, uint16 multiplierBps);

    /// @notice The factory-side spec revision this implementation conforms to. MUST return `"plp-v1"`
    ///         (pool launch preparation spec, v1).
    /// @return The version string.
    function factorySpecVersion() external pure returns (string memory);
}

PreparationFactoryBase 在 newPreparation 上施加 onlyHub 限制,仅 Pool Launch Portal 可以创建 Preparation。factory 需实现 _newPreparation(data):按自定义格式解析 data,部署(或克隆)Preparation,并返回该 Preparation 及其 multiplier。factorySpecVersion() 返回 plp-v1。

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

import {IPreparationFactory} from "src/interfaces/IPreparation.sol";
import {LaunchVaultAddresses} from "src/libraries/LaunchVaultAddresses.sol";

/// @title PreparationFactoryBase
/// @author The Flap Team
/// @notice Recommended (not mandatory) abstract base for Preparation factories.
///
///         A Preparation setup is two contracts:
///         1. the Preparation — the app's mechanism (game sales, mint, presale, whitelist, ...);
///         2. the PreparationFactory — deploys that Preparation and returns its floor multiplier.
///
///         This base provides ONLY the caller check every factory needs:
///         - `onlyHub`: the caller is the Pool Launch Portal of this chain;
///         - the per-chain getter behind it (`internal view virtual`).
///
///         `newPreparation` is implemented here and applies `onlyHub` before delegating to the
///         factory's `_newPreparation`, so a factory cannot forget the check. Everything else
///         (deployment / clone model, multiplier, salt locking, initial funding) is the factory's
///         own design. This base is deliberately storage-free.
///
/// @dev  ── WHAT A PREPARATION FACTORY IS ──────────────────────────────────────
///
/// The Portal calls `IPreparationFactory(preFactory).newPreparation{value: msg.value}(data)` from
/// `PortalPreparation.newPreparation` — see `Portal.newPreparation(preFactory, data)`. The caller is
/// therefore ALWAYS the Portal proxy (the "Hub"), and never a wallet, an EOA or another contract.
/// Gate the entry point with `onlyHub` (this base does) so nobody can create a Preparation outside
/// the Portal: an unrecorded Preparation has no multiplier and the Portal does not know it.
///
/// `msg.value` is whatever the app attached to `Portal.newPreparation`. The factory may forward it
/// to the Preparation (initial funding) or spend it on deployment.
///
/// ── SALT LOCKING ───────────────────────────────────────────────────────────
///
/// If the value is used to lock a launch salt, the Preparation MUST execute the lock itself.
/// `Portal.lockSalt` records `msg.sender` as the locker, and a Preparation's later launch only
/// accepts a lock whose locker is the Preparation. A lock taken by the factory records the factory
/// and makes the Preparation's launch revert with `FlapSaltLockedByAnotherUser`.
///
/// ── HOW TO IMPLEMENT ───────────────────────────────────────────────────────
///
/// 1. Inherit `PreparationFactoryBase`.
/// 2. Implement `_newPreparation(data)`: `abi.decode` `data` with your own layout, deploy (or clone)
///    the Preparation, initialize it with the Portal address and the decoded parameters, and return
///    it with its floor multiplier — for example:
///
/// ```solidity
/// function _newPreparation(bytes calldata data)
///     internal
///     override
///     returns (address preparation, uint16 bps)
/// {
///     (address creator, uint256 seed) = abi.decode(data, (address, uint256));
///     preparation = address(new MyPreparation(_getHub(), creator, seed));
///     bps = 5_000; // 50% of the create and graduation floors
/// }
/// ```
///
/// The Portal records the returned multiplier per Preparation and rejects `0` or `> 10000`.
///
/// Factories are immutable by convention: to change the mechanism, deploy a new factory and ask Flap
/// to trust it (`Portal.setTrustedPreparationFactory`).
abstract contract PreparationFactoryBase is IPreparationFactory {
    /// @notice The caller is not the Pool Launch Portal of this chain.
    error NotHub(address caller);

    // ─────────────────────────────────────────────────────────────────────────
    // Modifiers
    // ─────────────────────────────────────────────────────────────────────────

    /// @notice Restrict a function to the Pool Launch Portal of this chain.
    /// @dev Already applied to `newPreparation` below. Use it on any other Portal-facing entry you
    ///      add (for example a factory-side `initialize` the Portal calls separately).
    modifier onlyHub() {
        if (msg.sender != _getHub()) revert NotHub(msg.sender);
        _;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Chain addresses
    // ─────────────────────────────────────────────────────────────────────────

    /// @notice The Pool Launch Portal of this chain (see `LaunchVaultAddresses`). `virtual`.
    function _getHub() internal view virtual returns (address) {
        return LaunchVaultAddresses.hub();
    }

    // ─────────────────────────────────────────────────────────────────────────
    // IPreparationFactory
    // ─────────────────────────────────────────────────────────────────────────

    /// @notice The factory-side spec revision this implementation conforms to. MUST return `"plp-v1"`.
    /// @return The version string.
    function factorySpecVersion() public pure virtual override returns (string memory) {
        return "plp-v1";
    }

    /// @inheritdoc IPreparationFactory
    /// @dev Portal-only: `onlyHub` cannot be bypassed by a subclass.
    function newPreparation(bytes calldata data)
        external
        payable
        override
        onlyHub
        returns (address preparation, uint16 bps)
    {
        return _newPreparation(data);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Factory-specific
    // ─────────────────────────────────────────────────────────────────────────

    /// @notice Deploy the Preparation and return it with its floor multiplier.
    /// @dev Called by `newPreparation` after the Hub check. `msg.value` is available as usual, so a
    ///      factory can fund or initialize the Preparation with it.
    ///
    ///      `data` is opaque to the Portal: it is forwarded unchanged and never decoded there. Decode
    ///      it here with your own layout and use it to configure the Preparation (creator, sale
    ///      parameters, funding, ...). Keep the layout stable — it is the factory's public ABI.
    /// @param data Mechanism-specific payload from `Portal.newPreparation`; never decoded by the Portal.
    /// @return preparation The deployed Preparation (must be a contract).
    /// @return bps Floor multiplier in bps (1..10_000; 10_000 = no reduction).
    function _newPreparation(bytes calldata data) internal virtual returns (address preparation, uint16 bps);
}

Preparation

IPreparationPortal 是 Preparation 调用的发射入口:

/// @title IPreparationPortal
/// @notice The Portal entry point a Preparation calls once its pre-DEX conditions are met. Mirrors
///         PortalLaunch.launchTaxTokenV2 (declared locally to keep this interface dependency-light).
interface IPreparationPortal {
    function launchTaxTokenV2(
        IDirectPoolLaunch.CreateTaxTokenParams calldata p,
        Distribution calldata distribution,
        IDirectPoolLaunch.CreatorBuyParams calldata creatorBuy
    ) external payable returns (address token, address pool);
}

PreparationBase 对其做了封装。_getHub() 返回发射所用的 Portal;_launchOnHub(...) 默认转发当前调用的 msg.value(原生 quote),也可显式指定 value。

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

import {IDirectPoolLaunch} from "src/interfaces/IDirectPoolLaunch.sol";
import {Distribution} from "src/types/Distribution.sol";
import {IPreparationPortal} from "src/interfaces/IPreparation.sol";

/// @title PreparationBase
/// @author The Flap Team
/// @notice Recommended (not mandatory) abstract helper base for Preparation contracts.
///
///         It provides ONLY:
///         - `_getHub()`, the Pool Launch Portal this Preparation launches on (`internal view virtual`);
///         - `_launchOnHub(...)`, a typed wrapper around `Portal.launchTaxTokenV2`.
///
///         It has NO storage and no initializer, so it imposes no storage layout or deployment
///         pattern. The mechanism — game sales and purchases, mint, presale, whitelist, allocations, pricing,
///         claim state — is entirely the Preparation's own design, and the Portal never reads it.
///
/// @dev  ── WHAT A PREPARATION IS ──────────────────────────────────────────────
///
/// The Preparation is the pre-DEX contract an app deploys through its trusted factory. Users interact
/// with it directly (deposit / mint / buy / claim). Once its conditions are met, the Preparation calls
/// `launchTaxTokenV2` on the Portal FROM ITS OWN ADDRESS: the Portal records the token -> Preparation
/// provenance, applies the Preparation's floor multiplier, and allows exactly one launch per
/// Preparation (`PreparationAlreadyLaunched` on a second call). A failed launch reverts the whole
/// mechanism call, so the Preparation stays unlaunched.
///
/// A Preparation is NOT an ERC20 and does not implement any launch logic itself: the token, pool and
/// vault are created by the Portal in that single call.
///
/// ── SALT LOCKING ───────────────────────────────────────────────────────────
///
/// If the launch salt is locked, the Preparation must have locked it itself: `Portal.lockSalt` records
/// `msg.sender` as the locker and the launch requires `locker == Preparation`. A lock taken by the
/// factory records the factory and makes this launch revert.
///
/// ── HOW TO IMPLEMENT ───────────────────────────────────────────────────────
///
/// 1. Inherit `PreparationBase`.
/// 2. Store the Portal (and your factory) in your own storage during initialization, and return it
///    from `_getHub()`.
/// 3. Write the mechanism. When its conditions are met, call `_launchOnHub(p, distribution,
///    creatorBuy)` with the launch parameters.
abstract contract PreparationBase {
    /// @notice The Pool Launch Portal (Hub) this Preparation launches on.
    /// @dev Implement it as a stored value (set during initialization) or a constructor argument for a
    ///      non-cloned deployment.
    function _getHub() internal view virtual returns (address);

    /// @notice Launch through the Portal, forwarding this call's `msg.value` as the launch value.
    /// @dev Native quote: `msg.value` must cover `p.poolQuote + p.creatorBuyQuote`.
    ///      ERC-20 quote: the Preparation must hold the quote and approve the Portal instead, so pass
    ///      `value == 0` with `_launchOnHub(..., 0)`.
    function _launchOnHub(
        IDirectPoolLaunch.CreateTaxTokenParams calldata p,
        Distribution calldata distribution,
        IDirectPoolLaunch.CreatorBuyParams calldata creatorBuy
    ) internal returns (address token, address pool) {
        return _launchOnHub(p, distribution, creatorBuy, msg.value);
    }

    /// @notice Launch through the Portal with an explicit native value.
    /// @param value Native quote sent with the launch (`0` for an ERC-20 quote).
    function _launchOnHub(
        IDirectPoolLaunch.CreateTaxTokenParams calldata p,
        Distribution calldata distribution,
        IDirectPoolLaunch.CreatorBuyParams calldata creatorBuy,
        uint256 value
    ) internal returns (address token, address pool) {
        return IPreparationPortal(_getHub()).launchTaxTokenV2{value: value}(p, distribution, creatorBuy);
    }
}

常见报错

报错原因
UntrustedPreparationFactorynewPreparation 传入了未设置为低风险的 factory。
InvalidPreparationFactory被设置为低风险的地址不是合约。
InvalidPreparationfactory 返回了 EOA 或 EIP-7702 代理地址。
InvalidPreparationMultiplierBpsmultiplier 为 0 或大于 10000。
PreparationAlreadyExists返回的 Preparation 已被记录。
PreparationAlreadyLaunched同一 Preparation 重复发射。

面向 Vault 开发者

Vault 是可选的、按代币部署的合约,接收该代币 quote 收益的一部分,也可以接收 dev 预留的代币余量。

Vault 如何挂到发射上

在 feeConfigs 中加入一个 VAULT 槽:

const vaultSlot = {
  feeType: 4, // VAULT
  bps: 8000,
  configData: ethers.AbiCoder.defaultAbiCoder().encode(
    ["address", "bytes"],
    [vaultFactory, vaultData],
  ),
};

剩余 bps 只能来自 DEFLATION、LP_BPS 或 DIVIDEND;MARKETING 不能与 VAULT 同时出现。

在 launchTaxTokenV2 期间:

  1. Portal 校验发射,并调用:

    /// @notice 校验发射并创建其 vault。由 Portal 在创建阶段调用。
    /// @param token        发射代币的**预测**地址(尚未部署)。
    /// @param creator      发射调用者。
    /// @param launchKind   `LaunchKind` 的 `uint8` 值(DIRECT_V2_POOL_TAX = 0)。
    /// @param launchParams `abi.encode(CreateTaxTokenParams, Distribution)`。
    /// @return vault       放入本次发射 VAULT fee slot 的 vault。
    function newVault(
        address token,
        address creator,
        uint8 launchKind,
        bytes calldata launchParams
    ) external returns (address vault);
  2. launchParams = abi.encode(CreateTaxTokenParams, Distribution)。Factory 从该 payload 读取原始 quoteToken(address(0) = 原生币)以及自己的 vaultData。

  3. 返回的 Vault 会放入本次发射的 VAULT fee slot,并从那时起接收该槽 bps 份额的代币 quote 收益。

  4. Portal 记录 provenance:getLaunchVault(token) / getLaunchVaultInfo(token)。

newVault 在代币部署之前执行。Factory 可以 revert 或返回地址,但不得调用代币。

Factory 接口

/// @title IDirectLaunchVaultFactory
/// @notice Pool Launch 的 vault factory 接口。
/// @dev `newVault` 接收**完整**的发射输入(创建参数 + Distribution),因此一次调用内既创建 vault
///      也校验发射。没有单独的 `validateLaunch`。
interface IDirectLaunchVaultFactory {
    /// @notice 校验发射并创建其 vault。由 Portal 在创建阶段调用。
    /// @param token        发射代币的**预测**地址(尚未部署)。
    /// @param creator      发射调用者。
    /// @param launchKind   `LaunchKind` 的 `uint8` 值。
    /// @param launchParams `abi.encode(<该 kind 的创建参数>, Distribution)`。
    /// @return vault       放入本次发射 VAULT fee slot 的 vault。
    function newVault(address token, address creator, uint8 launchKind, bytes calldata launchParams)
        external
        returns (address vault);

    /// @notice 该 factory 的 vault 是否支持 `quoteToken`(`address(0)` = 原生币)。供 UI 使用。
    function isQuoteTokenSupported(address quoteToken) external view returns (bool);

    /// @notice factory 规范版本。必须以 `pl` 开头;当前为 `"pl-v1"`。
    function factorySpecVersion() external pure returns (string memory);

    /// @notice 该 factory 的 `FeeConfigVault.vaultData` 的 schema,用于 UI 生成表单。
    function vaultDataSchema() external pure returns (VaultDataSchema memory);

    /// @notice 与 `newVault` 内检查一致的 UI 提示。仅供参考。
    function tokenCreationPolicies() external view returns (FactoryPolicy[] memory);

    /// @notice 升级该 factory 部署的所有 vault 的实现。
    /// @dev 要获得低风险徽章,此函数必须只允许 Guardian 调用。
    function upgradeVaultImplementation(address newImplementation) external;
}

/// @title IVaultFactoryDividend
/// @notice 可选的 vault factory 扩展:计算本次发射的分红资产。
/// @dev 仅当 `FeeConfigDividend.dividendToken == MAGIC_DIVIDEND_COMPUTED` 且发射带有 VAULT 槽时调用。
///      未实现该接口的 factory 会被拒绝。
interface IVaultFactoryDividend {
    function resolveDividendToken(address predictedToken, address quoteToken, address creator, bytes calldata vaultData)
        external
        view
        returns (address dividendToken);
}
方法要求
newVault限制为 Portal 调用(onlyHub)。拒绝不支持的 launchKind。部署前校验发射。在同一次调用内完成部署与初始化。返回非零且有代码的地址。不要调用 token。
isQuoteTokenSupported必须真实,且与 newVault 一致。无法记账的 quote 返回 false。
factorySpecVersion返回 "pl-v1"(必须以 pl 开头)。
vaultDataSchema必须与 newVault 解码的 vaultData 布局一致。
tokenCreationPolicies面向 UI 的提示信息;与 newVault 的检查保持一致。
upgradeVaultImplementation限制为 Guardian 调用(onlyGuardian)。Factory 本身保持不可变;要改行为就部署新 factory。

所有基于 Pool Launch 的 vault factory 都必须返回以 pl 开头的 factorySpecVersion(当前为 "pl-v1"),以便集成方识别它是 Pool Launch 专属 factory。基类 DirectLaunchVaultFactoryBase 默认返回 "pl-v1",子类不要覆盖成其他值。

Factory 基类

DirectLaunchVaultFactoryBase 是推荐(非强制)的基类。它只提供 onlyHub / onlyGuardian、其背后的各链地址,以及两个解码辅助函数;IDirectLaunchVaultFactory 的所有方法都保持 abstract。完整源码(原文,未翻译),src/vaults/DirectLaunchVaultFactoryBase.sol:

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

import {IDirectLaunchVaultFactory} from "src/interfaces/ILaunchVaultSpec.sol";
import {IDirectPoolLaunch} from "src/interfaces/IDirectPoolLaunch.sol";
import {IPortalTypes} from "src/interfaces/IPortal.sol";
import {Distribution} from "src/types/Distribution.sol";
import {VaultDataSchema, FactoryPolicy} from "src/interfaces/IVaultSchemas.sol";
import {LaunchVaultAddresses} from "src/libraries/LaunchVaultAddresses.sol";

/// @title DirectLaunchVaultFactoryBase
/// @author The Flap Team
/// @notice Recommended (not mandatory) abstract base for Pool Launch vault factories.
///
///         It provides ONLY the caller checks and the per-chain addresses every factory needs in
///         exactly the same way:
///         - `onlyHub`:      the caller is the Direct Launch Portal (Hub) of this chain;
///         - `onlyGuardian`: the caller is the Flap Guardian of this chain;
///         - the two per-chain getters behind them (`internal view virtual`).
///
///         Every `IDirectLaunchVaultFactory` method is left abstract. The base enforces no implementation
///         convention: how a vault is deployed, initialized, validated or upgraded is the factory's
///         own design. The natspec on each method states what the Hub, the UI and the low-risk audit
///         expect. `SplitVaultFactory` / `StakingVaultFactory` are worked examples.
///
/// @dev  ── WHAT A LAUNCHER VAULT FACTORY IS ─────────────────────────────────
///
/// The Hub's create facets resolve a launch's `FeeSlotType.VAULT` slot by calling
/// `IDirectLaunchVaultFactory(vaultFactory).newVault(predictedToken, creator, uint8(kind), launchParams)` — see
/// `PortalLauncherBase._parseAndValidateFeeConfigs`. `launchParams` is
/// `abi.encode(<that kind's create params>, Distribution)`: the factory reads the RAW `p.quoteToken`
/// (`address(0)` = native, no WBNB normalization) and its own VAULT-slot `vaultData` from it. The
/// returned vault address lands in a marketing slot and receives that slot's bps share of the token's
/// quote revenue from then on. There is no on-chain registration requirement to *deploy* a factory;
/// however, only factories registered via `VaultLaunch.registerVaultFactory` get their vaults stamped
/// `official`/`riskLevel`/`category` at launch, so production factories SHOULD be registered.
///
/// `newVault` runs BEFORE the token is deployed (`predictedToken` is a CREATE2 prediction), so a
/// factory can revert or return a bogus address but cannot re-enter the Hub: the token is not cloned
/// yet and no launch state has been written. Gate `newVault` with `onlyHub` to stop a third party
/// pre-creating a deterministic clone (a squat that would make the real launch revert on the CREATE2
/// collision) — see `SplitVaultFactory` for the reference.
///
///
/// ── HOW TO IMPLEMENT ───────────────────────────────────────────────────────
///
/// 1. Inherit `DirectLaunchVaultFactoryBase`.
/// 2. Implement `newVault(...)` and gate it with `onlyHub`: validate the launch, deploy (or clone)
///    your vault, initialize it with `p.quoteToken` (raw; `address(0)` = native), and return a
///    non-zero address with code. Never call `token` (it has no code yet).
/// 3. Implement `isQuoteTokenSupported(quoteToken)` truthfully. Misdeclaring support strands users'
///    tax revenue in a vault that ignores it.
/// 4. Implement `vaultDataSchema()` / `factorySpecVersion()` / `tokenCreationPolicies()`.
///
/// NOTE ON UPGRADEABILITY: per Flap verification conventions a FACTORY should be immutable — to change
/// one, deploy a new factory and register it. The VAULTS it deploys SHOULD be upgradeable via a
/// solady `UpgradeableBeacon` + `LibClone.deployDeterministicERC1967BeaconProxy` (the launcher's own
/// per-token pattern — see `PortalBase.dividendBeacon` / `PortalDirectLauncherCreate._setupDividend`),
/// so one beacon upgrade retargets the logic of every already-launched vault at once. Give the vault
/// implementation a `version()` marker and bump it on each beacon upgrade (mirrors `Dividend.version`).
/// `StakingVaultFactory` is a complete worked example. This base itself is deliberately storage-free.
abstract contract DirectLaunchVaultFactoryBase is IDirectLaunchVaultFactory {
    /// @notice The caller is not the Direct Launch Portal (Hub) of this chain.
    error NotHub(address caller);
    /// @notice The caller is not the Guardian of this chain.
    error NotGuardian(address caller);

    // ─────────────────────────────────────────────────────────────────────────
    // Modifiers
    // ─────────────────────────────────────────────────────────────────────────

    /// @notice Restrict a function to the Direct Launch Portal (Hub) of this chain.
    /// @dev Put it on `newVault`. Without it, anyone could deploy a vault at a deterministic address
    ///      before the launch and make the real launch revert on the address collision.
    modifier onlyHub() {
        if (msg.sender != _getHub()) revert NotHub(msg.sender);
        _;
    }

    /// @notice Restrict a function to the Flap Guardian of this chain.
    /// @dev Put it on the upgrade path. For the low-risk badge the Guardian MUST be the only upgrade
    ///      authority.
    modifier onlyGuardian() {
        if (msg.sender != _getGuardian()) revert NotGuardian(msg.sender);
        _;
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Chain addresses
    // ─────────────────────────────────────────────────────────────────────────

    /// @notice The Direct Launch Portal (Hub) of this chain (see `LaunchVaultAddresses`). `virtual`.
    function _getHub() internal view virtual returns (address) {
        return LaunchVaultAddresses.hub();
    }

    /// @notice The Flap Guardian of this chain (see `LaunchVaultAddresses`). `virtual`.
    function _getGuardian() internal view virtual returns (address) {
        return LaunchVaultAddresses.guardian();
    }

    // ─────────────────────────────────────────────────────────────────────────
    // IDirectLaunchVaultFactory: all abstract
    // ─────────────────────────────────────────────────────────────────────────

    /// @notice Validate the launch and create its vault, in one call. Called by the Hub during the
    ///         create step, before the token is deployed.
    /// @dev `launchParams` by `launchKind` (`IPortalTypes.LaunchKind`):
    ///      | kind                   | launchParams                                                     |
    ///      |------------------------|------------------------------------------------------------------|
    ///      | DIRECT_V2_POOL_TAX (0) | abi.encode(IDirectPoolLaunch.CreateTaxTokenParams, Distribution) |
    ///      The params are the RAW launch input: `p.quoteToken == address(0)` means native (no WBNB
    ///      normalization). The factory's own `vaultData` is in `p.feeConfigs`: the VAULT slot whose
    ///      `FeeConfigVault.vaultFactory == address(this)`.
    ///
    ///      MUST: gate the caller with `onlyHub`; validate the launch (the reserved share, who the
    ///      strategy is, and any other rule its economics need) BEFORE deploying; revert with a clear
    ///      reason unless the launch is acceptable; initialize the vault in this same call and return
    ///      a non-zero address with code; NEVER call `token` (it has no code yet). SHOULD deploy
    ///      deterministically from `token` (one vault per token) behind an upgradeable proxy.
    /// @param token        The launch token's predicted address.
    /// @param creator      The launch caller (`msg.sender` of the launch).
    /// @param launchKind   `IPortalTypes.LaunchKind` as `uint8`.
    /// @param launchParams `abi.encode(<create params of that kind>, Distribution)`.
    /// @return vault       The vault, placed in the launch's VAULT fee slot.
    function newVault(address token, address creator, uint8 launchKind, bytes calldata launchParams)
        external
        virtual
        override
        returns (address vault);

    /// @notice Whether this factory's vault can account for revenue in `quoteToken`.
    /// @dev For UIs (to grey out unsupported quotes). MUST be truthful and consistent with `newVault`,
    ///      which MUST revert for a quote this returns false for.
    /// @param quoteToken Raw quote (`address(0)` = native).
    /// @return Whether the quote is supported.
    function isQuoteTokenSupported(address quoteToken) public view virtual override returns (bool);

    /// @notice Schema describing the `vaultData` bytes `newVault` expects, for UI form generation.
    /// @dev Field order and types MUST match how `newVault` decodes `vaultData`.
    /// @return The schema (`IVaultSchemas.VaultDataSchema`).
    function vaultDataSchema() public pure virtual override returns (VaultDataSchema memory);

    /// @notice The factory-side spec revision this implementation conforms to. Distinct from the
    ///         vault-side `StakingVault.vaultSpecVersion()`. MUST return a Pool Launch factory
    ///         version starting with `pl` (currently `"pl-v1"`).
    /// @return The version string.
    function factorySpecVersion() public pure virtual override returns (string memory) {
        return "pl-v1";
    }

    /// @notice Informational constraints this factory places on launch parameters, for proactive UI
    ///         hints. Informational only; `newVault` enforces. Keep the two consistent.
    /// @return The policies (empty if none).
    function tokenCreationPolicies() public view virtual override returns (FactoryPolicy[] memory);

    /// @notice Upgrade the implementation of every vault this factory has deployed.
    /// @dev For the low-risk badge this MUST be callable by the Guardian only: use `onlyGuardian`, and
    ///      leave no other upgrade path (no owner, proxy admin, upgrader role or deployer EOA). A
    ///      factory that wants no upgrades at all may revert unconditionally.
    /// @param newImplementation The new vault implementation.
    function upgradeVaultImplementation(address newImplementation) external virtual override;

    // ─────────────────────────────────────────────────────────────────────────
    // Decode helpers (optional convenience)
    // ─────────────────────────────────────────────────────────────────────────

    /// @notice Thrown by `_vaultDataFor` when the launch's feeConfigs carry no VAULT slot for this
    ///         factory.
    error VaultDataNotFound(address factory);

    /// @notice `newVault` was called with a `launchKind` this factory does not support.
    error UnsupportedLaunchKind(uint8 launchKind);

    /// @dev Decode the launch payload passed to `newVault`, keyed by `launchKind` so each kind decodes
    ///      its own params struct. Only `DIRECT_V2_POOL_TAX` is supported today; any other kind reverts
    ///      (`UnsupportedLaunchKind`) rather than being mis-decoded, so a future kind — with different
    ///      create params — is opt-in and never breaks this one.
    function _decodeLaunchParams(uint8 launchKind, bytes calldata launchParams)
        internal
        pure
        returns (IDirectPoolLaunch.CreateTaxTokenParams memory p, Distribution memory d)
    {
        if (launchKind != uint8(IPortalTypes.LaunchKind.DIRECT_V2_POOL_TAX)) {
            revert UnsupportedLaunchKind(launchKind);
        }
        return abi.decode(launchParams, (IDirectPoolLaunch.CreateTaxTokenParams, Distribution));
    }

    /// @dev Extract this factory's own VAULT-slot `vaultData` from the launch's feeConfigs.
    function _vaultDataFor(address factory, IDirectPoolLaunch.FeeConfig[] memory feeConfigs)
        internal
        pure
        returns (bytes memory vaultData)
    {
        for (uint256 i = 0; i < feeConfigs.length; ++i) {
            if (feeConfigs[i].feeType != IDirectPoolLaunch.FeeSlotType.VAULT) continue;
            IDirectPoolLaunch.FeeConfigVault memory v =
                abi.decode(feeConfigs[i].configData, (IDirectPoolLaunch.FeeConfigVault));
            if (v.vaultFactory == factory) return v.vaultData;
        }
        revert VaultDataNotFound(factory);
    }
}

Vault 接口

唯一必需的 Vault 接口是:

/// @title IDirectLaunchVault
/// @notice Pool Launch vault **唯一**必须实现的接口。
/// @dev 若 vault 还要接收本次发射的 dev 预留余量,需额外实现 `IStrategy.initializeDistribution`,
///      因为 Portal 会调用它。除此之外没有其他要求。
interface IDirectLaunchVault {
    /// @notice vault 的动态、可读状态。
    /// @dev 必须披露费用 / 佣金,以及所有获得收益或预留代币的参与方。
    function description() external view returns (string memory);

    /// @notice vault 面向用户方法的链上 UI 描述(`IVaultSchemas`,与联合曲线规范相同)。
    function vaultUISchema() external pure returns (VaultUISchema memory);
}
  • description() —— terminal 与 Vault UI 展示的可读状态。必须披露费用、佣金,以及所有获得收益或预留代币的参与方。
  • vaultUISchema() —— 描述 Vault 面向用户的 view / write 方法,让通用 UI 可以渲染。

Vault 基类

DirectLaunchVaultBase 是推荐(非强制)的基类。它没有存储、也没有 initializer;提供 onlyHub / onlyGuardian 以及代币驿站入口 _getTokenDockRouter(),description() / vaultUISchema() 保持 abstract。完整源码(原文,未翻译),src/vaults/DirectLaunchVaultBase.sol:

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

import {VaultUISchema} from "src/interfaces/IVaultSchemas.sol";
import {IDirectLaunchVault} from "src/interfaces/ILaunchVaultSpec.sol";
import {LaunchVaultAddresses} from "src/libraries/LaunchVaultAddresses.sol";

/// @title DirectLaunchVaultBase
/// @author The Flap Team
/// @notice Recommended (not mandatory) abstract helper base for Pool Launch vaults.
///
///         It provides ONLY:
///         - `onlyHub` and `onlyGuardian` modifiers (per-chain addresses);
///         - the Token Dock entry `_getTokenDockRouter()`, behind which routing (verified/open) and
///           migration live — a vault just calls `deposit` / `withdraw` / `balanceOf` on it.
///         The two mandatory `IDirectLaunchVault` methods, `description()` and `vaultUISchema()`, are
///         left abstract.
///
///         It has NO storage and no initializer, so it imposes no storage layout or deployment
///         pattern. Everything else (launch context, revenue accounting, payouts, distribution,
///         upgrade hygiene) is the vault's own design.
///
/// @dev ── TOKEN DOCK RULES ─────────────────────────────────────────────────────────────────────
///
///   - Never cache the dock address. Every deposit and withdrawal of `token` MUST call
///     `_getTokenDockRouter()` and use it. `TokenDockRouter` selects the labeled `VerifiedTokenDock`
///     once the DLP has whitelisted `(this, token)`, and the unlabeled `OpenTokenDock` before that —
///     and it moves this vault's balance across the first time it changes, so the vault never sees the
///     switch.
///   - Do not move a reserve into a dock until both docks are excluded from the token's dividends;
///     until then keep the reserve in the vault (the DLP excludes the vault).
///   - Until the docks are deployed, `LaunchVaultAddresses.tokenDockRouter()` returns `address(0)` and
///     `_getTokenDockRouter()` reverts `TokenDockNotConfigured()`.
abstract contract DirectLaunchVaultBase is IDirectLaunchVault {
    /// @notice The caller is not the Direct Launch Portal of this chain.
    error NotHub(address caller);
    /// @notice The caller is not the Guardian of this chain.
    error NotGuardian(address caller);
    /// @notice The Token Dock entry is not configured on this chain yet.
    error TokenDockNotConfigured();

    // ────────────────────────────────────────────────────────────────────────────────────────────
    // Modifiers
    // ────────────────────────────────────────────────────────────────────────────────────────────

    /// @notice Restrict a function to the Direct Launch Portal of this chain.
    /// @dev Put it on `IStrategy.initializeDistribution` if the vault receives the remainder.
    modifier onlyHub() {
        if (msg.sender != _getHub()) revert NotHub(msg.sender);
        _;
    }

    /// @notice Restrict a function to the Flap Guardian of this chain.
    /// @dev For the low-risk badge, every permissioned vault function MUST also accept the Guardian,
    ///      e.g. `if (msg.sender != creator && msg.sender != _getGuardian()) revert`.
    modifier onlyGuardian() {
        if (msg.sender != _getGuardian()) revert NotGuardian(msg.sender);
        _;
    }

    /// @notice The Direct Launch Portal of this chain. NOT the Bonding Curve Portal.
    /// @dev `virtual` for tests and forks with a freshly deployed DLP.
    function _getHub() internal view virtual returns (address) {
        return LaunchVaultAddresses.hub();
    }

    /// @notice The Flap Guardian of this chain (same as the bonding-curve vault Guardian).
    /// @dev `virtual` for tests.
    function _getGuardian() internal view virtual returns (address) {
        return LaunchVaultAddresses.guardian();
    }

    // ────────────────────────────────────────────────────────────────────────────────────────────
    // Token Dock entry
    // ────────────────────────────────────────────────────────────────────────────────────────────

    /// @notice The `TokenDockRouter` entry this vault uses for every reserve deposit / withdrawal. It
    ///         routes to the verified/open dock and migrates this vault's balance across on
    ///         whitelisting, so the vault does not implement any of that.
    /// @dev `virtual` for tests and forks with a freshly deployed `TokenDockRouter`.
    function _getTokenDockRouter() internal view virtual returns (address dock) {
        dock = LaunchVaultAddresses.tokenDockRouter();
        if (dock == address(0)) revert TokenDockNotConfigured();
    }

    // ────────────────────────────────────────────────────────────────────────────────────────────
    // IDirectLaunchVault: abstract
    // ────────────────────────────────────────────────────────────────────────────────────────────

    /// @notice Dynamic, human-readable status of the vault, shown by terminals and the vault UI.
    /// @dev MUST disclose fees/commission and every party that receives revenue or reserved tokens,
    ///      with current figures where useful (e.g. "Creator recouped 3.2 / 25 BNB; then 100% to
    ///      holders"). It may read state; keep it cheap enough for an `eth_call`.
    function description() public view virtual override returns (string memory);

    /// @notice On-chain description of the vault's user-facing methods, so a generic UI can render
    ///         them (`IVaultSchemas.VaultUISchema`, unchanged from the bonding-curve spec).
    /// @dev List every method a user or keeper is expected to call, with its inputs and any ERC20
    ///      approvals it needs.
    function vaultUISchema() public pure virtual override returns (VaultUISchema memory);
}

两个基类共用的各链地址

src/libraries/LaunchVaultAddresses.sol:

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

/// @title LaunchVaultAddresses
/// @author The Flap Team
/// @notice Per-chain addresses that every Pool Launch (Direct Launch) vault and vault factory needs.
///         Shared by `DirectLaunchVaultBase` and `DirectLaunchVaultFactoryBase`, so the two can never
///         drift apart.
///
///      | Getter                | BNB Chain (56)       | BNB Testnet (97)     | Robinhood (4663) |
///      |-----------------------|----------------------|----------------------|------------------|
///      | `hub()`               | 0x00004210…0000      | 0x00004210…0000      | TBD → reverts    |
///      | `guardian()`          | 0x9e27098d…8a4b      | 0x00004d58…0000      | 0x00004d58…0000  |
///      | `verifiedTokenDock()` | 0x00004B06…0000      | 0x00004B06…0000      | TBD → reverts    |
///      | `openTokenDock()`     | 0x0000E34C…0000      | 0x0000E34C…0000      | TBD → reverts    |
///      | `tokenDockRouter()`   | 0x000048E8…0000      | 0x000048E8…0000      | TBD → reverts    |
///
///      - "Direct Launch Portal" (DLP) is the flap-launcher `Portal` proxy. It is NOT the Bonding Curve
///        Portal (BCP). Never call this a bare "portal".
///      - The two Token Docks (and the router) are deployed deterministically: the same salt + initcode
///        land on the same address on every chain that shares the dock logic.
///      - Each dock getter returns the TransparentUpgradeableProxy address (the dock implementation is
///        behind a dedicated ProxyAdmin, owned by the deployer and renounced once verified).
///      - Every consumer reads these through an `internal view virtual` wrapper, so test harnesses (and
///        fork tests with a freshly deployed DLP) can override them.
library LaunchVaultAddresses {
    /// @notice The current chain has no Direct Launch Portal.
    /// @param chainId The `block.chainid` that was looked up.
    error UnsupportedChain(uint256 chainId);

    /// @notice The Direct Launch Portal (DLP) on the current chain: the only allowed caller of
    ///         `IDirectLaunchVaultFactory.newVault` and of `IStrategy.initializeDistribution`.
    /// @dev Reverts `UnsupportedChain` on any chain without a DLP (including Robinhood until its DLP
    ///      address is known), so an unconfigured deployment rejects every caller instead of accepting
    ///      a zero address.
    /// @return The DLP proxy address.
    function hub() internal view returns (address) {
        uint256 id = block.chainid;
        if (id == 56 || id == 97) return 0x000042108465206185A077Edc3F3fd2b49060000;
        revert UnsupportedChain(id);
    }

    /// @notice The Flap Guardian on the current chain. For the low-risk badge it is the ONLY upgrade
    ///         authority of a factory's vaults, and every permissioned vault function also accepts it.
    /// @dev BNB Chain uses the bonding-curve Guardian; every other chain uses `0x00004d58…0000`.
    /// @return The Guardian address.
    function guardian() internal view returns (address) {
        if (block.chainid == 56) return 0x9e27098dcD8844bcc6287a557E0b4D09C86B8a4b; // BNB Chain
        return 0x00004d58290854c5E9880F4b5B1993c6Ad8b0000; // every other chain
    }

    /// @notice The labeled `VerifiedTokenDock` singleton, resolved per chain.
    /// @dev Deterministically deployed: the same salt + initcode land on the same address on every chain
    ///      that shares this dock's logic. A chain that needs different dock logic gets its own branch
    ///      here, so adding logic on a new chain never affects the others. Reverts `UnsupportedChain`
    ///      elsewhere.
    /// @return The VerifiedTokenDock address.
    function verifiedTokenDock() internal view returns (address) {
        uint256 id = block.chainid;
        if (id == 56 || id == 97) return 0x00004B06D90Df7a39491264744BF187D90Df0000;
        revert UnsupportedChain(id); // add a branch per chain with its own dock
    }

    /// @notice The unlabeled, permissionless `OpenTokenDock` singleton, resolved per chain.
    /// @dev See `verifiedTokenDock` for the per-chain / deterministic-deploy notes.
    /// @return The OpenTokenDock address.
    function openTokenDock() internal view returns (address) {
        uint256 id = block.chainid;
        if (id == 56 || id == 97) return 0x0000E34C74b9eC7b52F35528249Aa9C4AdE00000;
        revert UnsupportedChain(id); // add a branch per chain with its own dock
    }

    /// @notice The `TokenDockRouter` singleton: the single dock entry a vault uses. It
    ///         routes to the verified/open dock and migrates on whitelisting.
    /// @dev See `verifiedTokenDock` for the per-chain / deterministic-deploy notes.
    /// @return The TokenDockRouter address.
    function tokenDockRouter() internal view returns (address) {
        uint256 id = block.chainid;
        if (id == 56 || id == 97) return 0x000048E888aBf09f690a952E1b386b2D031a0000;
        revert UnsupportedChain(id); // add a branch per chain with its own dock
    }
}

收益与 dev 余量

收益。 Vault 接收 VAULT 槽 bps 份额的代币 quote 收益。原生 quote 以原生币到账;ERC-20 quote 以该代币到账。建议的记账方式:

  • 保存一个 baseline,只识别 balance - baseline。
  • 差值为 0 时视为 no-op。
  • 每次流出时减少 baseline。
  • receive() 保持轻量:不 swap、不 revert。若需要物化差值,可加一个无许可的 sync()。

Dev 余量。 若要接收余量,实现 IStrategy.initializeDistribution:

/// @title IStrategy
/// @notice 代币分发策略接口,由 Portal 在发射过程中调用。
/// @dev strategy 是独立、可插拔的合约;新的分发机制(Airdrop、Vault 等)以新的 IStrategy 实现接入,
///      而不是在 Portal 里新增分支。
interface IStrategy {
    /// @notice 分发初始化时发出。
    /// @dev 由 strategy 自己发出,因此日志的 emitter 地址就是该 strategy —— 没有 `distributor` 字段。
    /// @param token  被分发的代币。
    /// @param amount 交给本次分发的 `token` 数量。
    event FlapDistributionInitialized(address token, uint256 amount);

    /// @notice 在该策略下初始化一次代币分发。
    /// @dev 由 strategy 负责从 `msg.sender` 拉取 `amount` 的 `token`(Portal 已为 strategy 精确 approve
    ///      `amount`)。部署或预测确定性 distributor 的实现**必须**把 `salt` 纳入地址推导,以便调用者
    ///      区分其他参数完全相同的分发。
    /// @param token      被分发的代币。
    /// @param amount     要分发的 `token` 数量。
    /// @param configData strategy 自定义的任意参数。
    /// @param salt       域分隔符,对 Portal 不透明。
    function initializeDistribution(
        address token,
        uint256 amount,
        bytes calldata configData,
        bytes32 salt
    ) external;
}
  • 限制为 Portal 调用(onlyHub)。
  • 要求 token == taxToken。
  • 用 transferFrom(msg.sender, address(this), amount) 精确拉取 amount,并校验余额差值。
  • 最多只能调用一次。
  • 发出 FlapDistributionInitialized。

发射时使用 distribution = { target: VAULT, amount: totalSupply - poolToken, ... }。Portal 会解析 provenance Vault 并调用它的 initializeDistribution。

如果 Vault 没有实现 initializeDistribution,不要把 distribution.target 设为 VAULT。

在 newVault 中强制分发规则

如果你的 Vault 机制依赖于接收预留代币 —— 例如承诺 80% 的总量进入 Vault 用于挖矿、线性释放或回购 —— 就要在 newVault 中强制校验,不符合时 revert。newVault 在任何东西部署之前就拿到完整的发射输入(CreateTaxTokenParams 与 Distribution),所以不合规的发射根本不会发生。

至少检查:

  • distribution.target == DistributionTarget.VAULT,确保余量发给本 Vault,而不是调用者指定的其他 strategy。
  • distribution.amount 恰好等于你的经济模型所需的数量,例如 p.totalSupply * 8000 / 10000。
  • p.poolToken + distribution.amount == p.totalSupply,确保没有其他剩余。
  • 机制依赖的其他条件:quote 代币、DIVIDEND 槽及其分红代币、VAULT 槽的 bps、要记录的创建者地址。
function newVault(address token, address creator, uint8 launchKind, bytes calldata launchParams)
    external
    override
    onlyHub
    returns (address vault)
{
    (IDirectPoolLaunch.CreateTaxTokenParams memory p, Distribution memory d) =
        _decodeLaunchParams(launchKind, launchParams);

    // 预留代币必须发给本 vault,且必须恰好是总量的 80%。
    if (d.target != DistributionTarget.VAULT) revert WrongDistributionTarget(d.target);
    uint256 expected = p.totalSupply * RESERVE_BPS / 10_000; // RESERVE_BPS = 8000
    if (d.amount != expected) revert WrongReserveAmount(d.amount, expected);
    if (p.poolToken + d.amount != p.totalSupply) revert SupplyMismatch();

    // ... 然后部署并初始化 vault。
}

如果 Vault 不依赖预留代币,可以跳过这一步;发射可以把余量发往别处(或把全部供应注入池子)。

余量去向:AllocationStrategy 或 Token Dock

dev 余量(totalSupply - poolToken)通常有两种去向。

方案 A —— 交给 AllocationStrategy(不由 Vault 托管)。

如果创建者想把余量分发给接收者,把发射的 distribution 指向共享的 AllocationStrategy,而不是 Vault:

const distribution = {
  target: 1, // EXPLICIT
  strategy: ALLOCATION_STRATEGY, // 共享单例
  amount: totalSupply - poolToken,
  configData: ethers.AbiCoder.defaultAbiCoder().encode(["address"], [creator]),
};

ALLOCATION_STRATEGY 是共享单例(BNB Chain 测试网:0x000051353ef814E4779B692cb2Bb3A6Cb3710000)。

只有 Portal 能调用 AllocationStrategy.initializeDistribution,所以 Vault 无法把代币转进去。创建者之后自行分发:

await allocationStrategy.batchAllocate(token, recipients, amounts);
const remaining = await allocationStrategy.remainingOf(token);

适用于余量需要分发、而不是由 Vault 持有的场景。VAULT 槽里的 Vault 仍然可以获得其 bps 份额的收益,只是不接收余量。

方案 B —— Vault 接收余量,并托管到 Token Dock。

当 distribution.target = VAULT 时,Vault 在 initializeDistribution 中收到余量。把余量托管到 Token Dock(TokenDockRouter),避免它作为一个巨大的持仓地址出现在链上:

function initializeDistribution(address token, uint256 amount, bytes calldata, bytes32)
    external
    onlyHub
{
    require(token == taxToken, "wrong token");
    require(!distributionInitialized, "already initialized");
    distributionInitialized = true;

    token.safeTransferFrom(msg.sender, address(this), amount);

    address dock = _getTokenDockRouter();
    IERC20(token).safeApprove(dock, amount);
    ITokenDock(dock).deposit(token, amount);

    emit FlapDistributionInitialized(token, amount);
}

之后分发:

address dock = _getTokenDockRouter();
ITokenDock(dock).withdraw(token, amount, recipient);

TokenDockRouter 会先把存款放进 open token dock。Vault 通过 Flap 低风险认证、并由 Portal 把 (vault, token) 加入白名单后,余额会自动从 open dock 迁移到 verified token dock。余额同一时间只会存在于其中一个 dock;balanceOf(token, vault) 会同时读取两者,以保证跨迁移边界时结果仍然正确。提款不受限制。

代币驿站接口(TokenDockRouter、OpenTokenDock、VerifiedTokenDock 都实现它;Vault 只与 TokenDockRouter 交互):

/// @title ITokenDock
/// @notice 预留代币的共享托管。两个单例使用同一接口。
interface ITokenDock {
    /// @notice 给 `msg.sender` 的余额记入 `amount` 的 `token`。通过 `transferFrom` 拉取并校验余额差值。
    ///         除非 `isWhitelisted(msg.sender, token)`,否则 VerifiedTokenDock 会 revert。
    function deposit(address token, uint256 amount) external;

    /// @notice 扣减 `msg.sender` 的余额并发送给 `to`。**永不**受限。
    function withdraw(address token, uint256 amount, address to) external;

    /// @notice `owner` 记录在案的 `token` 余额。
    function balanceOf(address token, address owner) external view returns (uint256);

    /// @notice 为所有 owner 托管的 `token` 总量。
    function totalReserved(address token) external view returns (uint256);

    /// @notice `vault` 是否可以存入 `token`。Verified:按 (vault, token) 白名单。Open:始终为 true。
    function isWhitelisted(address vault, address token) external view returns (bool);

    event FlapTokenDockDeposited(address token, address owner, uint256 amount);
    event FlapTokenDockWithdrawn(address token, address owner, address to, uint256 amount);
}

/// @title IVerifiedTokenDock
/// @notice 仅 verified(带标记的)驿站才有的额外接口。
interface IVerifiedTokenDock is ITokenDock {
    /// @notice 把 `(vault, token)` 加入白名单。**只能**由 Portal 调用。
    function addToWhitelist(address vault, address token) external;

    event FlapTokenDockWhitelisted(address vault, address token);
}

Vault data schema

vaultDataSchema()(factory 的创建表单)与 vaultUISchema()(vault 面向用户的方法)使用与联合曲线 Vault 相同的 IVaultSchemas 结构体 —— Pool Launch 没有任何变化。见官方文档:UI schema reference (IVaultSchemasV1)。

示例 Vault

三个 Pool Launch Vault 的示意。它们是伪代码:每个函数体都是注释,说明需要实现什么。三者的收入设置相同,只在预留代币的用法上不同。各示例的动画见 可以构建什么。

共同设置(三个示例都相同):

  • 发射把总量的 80% 发给 vault(distribution.target = VAULT)。newVault 强制校验这一点,否则 revert —— 见 在 newVault 中强制分发规则。
  • 总量的 1% 在 30 天内线性释放给开发者。newVault 记录开发者地址(creator)。
  • quote 收入: 20% 给开发者,直到开发者收到其注入的 quote(poolQuote)的 1.5 倍;其余全部存入代币的分红合约,作为持有者的奖励。达到上限后,100% 归持有者。
  • 剩余的 79% 是各示例不同的地方。

示例 A —— 质押挖矿

79% 作为挖矿池,线性释放给质押者。质押者选择锁定期;锁得越久,权重倍数越高。

Factory:

contract StakeMineVaultFactory is DirectLaunchVaultFactoryBase {
    uint16 constant RESERVE_BPS = 8000;   // 总量的 80% 必须进入 vault
    uint16 constant DEV_VEST_BPS = 100;   // 总量的 1% 线性释放给开发者
    // 挖矿 = RESERVE_BPS - DEV_VEST_BPS = 总量的 79%

    function newVault(address token, address creator, uint8 launchKind, bytes calldata launchParams)
        external
        override
        onlyHub
        returns (address vault)
    {
        // 1. (p, d) = _decodeLaunchParams(launchKind, launchParams)。
        // 2. 强制预留:d.target == VAULT 且
        //    d.amount == p.totalSupply * RESERVE_BPS / 10_000;否则 revert。
        // 3. 强制 quote:isQuoteTokenSupported(p.quoteToken);否则 revert。
        // 4. 强制分红:p.feeConfigs 中有 DIVIDEND 槽,且其 dividendToken 就是本 vault
        //    要存入的代币(例如 quote);否则 revert。
        // 5. 通过 _vaultDataFor(address(this), p.feeConfigs) 读取本 factory 的 vaultData
        //    (例如质押档位)并校验。
        // 6. 以 `token` 为 salt 确定性部署 vault(beacon proxy),并用以下参数初始化:
        //    token、原始 quote、dev = creator、devCap = p.poolQuote * 3 / 2(初始资金的 1.5 倍)、
        //    devVest = p.totalSupply * DEV_VEST_BPS / 10_000、vestStart = block.timestamp、
        //    vestDuration = 30 天。
        // 7. 返回 vault。这里不要调用 `token`,它还没有代码。
    }

    // isQuoteTokenSupported / factorySpecVersion("pl-v1")/ vaultDataSchema /
    // tokenCreationPolicies / upgradeVaultImplementation(onlyGuardian):同基类。
}

Vault:

contract StakeMineVault is DirectLaunchVaultBase, IStrategy {
    /// quote 收入(VAULT 槽分得的交易税)。保持轻量,永不 revert。
    receive() external payable {
        // 1. newRevenue = balance - accountedQuote(余额差值记账);为 0 则直接返回。
        // 2. devShare = newRevenue * 20%,上限为 (devCap - devPaid)。若 devShare > 0:
        //    devPaid += devShare;把 devShare 发给 `dev`。
        // 3. 剩余部分(newRevenue - devShare)存入代币的分红合约(IDividend.deposit),
        //    作为持有者的分红奖励。
        // 4. 开发者线性释放:vested = devVest * min(now - vestStart, vestDuration) / vestDuration;
        //    若 vested > devReleased,把 (vested - devReleased) 个代币直接发给 `dev`
        //    (经由代币驿站),并更新 devReleased。
    }

    /// 无许可的触发入口,用于识别没有触发回调就到账的收入(例如 ERC-20 quote)。
    function sync() external {
        // 与 receive() 的步骤 1-4 相同。
    }

    /// IStrategy:Portal 在发射交易中把 80% 预留代币交给 vault。
    function initializeDistribution(address token, uint256 amount, bytes calldata, bytes32)
        external
        onlyHub
    {
        // 要求 token == taxToken、只能调用一次;用 transferFrom 拉取 `amount` 并校验余额差值;
        // 存入代币驿站;发出 FlapDistributionInitialized。
    }

    /// 以 `lockTier` 质押 `amount` 个发射代币
    /// (例如 0 = 30 天 x1,1 = 90 天 x1.5,2 = 180 天 x2)。
    function stake(uint256 amount, uint8 lockTier) external {
        // 结算奖励;从调用者拉取 `amount` 个代币;记录仓位
        // (amount、unlockAt、weight = amount * multiplier[lockTier]);累加到 totalWeight。
    }

    /// 领取目前已挖到的代币。
    function claimRewards() external {
        // 结算释放:rewardPerWeight += emissionRate * elapsed / totalWeight;
        // 从挖矿池(代币驿站提取)向调用者支付待领代币。
    }

    /// 锁定期结束后取回质押。
    function unstake(uint256 positionId) external {
        // 要求 now >= unlockAt;领取待领奖励;移除权重;返还代币。
    }

    /// 只读:`user` 的待领奖励。
    function pendingRewards(address user) external view returns (uint256) {
        // 与 claimRewards 相同的结算,但不修改状态。
    }

    /// IDirectLaunchVault
    function description() public view override returns (string memory) {
        // 披露:quote 收入的 20% 给开发者,直到初始资金的 1.5 倍(显示已付 / 上限),其余作为分红
        // 给持有者;开发者 1% 的释放进度(已释放 / 总量);以及其余预留代币的去向。
    }

    function vaultUISchema() public pure override returns (VaultUISchema memory) {
        // 列出上面所有面向用户的方法,以及各自的输入与所需的 approve。
    }
}

示例 B —— 随市值上涨释放预留代币

79% 不用于挖矿。Vault 在递增的市值档位挂出固定价格卖单:例如在 20 万美元市值卖出总量的 1%,30 万美元再卖 1%,以此类推。只有市场涨到对应档位时才会释放代币,供应随代币表现逐步增加。卖单收到的 quote 作为分红给持有者,并且逐步释放(例如在 7 天内)而不是一次性发放,避免有人在大额成交前突击买入来瓜分这笔收入。

Factory 与示例 A 相同,另外校验 vaultData 中的挂单阶梯(档位严格递增、总量 ≤ 总供应的 79%)。

contract PriceLadderVault is DirectLaunchVaultBase, IStrategy {
    struct Order {
        uint256 marketCapUsd; // 例如 200_000e18、300_000e18……
        uint256 amount;       // 例如每档总量的 1%
        uint256 sold;
    }

    /// quote 收入(VAULT 槽分得的交易税)。保持轻量,永不 revert。
    receive() external payable {
        // 1. newRevenue = balance - accountedQuote(余额差值记账);为 0 则直接返回。
        // 2. devShare = newRevenue * 20%,上限为 (devCap - devPaid)。若 devShare > 0:
        //    devPaid += devShare;把 devShare 发给 `dev`。
        // 3. 剩余部分(newRevenue - devShare)存入代币的分红合约(IDividend.deposit),
        //    作为持有者的分红奖励。
        // 4. 开发者线性释放:vested = devVest * min(now - vestStart, vestDuration) / vestDuration;
        //    若 vested > devReleased,把 (vested - devReleased) 个代币直接发给 `dev`
        //    (经由代币驿站),并更新 devReleased。
    }

    /// 无许可的触发入口,用于识别没有触发回调就到账的收入(例如 ERC-20 quote)。
    function sync() external {
        // 与 receive() 的步骤 1-4 相同。
    }

    /// IStrategy:Portal 在发射交易中把 80% 预留代币交给 vault。
    function initializeDistribution(address token, uint256 amount, bytes calldata, bytes32)
        external
        onlyHub
    {
        // 要求 token == taxToken、只能调用一次;用 transferFrom 拉取 `amount` 并校验余额差值;
        // 存入代币驿站;发出 FlapDistributionInitialized。
    }

    /// 从已达到市值档位的挂单买入。
    function buy(uint256 orderId, uint256 minTokensOut) external payable {
        // 要求当前市值(V2 池价格 x 总量)>= 该档位;价格 = order.marketCapUsd / totalSupply;
        // 收取 quote,从代币驿站发出代币,更新 order.sold。不要把 quote 一次性存入分红:把它加到
        // `streamPending`,并重新计算释放速率(streamRate = (streamPending + 未释放部分) /
        // STREAM_DURATION,例如 7 天;streamEnd = now + STREAM_DURATION)。
    }

    /// 无许可:把卖单收入中到目前为止应释放的部分存入分红。
    function dripDividends() public {
        // due = streamRate * (min(now, streamEnd) - lastDrip);lastDrip = now;
        // 把 `due` 存入分红合约(IDividend.deposit)给持有者。
        // receive() / sync() 中也会调用,使释放随日常活动推进。
    }

    /// 只读:每一档的价位、数量、剩余量,以及当前是否开放。
    function orders() external view returns (Order[] memory) {
        // 返回整个阶梯。
    }

    /// IDirectLaunchVault
    function description() public view override returns (string memory) {
        // 披露:quote 收入的 20% 给开发者,直到初始资金的 1.5 倍(显示已付 / 上限),其余作为分红
        // 给持有者;开发者 1% 的释放进度(已释放 / 总量);以及其余预留代币的去向。
    }

    function vaultUISchema() public pure override returns (VaultUISchema memory) {
        // 列出上面所有面向用户的方法,以及各自的输入与所需的 approve。
    }
}

示例 C —— Vault 作为众多单边池的 hook

Vault 作为 PancakeSwap Infinity 或 Uniswap v4 池的 hook。它用 79% 的预留代币,对多种支持的 quote 代币开设虚拟池 —— 价格由 hook 内的自定义曲线决定。开池不需要买入任何 quote:每个池都是单边的、由预留代币支撑,因此代币可以与任意多个 quote 配对。

Factory 与示例 A 相同,另外校验 vaultData 中的 quote 代币列表与曲线参数。

contract HookPoolsVault is DirectLaunchVaultBase, IStrategy {
    /// quote 收入(VAULT 槽分得的交易税)。保持轻量,永不 revert。
    receive() external payable {
        // 1. newRevenue = balance - accountedQuote(余额差值记账);为 0 则直接返回。
        // 2. devShare = newRevenue * 20%,上限为 (devCap - devPaid)。若 devShare > 0:
        //    devPaid += devShare;把 devShare 发给 `dev`。
        // 3. 剩余部分(newRevenue - devShare)存入代币的分红合约(IDividend.deposit),
        //    作为持有者的分红奖励。
        // 4. 开发者线性释放:vested = devVest * min(now - vestStart, vestDuration) / vestDuration;
        //    若 vested > devReleased,把 (vested - devReleased) 个代币直接发给 `dev`
        //    (经由代币驿站),并更新 devReleased。
    }

    /// 无许可的触发入口,用于识别没有触发回调就到账的收入(例如 ERC-20 quote)。
    function sync() external {
        // 与 receive() 的步骤 1-4 相同。
    }

    /// IStrategy:Portal 在发射交易中把 80% 预留代币交给 vault。
    function initializeDistribution(address token, uint256 amount, bytes calldata, bytes32)
        external
        onlyHub
    {
        // 要求 token == taxToken、只能调用一次;用 transferFrom 拉取 `amount` 并校验余额差值;
        // 存入代币驿站;发出 FlapDistributionInitialized。
    }

    /// 在 PCS Infinity 或 Uni v4 上开设单边池 TOKEN / `quote`,以本 vault 为 hook。
    function openPool(address quote) external {
        // 要求 `quote` 受支持且尚未开设;以本合约为 hook、带自定义曲线标志初始化池子;
        // 记录该 `quote` 的曲线状态。
    }

    /// Hook 回调:用自定义曲线而不是 AMM 为每笔 swap 定价。
    function beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
        external
        returns (bytes4, BeforeSwapDelta, uint24)
    {
        // 只允许 pool manager;按该 quote 的曲线状态计算输出;从预留(代币驿站)结算代币并收取 quote;
        // 返回替代 AMM swap 的 delta。
    }

    /// 这些池子产生的 quote 收入,与 receive() 一样按 20% / 80% 分账。
    function collect(address quote) external {
        // 取出 hook 为 `quote` 累积的 quote,并执行收入分账。
    }

    /// IDirectLaunchVault
    function description() public view override returns (string memory) {
        // 披露:quote 收入的 20% 给开发者,直到初始资金的 1.5 倍(显示已付 / 上限),其余作为分红
        // 给持有者;开发者 1% 的释放进度(已释放 / 总量);以及其余预留代币的去向。
    }

    function vaultUISchema() public pure override returns (VaultUISchema memory) {
        // 列出上面所有面向用户的方法,以及各自的输入与所需的 approve。
    }
}

检查清单

  • newVault 限制为 Portal 调用(onlyHub)。
  • newVault 拒绝不支持的 launchKind。
  • 若 Vault 依赖预留代币,newVault 校验 distribution.target == VAULT 与精确的 distribution.amount,否则 revert。
  • 在部署 Vault 之前完成发射校验。
  • Vault 是确定性的(每个代币一个)且有代码。
  • newVault 不调用代币(它还没有代码)。
  • isQuoteTokenSupported 与 newVault 一致。
  • vaultDataSchema 与解码的 vaultData 一致。
  • factorySpecVersion 返回 "pl-v1"(以 pl 开头)。
  • Factory 没有升级路径;Vault 的升级路径只允许 Guardian。
  • Vault 只初始化一次,并在构造函数中禁用 initializer。
  • Vault 按余额差值识别收益,而不是直接读原始余额。
  • receive() 不会 revert。
  • Vault 的特权函数同时接受 Guardian。
  • description() 披露所有费用与接收方。
  • vaultUISchema() 列出所有面向用户的方法及其 approve 需求。
  • 若 Vault 接收余量,initializeDistribution 是 onlyHub、单次调用,并校验拉取到的余额。