Pool Launch β€” Integration Guide

Pool Launch lists a tax token directly on a V2 DEX pool in a single transaction. There is no bonding-curve phase: the token is deployed, the pool is seeded and locked, the dev remainder is routed, and an optional creator first buy runs β€” all atomically.

Contents

Pool Launch at a glance

No curve: straight into a real pool

Bonding curve (today)
supply soldpricegraduateDEXprotocol-run curve
  • Fixed supply, fixed curve
  • Graduation = curve fills up
Pool Launch (new)
Creatorquote + tokenDEX poolfrom block 1LP burned πŸ”’seedno curve Β· custom supply Β· custom seed
  • Creator picks supply & seed size
  • Graduation = enough liquidity for the supply

A bonding-curve launch fixes the supply and the curve; the token only reaches a DEX once the curve is bought out. A Pool Launch skips the curve. The creator chooses:

  • The total supply. Any value from 100 to 10¹⁡ tokens (18 decimals) β€” not a fixed protocol supply.
  • How much of it goes into the pool (poolToken) and how much quote goes with it (poolQuote). Together they set the opening price, poolQuote / poolToken.

The LP is burned in the same transaction, so the pool can't be pulled. The token trades on the DEX from the first block.

Graduation: one check, at launch

A Pool Launch token is on the DEX from the first block, so there is nothing to migrate. For the current V2 kind, graduated means the launch seeded enough quote for its pool share.

Launch txpool seededtradable nowchecked oncepool valueβ‰₯ floor ?at launchyesnoπŸŽ“ GraduatedFlapTokenGraduatedπŸ†• Newno event Β· stays hereno bonding curve β†’ nothing to migrate Β· a later price rise never moves a token to Graduated
  • The check runs once, inside the launch transaction, right after the pool is seeded.
  • Meets the floor β†’ FlapTokenGraduated is emitted; terminals can show the token as Graduated.
  • Misses it β†’ no event. For this kind, the token stays in New and does not graduate later.

Formula: Graduation in Pool Launch.

Pool Launch itself looks simple: seed a pool, burn the LP, done. The new Pool Launch Vault is what makes it more than that. It opens the door to a lot of new applications built on the launch itself β€” mining, airdrops, and more complex mechanisms.

A vault that also distributes tokens

Bonding-curve vault
taxVaultprocesses revenue only
Pool Launch vault
taxreserveVault= strategyrevenue + token distribution

A Pool Launch Vault does two things:

  • Revenue. It receives a share of the token's trading tax (the VAULT fee slot), like a bonding-curve vault.
  • Reserved supply. It can also receive the tokens that are not seeded into the pool, inside the launch transaction itself, and hand them out under rules written into its code β€” vesting, airdrops, staking rewards, buy-backs. The factory checks the whole launch before the vault exists, so these rules are part of what gets audited.

This lets a creator build a mechanism around the reserve without holding it in a wallet they could dump.

Example vault: seed it, then earn the seed back

This is one possible vault, not the spec. Take a BNB-quoted tax token: the creator seeds 12.5 BNB with 20% of the supply into the pool. The vault sends 20% of its BNB tax revenue to the creator until the creator has received 1.5Γ— the seed (18.75 BNB), and deposits the rest into the dividend contract for holders. After the cap, 100% goes to holders. 1% of the supply vests to the creator linearly over 30 days. The three example vaults below build on the same setup.

Creatorseeds 12.5 BNBTOKEN / BNBPancakeSwap pooltax in BNBVaultsplits revenueCreator: 20% of tax, up to 1.5× the seed12.5 BNBcap 1.5× = 18.75 BNBHolders: BNB dividends80% now · 100% after the cap1% reservedlinear over 30 days→ creatorrest of reserve?open question
  • Without a vault: the only way to get the 12.5 BNB back is to hold a bag and dump it. The price crashes, holders lose, and the creator moves on to the next launch.
  • With this vault: the 80% reserve sits in the vault under fixed rules, so there is no bag to dump (only 1% that vests linearly over 30 days). The creator is paid back in BNB from trading tax, up to 1.5Γ— the seed. More trading means faster payback, so the creator's interest is to keep the token alive, not to kill it.
  • A new way for the creator to earn: dumping is no longer the only way to get paid. Pool Launch gives the creator a second income, the trading tax paid out through the vault under rules anyone can read, so keeping the token alive pays.

What you can build: three example vaults

All three share the setup above: 80% of the supply goes to the vault, 1% vests to the creator over 30 days, and 20% of the quote revenue goes to the creator until 1.5Γ— the seed, with the rest paid to holders as dividends. They differ in what they do with the remaining 79%. The pseudo-code for each is in Example vaults.

Stake to mine

The 79% is a mining pool, emitted linearly to stakers. Stakers choose a lock period; a longer lock gets a higher weight multiplier.

TOKEN / QUOTEV2 pool Β· tax80% of supplylaunch tx β†’ vaultVaultreceive()reserve 80%= 1% + 79%20%Dev: until 1.5Γ— seedcap 1.5Γ—80%Dividenddeposit()Dev 1%: linear over 30 daysMining pool 79% Β· linear emission30d Γ—190d Γ—1.5180d Γ—2stakers lock longer β†’ higher weight β†’ bigger share of the 79% emission

Release the reserve as the market cap grows

The vault posts fixed-price sell orders at rising market-cap levels: 1% of the supply at a $200K market cap, the next 1% at $300K, and so on. Tokens are released only when the market reaches each level. The quote from these sales goes to holders as dividends, streamed gradually.

TOKEN / QUOTEV2 pool Β· tax79% reserve+1% dev vestVaultsame split as Afixed-pricesell orders1% per levelsale proceeds β†’holders, streamed1% @ 200K1% @ 300K1% @ 400K1% @ 500K…market captime β†’each fixed-price order fills only once the market reaches it

Vault as a hook for many single-sided pools

The vault is a hook for PancakeSwap Infinity or Uniswap v4 pools. With the reserve, it opens single-sided pools against many quote tokens, priced by a custom curve inside the hook. No quote has to be bought to seed them, so the token can be paired with any number of quotes.

TOKEN / QUOTEV2 pool Β· tax79% reserveno quote neededVault = Hookcustom curvebeforeSwap()same split as ATOKEN / BNBTOKEN / USDTTOKEN / USD1TOKEN / ETHTOKEN / CAKEbuyers pay any quotePCS Infinity / Uni v4 pools: one-sided, priced by the hook, as many quotes as you like

Why a Token Dock

Reserve in a custom vault
Top holders%
0x9f3a…c2e178%
0x51b0…77aa4%
0xa3c9…0d143%
0x0e72…b9f02%
Unknown token allocation contract. DYOR.
Reserve in the Token Dock
Top holders%
πŸ”– Token Dock Β· Reserved78%
0x51b0…77aa4%
0xa3c9…0d143%
0x0e72…b9f02%
βœ“ known contract, reserved by vault rules

Any mechanism that reserves tokens shows up on-chain as one giant holder. Traders see "78% in one address" and assume a rug. The Token Dock fixes this: vaults custody their reserve in one shared, well-known contract that terminals can label as "reserved", the same way lockers made locked LP easy to recognise.

Low-risk vaultwhitelisted (vault, token)Unverified vaultnot whitelistedVerified Token DockπŸ”– labeled by terminalsdeposit: whitelist onlyOpen Token Dockno labeldeposit: anyonebecomes low-risk β†’ auto-migratewithdraw is never gated Β· one address for every terminal to label

Every vault deposits through TokenDockRouter. A vault that is not yet verified uses the open dock. Once Flap certifies it as low-risk, the Portal whitelists its (vault, token) pair and the balance moves to the verified dock automatically. Withdrawals are never blocked. See Reserve options for the interface.

Who this guide is for

  • Terminals, bots and wallets β€” launch tokens, preview the outcome, index launches, and read state.
  • Vault developers β€” plug a custom vault into a launch through the VAULT fee slot.

Deployed addresses (BNB Chain testnet)

ContractAddress
Portal (proxy)0x000042108465206185A077Edc3F3fd2b49060000
FlapDirectTaxToken implementation0x7777c57ff7dc441c1715b2e36fa581c48B782222
TaxTokenHelper0xd64441e5fcd02d342b8cf6eba10ef6e40d0da90f
AllocationStrategy0x000051353ef814E4779B692cb2Bb3A6Cb3710000
TokenDockRouter0x000048E888aBf09f690a952E1b386b2D031a0000
OpenTokenDock0x0000E34C74b9eC7b52F35528249Aa9C4AdE00000
VerifiedTokenDock0x00004B06D90Df7a39491264744BF187D90Df0000

Deployed addresses (BNB Chain mainnet)

ContractAddress
Portal (proxy)0x000042108465206185A077Edc3F3fd2b49060000
FlapDirectTaxToken implementation0x7777c57ff7dc441c1715b2e36fa581c48B782222
TaxTokenHelper0x53841c73217735F37BC1775538b03b23feFD8346
AllocationStrategy0x000051353ef814E4779B692cb2Bb3A6Cb3710000
TokenDockRouter0x000048E888aBf09f690a952E1b386b2D031a0000
OpenTokenDock0x0000E34C74b9eC7b52F35528249Aa9C4AdE00000
VerifiedTokenDock0x00004B06D90Df7a39491264744BF187D90Df0000

What a Pool Launch does

Calling launchTaxTokenV2 runs the full launch as one transaction:

  1. Create the tax token, its dividend tracker and its tax processor. If the launch has a VAULT fee slot, the configured vault factory validates the launch and creates the token's vault at this step.
  2. Seed the V2 pool with poolToken and poolQuote, and burn the LP to the dead address.
  3. Mark the token status = DEX and record the dev-reserved remainder.
  4. Distribute the remainder if distribution.amount > 0.
  5. Creator buy if creatorBuyQuote > 0.

The token is tradable immediately after the transaction.

Graduation in Pool Launch

For the current DIRECT_V2_POOL_TAX kind, graduation is a one-time check at launch: the token is already on the DEX, so there is nothing to migrate. It graduates when the pool's quote side is worth enough for its share of supply.

quoteUsd       = poolQuote Γ— quotePriceUsd
floorUsd       = 1500 Γ— multiplierBps / 10000 Γ— totalSupply / poolToken
createFloorUsd = 750  Γ— multiplierBps / 10000 Γ— totalSupply / poolToken
graduated      ⇔ quoteUsd β‰₯ floorUsd
launch allowed ⇔ quoteUsd β‰₯ createFloorUsd

The graduation floor is $1,500 at a 100% pool share. Because only part of supply is in the pool, it scales with 1 / share: at 1Γ—, 50% β†’ $3,000, 20% β†’ $7,500. multiplierBps is 10000 (1Γ—) unless the vault factory lowers it; the same multiplier also lowers the creation floor (base $750 at 100%): at 50% β†’ $1,500, 20% β†’ $3,750, and at 0.5Γ— those halve again. A launch below the creation floor reverts LaunchLiquidityTooLow; passing it does not imply graduation.

FlapTokenGraduated is emitted in the launch transaction when graduated is true. For this kind the check is not repeated; no event at launch means no graduation. Use graduationFloorV2() to preview both thresholds β€” see Pre-launch preview. The legacy graduationFloor() remains available for graduation-only callers.

Graduation is kind-specific; see Events to index.

For terminals and bots

Entry point

/// @notice Launch a tax token end-to-end in one transaction: create the token, seed and lock its
///         V2 pool, route the dev remainder, and run the optional creator first buy.
/// @dev    Native quote: `msg.value` MUST equal `p.poolQuote + p.creatorBuyQuote`.
///         ERC-20 quote: `msg.value` MUST be 0; approve the Portal for `p.poolQuote + p.creatorBuyQuote`.
/// @param p            Create parameters. `poolQuote` / `poolToken` / `creatorBuyQuote` are read from
///                     `p` only, so the caller never repeats them and the pool that is seeded always
///                     matches what the token was created with.
/// @param distribution Hand-off for the dev-reserved remainder. `distribution.amount > 0` sends that
///                     much to the resolved strategy right after the pool is seeded. There is NO
///                     post-launch distribution entry point: a launch must either seed its whole supply
///                     into the pool (`poolToken == totalSupply`) or hand the remainder off here. The
///                     remainder policy runs for EVERY launch β€” a `pendingRemainder` above 1 token
///                     reverts `ExcessiveRemainder`; a residual of <= 1 token is burned to DEAD as dust.
/// @param creatorBuy   Optional. `p.creatorBuyQuote > 0` triggers a creator first buy AFTER the pool is
///                     seeded and any distribution has run; `minOutputAmount` / `permitData` are that
///                     swap's own slippage bound and permit. Ignored when `p.creatorBuyQuote == 0`.
/// @return token The newly deployed tax token.
/// @return pool  The seeded, permanently locked V2 pool.
function launchTaxTokenV2(
    CreateTaxTokenParams calldata p,
    Distribution calldata distribution,
    CreatorBuyParams calldata creatorBuy
) external payable returns (address token, address pool);
  • Native quote (quoteToken == address(0)): msg.value = poolQuote + creatorBuyQuote.
  • ERC-20 quote: msg.value = 0; approve the Portal for poolQuote + creatorBuyQuote.
  • The (DIRECT_V2_POOL_TAX, quoteToken) pair must be enabled by the protocol; otherwise the launch reverts LaunchQuotePairNotEnabled.

Launch parameters

CreateTaxTokenParams

/// @notice Parameters for a Pool Launch (`launchTaxTokenV2`'s `p`).
/// @dev `poolQuote` / `poolToken` are committed when the token is created and checked again when the
///      pool is seeded. No funds are pulled at create time.
struct CreateTaxTokenParams {
    string name;
    string symbol;
    string meta;                // token metadata (IPFS CID)
    uint256 totalSupply;        // creator-chosen, in [100 ether, 1e15 ether], 18 decimals
    bytes32 salt;               // CREATE2 salt; the token address must end in the vanity suffix
    address quoteToken;         // address(0) = native; pool identity is baked into the token at initialize()
    uint8 dexId;                // which DEX provider
    uint256 poolQuote;          // quote that WILL be injected into the pool (> 0)
    uint256 poolToken;          // token that WILL be injected into the pool (> 0 and <= totalSupply)
    uint16 buyTaxRate;          // bps
    uint16 sellTaxRate;         // bps
    uint32 taxDuration;         // seconds; validated against protocol-wide bounds
    uint32 antiFarmerDuration;  // seconds
    FeeConfig[] feeConfigs;     // active slots' bps must sum to exactly 10000
    // ---- NOT supported in this version: keep in the ABI, but pass 0 / 0 / [] ----
    uint8 antiSniperMode;       // must be 0
    uint32 exemptWindow;        // must be 0
    address[] exemptAddrs;      // must be empty
    // ---- optional creator first buy ----
    // > 0 makes launchTaxTokenV2 run the creator first buy with this much quote.
    uint256 creatorBuyQuote;
    // ---- NOT supported in this version: keep in the ABI, but pass 0 / 0x ----
    bytes32 extensionID;
    bytes extensionData;
    // ---- optional commission ----
    // address(0) = disabled. When set, the commission bps is derived from the launch's effective tax
    // rate (max(buyTaxRate, sellTaxRate)) and baked into the token's tax processor; the receiver is paid
    // out of the protocol-fee share during dispatch.
    address commissionReceiver;
}
FieldTypeNotes
name, symbol, metastringToken identity. meta is the IPFS CID.
totalSupplyuint256Chosen by the creator β€” any value in [100 ether, 1e15 ether] (100 to 10¹⁡ tokens, 18 decimals).
saltbytes32CREATE2 salt. The resulting address must end in the tax-token vanity suffix (0x7777).
quoteTokenaddressaddress(0) = native.
dexIduint8Target DEX. 0 on every supported chain.
poolQuoteuint256Quote injected into the pool. Committed here and checked again when the pool is seeded.
poolTokenuint256Token injected into the pool. Must be > 0 and <= totalSupply.
buyTaxRate, sellTaxRateuint16bps. Maximum 1000 (10%).
taxDurationuint32Seconds.
antiFarmerDurationuint32Seconds.
feeConfigsFeeConfig[]At most 4 entries. Active slots must sum to exactly 10000 bps. See Fee slots.
antiSniperMode, exemptWindow, exemptAddrsuint8, uint32, address[]Not supported in this version. Pass 0, 0, [].
creatorBuyQuoteuint256Creator first-buy spend. 0 = no first buy.
extensionID, extensionDatabytes32, bytesNot supported in this version. Pass bytes32(0) / 0x.
commissionReceiveraddressOptional commission beneficiary. address(0) = disabled.

The opening price before the creator buy is:

P0 = poolQuote / poolToken

A launch must either seed the whole supply into the pool (poolToken == totalSupply) or route the remainder through distribution.

Salt locking (anti-front-run)

A token address is CREATE2(tokenImplDirectTaxed, salt, Portal). Anyone who knows salt can compute that address and launch first, so a creator can reserve a salt before launching by paying SALT_LOCK_FEE() in native currency. Once locked, only the locker can launch the token at that salt.

function lockSalt(bytes32 salt, uint8 kind) external payable;
  • kind = DIRECT_V2_POOL_TAX (0).
  • msg.value must equal SALT_LOCK_FEE() exactly; the fee is forwarded to the fee receiver and is not refundable.
  • The predicted address must satisfy the vanity suffix (0x7777); otherwise VanityAddressRequirementNotMet.
  • A salt can be locked once; re-locking reverts. A salt whose token is already deployed cannot be locked.
  • The lock has no expiry. It is consumed by the first successful launch; the entry stays on-chain as an audit trail.
  • An unlocked salt can still be launched by anyone β€” locking is opt-in.
const fee = await portal.SALT_LOCK_FEE();
await portal.lockSalt(salt, 0, { value: fee }); // 0 = DIRECT_V2_POOL_TAX

// Later, the locker launches with the same salt.
await portal.launchTaxTokenV2({ ...p, salt }, distribution, creatorBuy);

Read the lock state:

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

Events:

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 is emitted when the salt is reserved; FlapSaltUsed when it is consumed by a launch.

Errors: FeatureDisabled (salt locking disabled β€” fee is zero), SaltLockFeeMismatch(expected, provided), SaltAlreadyLockedByAnotherUser(salt, locker), SaltAlreadyLockedBySelf(salt), SaltLockTokenAlreadyDeployed(salt, token), UnsupportedLaunchKind(kind), VanityAddressRequirementNotMet(token). At launch: FlapSaltLockedByAnotherUser(salt, locker), SaltLockKindMismatch(salt, locked, provided).

Fee slots

/// @dev `FeeConfigDividend.dividendToken` sentinel: distribute the launched token itself.
address constant MAGIC_DIVIDEND_SELF = 0xfEEDFEEDfeEDFEedFEEdFEEDFeEdfEEdFeEdFEEd;

/// @dev `FeeConfigDividend.dividendToken` sentinel: ask the launch's vault factory to compute it.
address constant MAGIC_DIVIDEND_COMPUTED = 0xC0Dec0dec0DeC0Dec0dEc0DEC0DEC0DEC0DEC0dE;

/// @notice Fee-slot type; selects how `FeeConfig.configData` is decoded. Append-only.
///           NONE      β†’ configData ignored (empty slot)
///           DEFLATION β†’ configData empty (burn to DEAD; no parameters)
///           LP_BPS    β†’ configData empty (added to LP; no parameters)
///           MARKETING β†’ configData = abi.encode(FeeConfigMarket)
///           VAULT     β†’ configData = abi.encode(FeeConfigVault)
///           DIVIDEND  β†’ configData = abi.encode(FeeConfigDividend)
enum FeeSlotType {
    NONE,        // 0
    DEFLATION,   // 1: burn to DEAD
    LP_BPS,      // 2: add to LP
    MARKETING,   // 3: send to a marketing wallet
    VAULT,       // 4: send to the launch's vault
    DIVIDEND     // 5: distribute as dividends
}

/// @notice One fee slot: a type tag, its bps share, and an opaque blob decoded by `feeType`.
/// @dev Adding a new fee type never changes this struct β€” only `FeeSlotType` gains a value and a new
///      `FeeConfigXxx` decode target is defined. All non-NONE slots' bps MUST sum to exactly 10000.
struct FeeConfig {
    FeeSlotType feeType;
    uint16 bps;
    bytes configData;
}

/// @notice `FeeConfig.configData` payload for `FeeSlotType.MARKETING`.
struct FeeConfigMarket {
    address marketingAddress;
}

/// @notice `FeeConfig.configData` payload for `FeeSlotType.VAULT`.
/// @param vaultFactory The `IDirectLaunchVaultFactory` that validates the launch and creates its vault.
/// @param vaultData    Factory-specific parameters, laid out as described by `vaultDataSchema()`.
struct FeeConfigVault {
    address vaultFactory;
    bytes vaultData;
}

/// @notice `FeeConfig.configData` payload for `FeeSlotType.DIVIDEND`.
/// @dev `dividendToken`: 0 = quote, MAGIC_DIVIDEND_SELF = launched token, MAGIC_DIVIDEND_COMPUTED = ask
///      the launch's vault factory (requires a VAULT slot), otherwise a concrete ERC-20 (quote, launched
///      token, or a swap-registry-supported asset).
///      `minimumShareBalance` MUST be within [0.001%, 0.1%] of this launch's own totalSupply.
struct FeeConfigDividend {
    address dividendToken;
    uint256 minimumShareBalance;
}

configData encoding:

SlotconfigData
MARKETINGabi.encode(address marketingAddress)
VAULTabi.encode(address vaultFactory, bytes vaultData)
DIVIDENDabi.encode(address dividendToken, uint256 minimumShareBalance)
DEFLATION / LP_BPSempty

Rules:

  • The sum of all active (non-NONE) slots' bps must be exactly 10000.
  • feeConfigs has at most 4 entries. Each type (DEFLATION, LP_BPS, DIVIDEND, MARKETING, VAULT) may appear at most once. MARKETING and VAULT are mutually exclusive.
  • For DIVIDEND, minimumShareBalance must be within [0.001%, 0.1%] of totalSupply.
  • For VAULT, the vault address is not supplied by the caller. The Portal resolves it from vaultFactory and places it in the launch's marketing-or-vault slot. See For Vault developers.

Dev remainder

/// @notice Selects where a launch's dev-reserved token remainder goes.
///           NONE     - no distribution (the remainder policy still runs; `amount` must be 0).
///           EXPLICIT - use `Distribution.strategy` as-is (caller-named IStrategy, non-zero).
///           VAULT    - route to THIS launch's provenance vault; the `strategy` field is ignored and
///                      overwritten with the resolved vault.
enum DistributionTarget {
    NONE,      // 0
    EXPLICIT,  // 1
    VAULT      // 2
}

/// @notice One distribution instruction: which target/strategy to use, how many tokens, and any
///         strategy-specific data.
/// @param target     Where the remainder goes (see `DistributionTarget`).
/// @param strategy   The `IStrategy` to call for EXPLICIT; ignored for NONE and VAULT.
/// @param amount     Tokens handed to the strategy. Must not exceed the recorded `pendingRemainder`.
/// @param configData Opaque, strategy-specific parameters, forwarded to `initializeDistribution`.
struct Distribution {
    DistributionTarget target;
    address strategy;
    uint128 amount;
    bytes configData;
}
  • NONE: no distribution. amount must be 0.
  • EXPLICIT: send amount to the caller-named strategy (must be non-zero).
  • VAULT: send amount to the launch's provenance vault. strategy is ignored.
  • amount is the dev-reserved remainder, normally totalSupply - poolToken.

The remainder policy runs on every launch, with or without a distribution:

  • pendingRemainder == 0: nothing to do.
  • pendingRemainder in (0, 1 token]: burned to DEAD as dust.
  • pendingRemainder > 1 token: the launch reverts ExcessiveRemainder.

Creator first buy

/// @notice Optional creator-first-buy swap knobs. The buy only executes when `p.creatorBuyQuote > 0`.
///         This struct carries only the swap's OWN parameters β€” not the amount (== creatorBuyQuote) or the
///         direction (implied: quote β†’ the just-launched token).
/// @param minOutputAmount Minimum tokens out (slippage bound).
/// @param permitData      Optional EIP-2612 permit for an ERC-20 quote; empty to rely on a prior approve.
struct CreatorBuyParams {
    uint256 minOutputAmount;
    bytes permitData;
}

Used only when creatorBuyQuote > 0. After the pool is seeded and the remainder is distributed, the Portal performs an ordinary buy of creatorBuyQuote of the quote token and sends the output to the caller. minOutputAmount is the slippage bound; permitData is the optional EIP-2612 permit for an ERC-20 quote.

Pre-launch preview

Use graduationFloorV2 to preview both the creation gate and the graduation floor before sending a launch. The legacy graduationFloor still exposes only the original four graduation fields.

const preview = await portal.graduationFloorV2({
  quoteToken,          // address(0) = native
  quotePriceUsd,       // 18-dec WAD
  poolQuote,
  poolToken,
  totalSupply,
  vaultFactory,        // address(0) if the launch has no VAULT slot
});

// preview.meetsCreateFloor, preview.createFloorUsdWad
// preview.meetsFloor, preview.floorUsdWad
// preview.quoteUsdWad, preview.multiplierBps
/// @notice Inputs are identical to the legacy preview.
struct GraduationFloorParams {
    address quoteToken;     // address(0) = native
    uint256 quotePriceUsd;  // quote token USD price, 18-dec WAD
    uint256 poolQuote;      // quote injected into the V2 pool
    uint256 poolToken;      // token injected into the V2 pool
    uint256 totalSupply;    // launch total supply
    address vaultFactory;   // the launch's VAULT-slot factory (address(0) = none)
}

/// @notice V2 result: the legacy four fields plus the creation-floor fields.
///         All USD figures are 18-dec WAD.
struct GraduationFloorResultV2 {
    bool meetsFloor;          // quoteUsdWad >= floorUsdWad
    uint256 floorUsdWad;      // required pool quote USD to graduate (ceil)
    uint256 quoteUsdWad;      // pool quote USD = toWad(poolQuote) * quotePriceUsd
    uint16 multiplierBps;     // effective multiplier (10000 = 1x)
    bool meetsCreateFloor;    // quoteUsdWad >= createFloorUsdWad
    uint256 createFloorUsdWad; // required pool quote USD to launch (ceil)
}

function graduationFloorV2(GraduationFloorParams calldata params)
    external
    view
    returns (GraduationFloorResultV2 memory result);
FieldFormula
quoteUsdWadtoWad(poolQuote) * quotePriceUsd
createFloorUsdWadceil(750 * multiplierBps / 10000 * totalSupply / poolToken)
floorUsdWadceil(1500 * multiplierBps / 10000 * totalSupply / poolToken)
meetsCreateFloorquoteUsdWad >= createFloorUsdWad
meetsFloorquoteUsdWad >= floorUsdWad
  • meetsCreateFloor == false means launchTaxTokenV2 would revert LaunchLiquidityTooLow(quoteUsdWad, createFloorUsdWad).
  • Falling between the two floors is valid: the launch is allowed but does not graduate.
  • createFloorUsdWad and floorUsdWad are total pool quote USD, not price per token.
  • The remaining quantity for graduation is:
    const graduationShortfallUsd = max(0n, preview.floorUsdWad - preview.quoteUsdWad);
    const graduationShortfallQuoteWad = graduationShortfallUsd * 10n ** 18n / quotePriceUsd;
    then convert graduationShortfallQuoteWad to the quote token's decimals.
  • Example: BNB at $600, 3 BNB, 50% pool share β†’ quoteUsd = $1,800, createFloor = $1,500, floor = $3,000; the launch is allowed but not graduated, and needs $1,200 (2 BNB) more to graduate.

The legacy graduationFloor() and its four-field GraduationFloorResult are unchanged for existing terminals. New integrations should call graduationFloorV2().

The remaining figures are plain arithmetic on the launch inputs:

FigureFormula
Initial pricepoolQuote / poolToken
Initial liquiditypoolQuote * 2
Pool allocationpoolToken
Reserved (Dev or Vault)totalSupply - poolToken
FDVtotalSupply * initialPrice

For the creator first-buy output, the pool is a standard V2 pair. Compute the output with the V2 constant-product formula using the target DEX's LP fee, or rely on creatorBuy.minOutputAmount for slippage protection.

Launch example

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, // native
  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,
};

// No remainder: everything is seeded into the pool.
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();

To route the dev remainder to the launch's vault instead:

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

For an ERC-20 quote, set quoteToken to the token, send no value, and approve the Portal for poolQuote + creatorBuyQuote first.

Reading launch state

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

launchQuoteConfig(kind, quoteToken) returns the allowlist flag and the configured quote USD price:

const [enabled, quotePriceUsd] = await portal.launchQuoteConfig(0, quoteToken);
/// @notice Flattened launch state = LaunchState + LaunchDexPool + a couple of token facts, in one call.
/// @dev Enum-typed fields are surfaced as uint8 so a reader never reverts on an enum value the caller's
///      ABI doesn't know yet.
struct LaunchTokenState {
    // ── immutable launch identity ──
    uint8 kind;                 // LaunchKind (0 = DIRECT_V2_POOL_TAX)
    uint8 tokenVersion;         // TokenVersion (0 = TOKEN_DIRECT_TAXED)
    uint8 externalVenue;        // MigratorType the token lists on
    // ── immutable launch params ──
    uint8 dexId;                // DEX provider id
    uint8 quoteTokenType;       // QuoteTokenType (native vs ERC-20)
    address quoteTokenAddress;  // quote token (address(0) for native)
    // ── mutable runtime ──
    uint8 status;               // TokenStatus (Created before the pool exists, DEX after)
    uint256 circulatingSupply;  // tokens in circulation
    uint256 pendingRemainder;   // remainder a distribution may still hand out; 0 while Created
    // ── external pool ──
    address pool;               // pool address
    uint8 poolType;             // PoolType venue discriminator
    bytes venueData;            // per-venue params (empty for V2)
    // ── token facts (live reads) ──
    uint256 totalSupply;        // ERC-20 totalSupply() of the token
}

/// @notice Full launch state for a token. Reverts `LaunchTokenNotFound` if `token` was not launched
///         through Pool Launch.
function getLaunchToken(address token) external view returns (LaunchTokenState memory state);

/// @notice Read the launch config for a (kind, quote token) pair.
/// @return enabled       Launch allowlist flag for this pair.
/// @return quotePriceUsd Quote token USD price, 18-dec WAD.
function launchQuoteConfig(LaunchKind kind, address quoteToken)
    external
    view
    returns (bool enabled, uint256 quotePriceUsd);

/// @notice Vault provenance for a launch's VAULT fee slot. Both values are address(0) when the launch
///         had no VAULT slot (or `token` was not a launch).
function getLaunchVault(address token) external view returns (address vault, address vaultFactory);

/// @notice Vault provenance plus its certification metadata.
struct LaunchVaultInfo {
    address vault;
    address vaultFactory;
    bool isOfficial;
    uint8 riskLevel;
    uint8 category;
}

/// @notice Compatibility view of a token's vault provenance and metadata (see `LaunchVaultInfo`).
function getLaunchVaultInfo(address token) external view returns (LaunchVaultInfo memory info);

Events to index

/// @notice Schema id for `FlapDirectLaunched.data` on this launch path.
/// @dev `kind` says WHICH launch shape (a stable business classification); `schemaId` says WHICH struct
///      LAYOUT is packed into `data`, so it can evolve independently. A shape gaining a field does NOT
///      change `kind`, but it bumps the schema id (_V1 β†’ _V2). Indexers read `kind` from the decoded log
///      and pick the decoder by `schemaId`; an unknown schema id means the layout is newer than this integration understands.
bytes32 constant V2_POOL_LAUNCH_SCHEMA_V1 = keccak256("DirectLaunchV2(DirectLaunchV2Data)");

/// @notice The single launch event for every Pool Launch. Carries the token, the shape (`kind`), the
///         creator, the layout id (`schemaId`) and the abi-encoded shape struct (`data`).
/// @dev    topic0 = keccak256("FlapDirectLaunched(address,uint8,address,bytes32,bytes)").
///         V2_POOL_LAUNCH_SCHEMA_V1 β†’ abi.decode(data, (DirectLaunchV2Data)).
/// @param token    The launched token (correlation key).
/// @param kind     Launch shape (DIRECT_V2_POOL_TAX).
/// @param creator  The launch caller (msg.sender).
/// @param schemaId keccak256 of the encoded struct's canonical label.
/// @param data     abi.encode(<shape struct identified by schemaId>).
event FlapDirectLaunched(
    address token,
    LaunchKind kind,
    address creator,
    bytes32 schemaId,
    bytes data
);

/// @notice Emitted once, inside the launch transaction, when the launch meets its graduation floor.
///         Never emitted later; a token that misses the floor at launch never graduates.
event FlapTokenGraduated(address token);

/// @notice Emitted when a CREATE2 salt is reserved via `lockSalt`.
event FlapSaltLocked(address locker, bytes32 salt, address tokenAddress, uint8 launchKind, uint256 ts);

/// @notice Emitted when a locked salt is consumed by a successful launch.
event FlapSaltUsed(address locker, bytes32 salt, address tokenAddress, uint8 launchKind, uint256 ts);

/// @notice Emitted when a launch resolves the vault for its VAULT fee slot, with provenance.
/// @dev Not a distribution marker: a launch can have a VAULT slot without routing its remainder to
///      the vault. Use `FlapDistributionExecuted` (target == VAULT) to detect that.
event FlapLaunchVaultResolved(address token, address vault, address vaultFactory);

/// @notice Emitted when a token's dev remainder is distributed via a strategy.
/// @param token    The token distributed.
/// @param target   Caller intent (`NONE` / `EXPLICIT` / `VAULT`). `VAULT` marks a launch that routed
///                 its remainder to its provenance vault.
/// @param strategy The resolved strategy that pulled the tokens (for `VAULT`, the vault itself).
/// @param amount   The amount distributed.
event FlapDistributionExecuted(
    address token,
    DistributionTarget target,
    address strategy,
    uint256 amount
);

/// @notice Same as the ERC-20 `Transfer`, plus each side's resulting `balanceOf`, so indexers don't
///         need a separate balance lookup per transfer.
event FlapTokenBalanceChanged(
    address from,
    address to,
    uint256 value,
    uint256 fromBalance,
    uint256 toBalance
);
  • FlapDirectLaunched is the canonical launch record. schemaId selects the layout of data.
  • For this launch path, schemaId = V2_POOL_LAUNCH_SCHEMA_V1; decode data as DirectLaunchV2Data.
  • FlapTokenGraduated marks graduation. For DIRECT_V2_POOL_TAX it is emitted inside the launch transaction when the floor is met; see the kind note below.
  • FlapSaltLocked is emitted when a salt is reserved; FlapSaltUsed when a launch consumes it.
  • FlapLaunchVaultResolved is emitted during creation when the launch resolved a VAULT slot; it is the fastest way to index vault provenance. It is not a distribution marker β€” use FlapDistributionExecuted with target == VAULT for that.
  • FlapDistributionExecuted is emitted when the dev remainder is handed to a strategy; target == VAULT means the launch's provenance vault received it.
  • FlapTokenBalanceChanged fires on every balance change of the token and carries post-transfer balances for both sides (address(0) = mint/burn). It is not indexed β€” filter in the application layer.

Graduation depends on kind. DIRECT_V2_POOL_TAX (the only kind today) checks graduation once in the launch transaction: if FlapTokenGraduated is not emitted, the token did not graduate for this kind. Other kinds may use different rules: future kinds can introduce a progress mechanism and emit FlapTokenGraduated only after the progress threshold is met. Index by (token, kind) β€” kind is available from FlapDirectLaunched or getLaunchToken β€” and continue listening for the event after the launch transaction.

DirectLaunchV2Data:

/// @notice Full `FlapDirectLaunched.data` payload for DIRECT_V2_POOL_TAX (schema V2_POOL_LAUNCH_SCHEMA_V1).
/// @param nonce           Global creation nonce (same sequence as curve-launched tokens).
/// @param ts              Block timestamp of creation.
/// @param tokenVersion    Token implementation (TOKEN_DIRECT_TAXED).
/// @param quoteToken      Quote side of the pool (address(0) for native).
/// @param migratorType    External venue / pool type the token lists on (always V2_MIGRATOR here).
/// @param initialPriceWad Pool open price as 1e18-fp quote-per-token (decimals-normalized).
/// @param name            Token name.
/// @param symbol          Token symbol.
/// @param meta            Token metadata URI.
/// @param pool            The V2 pool address (mainPool).
/// @param totalSupply     Total token supply (18 decimals).
/// @param antiSniperMode  Always 0 (anti-sniper is not supported in this version).
/// @param maxBuyBps       ALWAYS 0 (no per-tx.origin buy cap).
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;
}
FieldMeaning
nonceGlobal launch nonce.
tsLaunch block timestamp.
tokenVersionTOKEN_DIRECT_TAXED (0).
quoteTokenQuote side (address(0) = native).
migratorTypeAlways V2_MIGRATOR.
initialPriceWadOpening price, 18-dec quote-per-token.
name, symbol, metaToken identity.
poolV2 pool address.
totalSupplyTotal supply, 18 decimals.
antiSniperModeAlways 0 in this version.
maxBuyBpsAlways 0 on this path.

For Flap App Developers

Mechanisms such as a game sale, a mint, or a presale must complete a pre-launch stage before the token can enter the pool. Pool Launch supports such scenarios through a Preparation, which involves two contracts:

  • PreparationFactory: provided by the Flap App and required to be set to low risk first. The Portal calls it to deploy a Preparation and obtains that Preparation's multiplier.
  • Preparation: the contract that carries the pre-launch stage. Users interact with it directly. Once its conditions are met, it calls the Portal's launchTaxTokenV2 from its own address to complete the launch. Each Preparation can launch only once.

The multiplier determines both the creation floor and the graduation floor. It ranges from 1 to 10000 bps, where 10000 means no reduction. The value is returned by the PreparationFactory and must be confirmed by Flap before use.

Launching without a pre-launch stage

Apps without a pre-launch stage should integrate as a Vault. See For Vault developers.

Apps with a pre-launch stage should use a Preparation, described below.

Preparation

A Preparation is a contract that executes the Flap App's own mechanism and completes a single launch. It is not an ERC20.

The Portal ships no implementation: the app implements the mechanism, the factory deploys it, and Flap sets the factory to low risk. Two recommended base contracts handle the common plumbing; see Base contracts.

The mechanism resides entirely within the Preparation: game sales and purchases, minting, presale, whitelist, allocations, pricing and claim state are all managed by it. The Portal never reads that state. It only requires the Preparation to call launchTaxTokenV2 from its own address once its conditions are met.

The Portal stores exactly one record per Preparation:

struct PreparationRecord {
    address preFactory;   // the low-risk factory that created it
    uint16 multiplierBps; // 1..10000, scales the create and graduation floors
}
/// @notice Deploy a Preparation through a factory set to low risk and record it on the Portal.
/// @dev Payable: `msg.value` is forwarded to the factory.
function newPreparation(address preFactory, bytes calldata data)
    external payable returns (address preparation, uint16 multiplierBps);

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

The Portal calls only the following function on the factory:

/// @dev Portal-only. Returns the Preparation and its multiplier (1..10000, 10000 = no reduction).
function newPreparation(bytes calldata data) external payable returns (address preparation, uint16 multiplierBps);

The layout of data is defined by the app: the factory decodes it with abi.decode to configure the Preparation, and the protocol requires no specific initializer. The factory only needs to return the deployed Preparation and its multiplier. Deployment, cloning and funding are left to the factory.

Once its conditions are met, the Preparation completes the launch through the Portal:

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

Call flow:

  1. Portal.setTrustedPreparationFactory(factory, true): executed by Flap to set the factory to low risk, once per factory (admin or auditor role).
  2. Portal.newPreparation{value}(factory, data): called by the app once per sale. The Portal forwards msg.value to factory.newPreparation{value}(data), verifies that the returned address is a contract and the multiplier is valid, records {preFactory, multiplierBps} and emits FlapPreparationCreated.
  3. Users interact with the Preparation: in-game purchases, minting, presale purchases, whitelist claims. The conditions, pricing and state are defined by the app.
  4. The Preparation calls Portal.launchTaxTokenV2{value}(p, distribution, creatorBuy), exactly once. The Portal marks the Preparation as launched (a second call reverts) and records the token-to-Preparation provenance.

Events: FlapPreparationCreated and FlapPreparationFactorySet. Neither has indexed fields.

RuleBehaviour
One launch per PreparationA repeated launch reverts with PreparationAlreadyLaunched. Deploy a new Preparation for each sale.
Preparation multiplier takes precedenceWhen the caller is a Preparation, the floor check uses its multiplierBps instead of the vault-factory multiplier.
Revoking low risk does not block launchesA factory whose low-risk setting is revoked cannot create new Preparations. Existing ones can still launch, but the floor is computed with the vault-factory multiplier.
The Preparation must lock its own saltlockSalt records msg.sender as the locker. A lock made by the factory causes the Preparation's launch to fail.

Base contracts

Two storage-free base contracts handle the common plumbing; the mechanism remains the app's own. Sources: src/vaults/PreparationFactoryBase.sol and src/vaults/PreparationBase.sol. Interfaces: src/interfaces/IPreparation.sol.

Factory

IPreparationFactory defines everything the Portal calls:

/// @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 applies onlyHub to newPreparation, so only the Pool Launch Portal can create a Preparation. The factory implements _newPreparation(data): decode data with its own layout, deploy (or clone) the Preparation, and return it with its floor multiplier. factorySpecVersion() returns 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 is the launch entry the Preparation calls:

/// @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 wraps it. _getHub() returns the Portal to launch on; _launchOnHub(...) forwards the current call's msg.value (native quote) by default, or an explicit 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 equal `p.poolQuote + p.creatorBuyQuote` exactly.
    ///      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);
    }
}

Errors

ErrorCause
UntrustedPreparationFactorynewPreparation was called with a factory that has not been set to low risk.
InvalidPreparationFactoryThe address being set to low risk is not a contract.
InvalidPreparationThe factory returned an EOA or an EIP-7702 delegate.
InvalidPreparationMultiplierBpsMultiplier is 0 or above 10000.
PreparationAlreadyExistsThe returned Preparation is already recorded.
PreparationAlreadyLaunchedThe same Preparation launched more than once.

For Vault developers

A Vault is an optional, per-token contract that receives a share of the token's quote revenue. It can also receive the dev-reserved token remainder.

How a Vault attaches to a launch

Include a VAULT slot in feeConfigs:

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

The remaining bps must come from DEFLATION, LP_BPS, or DIVIDEND; MARKETING cannot be combined with VAULT.

During launchTaxTokenV2:

  1. The Portal validates the launch and calls:

    /// @notice Validate the launch and create its vault. Called by the Portal in the create step.
    /// @param token        The launch token's PREDICTED address (not deployed yet).
    /// @param creator      The launch caller.
    /// @param launchKind   `LaunchKind` as `uint8` (DIRECT_V2_POOL_TAX = 0).
    /// @param launchParams `abi.encode(CreateTaxTokenParams, 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 returns (address vault);
  2. launchParams = abi.encode(CreateTaxTokenParams, Distribution). The factory reads the raw quoteToken (address(0) = native) and its own vaultData from that payload.

  3. The returned Vault is placed in the launch's VAULT fee slot and receives that slot's bps share of the token's quote revenue.

  4. The Portal records provenance: getLaunchVault(token) / getLaunchVaultInfo(token).

newVault runs before the token is deployed. The factory can revert or return an address, but must not call the token.

Factory interface

The complete factory interface, src/interfaces/ILaunchVaultSpec.sol:

/// @title IDirectLaunchVaultFactory
/// @author The Flap Team
/// @notice Pool Launch vault factory interface.
/// @dev `newVault` receives the FULL launch input (create params + Distribution), so it creates the vault
///      AND validates the launch in one call. There is no separate `validateLaunch`.
interface IDirectLaunchVaultFactory {
    /// @notice Validate the launch and create its vault. Called by the DLP in the create step.
    /// @param token        The launch token's PREDICTED address (not deployed yet).
    /// @param creator      The launch caller.
    /// @param launchKind   `IPortalTypes.LaunchKind` as `uint8`.
    /// @param launchParams `abi.encode(<create params of that kind>, Distribution)`.
    /// @return vault       The vault that is placed in the launch's VAULT fee slot.
    function newVault(address token, address creator, uint8 launchKind, bytes calldata launchParams)
        external
        returns (address vault);

    /// @notice Whether this factory's vault supports `quoteToken` (`address(0)` = native). For UIs.
    function isQuoteTokenSupported(address quoteToken) external view returns (bool);

    /// @notice The factory spec version. Must start with `pl`; currently `"pl-v1"`.
    function factorySpecVersion() external pure returns (string memory);

    /// @notice Schema of this factory's `FeeConfigVault.vaultData`, for UI form generation.
    function vaultDataSchema() external pure returns (VaultDataSchema memory);

    /// @notice UI hints that mirror the checks in `newVault`. Informational only.
    function tokenCreationPolicies() external view 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.
    function upgradeVaultImplementation(address newImplementation) external;
}

/// @title IVaultFactoryDividend
/// @notice Optional vault-factory extension: compute the launch's dividend asset.
/// @dev Invoked only when `FeeConfigDividend.dividendToken == MAGIC_DIVIDEND_COMPUTED` and the launch
///      carries a VAULT slot. Factories that do not implement it are rejected.
interface IVaultFactoryDividend {
    function resolveDividendToken(address predictedToken, address quoteToken, address creator, bytes calldata vaultData)
        external
        view
        returns (address dividendToken);
}
MethodRequirement
newVaultRestrict to the Portal (onlyHub). Reject unsupported launchKind. Validate the launch before deploying. Deploy and initialize the Vault in the same call. Return a non-zero address with code. Never call token.
isQuoteTokenSupportedMust be truthful and consistent with newVault. Return false for a quote the Vault cannot account for.
factorySpecVersionReturn "pl-v1" (must start with pl).
vaultDataSchemaMust match the vaultData layout newVault decodes.
tokenCreationPoliciesInformational hints for UIs; keep consistent with the newVault checks.
upgradeVaultImplementationRestrict to the Guardian (onlyGuardian). Keep the factory immutable; change behavior by deploying a new factory.

All Pool Launch vault factories MUST return a factorySpecVersion starting with pl (currently "pl-v1"). Integrations use this prefix to identify Pool Launch-specific factories. DirectLaunchVaultFactoryBase already returns "pl-v1" by default; subclasses must not override it with a different value.

Factory base contract

DirectLaunchVaultFactoryBase is the recommended (not mandatory) base. It provides only onlyHub / onlyGuardian, the per-chain addresses behind them, and two decode helpers; every IDirectLaunchVaultFactory method stays abstract. Complete source, 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 interface

The only mandatory Vault surface, src/interfaces/ILaunchVaultSpec.sol:

/// @title IDirectLaunchVault
/// @author The Flap Team
/// @notice The ONLY mandatory vault surface in a Pool Launch vault.
/// @dev A vault that also receives the launch's dev-reserved remainder additionally implements
///      `IStrategy.initializeDistribution`, because the DLP calls it. Nothing else is required.
interface IDirectLaunchVault {
    /// @notice Dynamic, human-readable status of the vault.
    /// @dev MUST disclose fees/commission and every party that receives revenue or reserved tokens.
    function description() external view returns (string memory);

    /// @notice On-chain UI description of the vault's user-facing methods (`IVaultSchemas`, unchanged
    ///         from the bonding-curve spec).
    function vaultUISchema() external pure returns (VaultUISchema memory);
}
  • description() β€” human-readable status shown by terminals and the Vault UI. It must disclose fees, commission, and every party that receives revenue or reserved tokens.
  • vaultUISchema() β€” describes the Vault's user-facing view and write methods so a generic UI can render them.

Vault base contract

DirectLaunchVaultBase is the recommended (not mandatory) base. It has no storage and no initializer; it provides onlyHub / onlyGuardian and the Token Dock entry _getTokenDockRouter(), and leaves description() / vaultUISchema() abstract. Complete source, 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);
}

Per-chain addresses used by both bases

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
    }
}

Revenue and the dev remainder

Revenue. The Vault receives the VAULT slot's bps share of the token's quote revenue. For a native quote it arrives as native currency; for an ERC-20 quote it arrives as that token. Recommended accounting:

  • Track a stored baseline and recognize only balance - baseline.
  • Treat a zero delta as a no-op.
  • Decrement the baseline on every outflow.
  • Keep receive() light: no swaps, no reverts. Add a permissionless sync() if the Vault needs to materialize the delta.

Dev remainder. To receive the remainder, implement IStrategy.initializeDistribution:

/// @title IStrategy
/// @notice Interface for token distribution strategies, invoked by the Portal during a launch.
/// @dev A strategy is a standalone, pluggable contract; new distribution mechanisms (Airdrop, Vault, ...)
///      are new IStrategy implementations, not new Portal branches.
interface IStrategy {
    /// @notice Emitted when a distribution is initialized.
    /// @dev Emitted by the strategy itself, so the log's emitter address identifies it β€” there is no
    ///      `distributor` field.
    /// @param token  The token being distributed.
    /// @param amount The amount of `token` handed to this distribution.
    event FlapDistributionInitialized(address token, uint256 amount);

    /// @notice Initialize a distribution of tokens under this strategy.
    /// @dev The strategy is responsible for pulling `amount` of `token` from `msg.sender` (the Portal has
    ///      already approved the strategy for exactly `amount`). Implementations that deploy or predict
    ///      deterministic distributors MUST fold `salt` into the address derivation so callers can
    ///      domain-separate otherwise-identical distributions.
    /// @param token      The token being distributed.
    /// @param amount     The amount of `token` to distribute.
    /// @param configData Arbitrary, strategy-specific parameters.
    /// @param salt       Domain separator, opaque to the Portal.
    function initializeDistribution(
        address token,
        uint256 amount,
        bytes calldata configData,
        bytes32 salt
    ) external;
}
  • Restrict to the Portal (onlyHub).
  • Require token == taxToken.
  • Pull exactly amount with transferFrom(msg.sender, address(this), amount) and verify the balance delta.
  • Allow it at most once.
  • Emit FlapDistributionInitialized.

Launch with distribution = { target: VAULT, amount: totalSupply - poolToken, ... }. The Portal resolves the provenance Vault and calls initializeDistribution on it.

If the Vault does not implement initializeDistribution, do not set distribution.target = VAULT.

Enforcing the distribution in newVault

If your vault's mechanism depends on receiving the reserve β€” for example it promises that 80% of the supply goes into the vault for mining, vesting or a buy-back β€” enforce it in newVault and revert when the launch does not match. newVault receives the full launch input (CreateTaxTokenParams and Distribution) before anything is deployed, so a non-compliant launch never happens.

Check at least:

  • distribution.target == DistributionTarget.VAULT, so the remainder goes to this vault and not to a caller-named strategy.
  • distribution.amount is exactly the amount your economics expect, e.g. p.totalSupply * 8000 / 10000.
  • p.poolToken + distribution.amount == p.totalSupply, so nothing else is left over.
  • Anything else your mechanism relies on: the quote token, the DIVIDEND slot and its dividend token, the VAULT slot's bps, the creator address you will record.
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);

    // The reserve MUST come to this vault, and MUST be exactly 80% of the supply.
    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();

    // ... then deploy and initialize the vault.
}

If the vault does not depend on the reserve, skip this; the launch can route the remainder elsewhere (or seed the whole supply).

Reserve options: AllocationStrategy or Token Dock

The dev remainder (totalSupply - poolToken) has two common destinations.

Option A β€” route it to AllocationStrategy (no Vault custody).

If the creator wants to allocate the reserve to recipients, point the launch's distribution at the shared AllocationStrategy instead of the Vault:

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

ALLOCATION_STRATEGY is the shared singleton (0x000051353ef814E4779B692cb2Bb3A6Cb3710000 on BNB Chain testnet).

Only the Portal can call AllocationStrategy.initializeDistribution, so a Vault cannot forward tokens into it. The creator later drains it:

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

Use this when the reserve should be allocated rather than held by a Vault. A Vault in the VAULT slot can still receive its bps share of revenue; it just does not receive the remainder.

Option B β€” the Vault receives it and custodies it in the Token Dock.

With distribution.target = VAULT, the Vault receives the remainder in initializeDistribution. Custody it in the Token Dock (TokenDockRouter) so the reserve does not appear as a large holder:

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

Distribute later:

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

TokenDockRouter deposits into the open token dock first. After the Vault is Flap low-risk certified and the Portal whitelists (vault, token), its balance migrates automatically from the open dock to the verified token dock. The reserve is held in exactly one dock at a time; balanceOf(token, vault) reads both so it stays correct across the migration boundary. Withdrawals are never gated.

Token Dock interface (TokenDockRouter, OpenTokenDock and VerifiedTokenDock all expose it; a Vault only talks to TokenDockRouter):

/// @title ITokenDock
/// @notice Shared custody for reserved tokens. Same interface for both singletons.
interface ITokenDock {
    /// @notice Credit `amount` of `token` to `msg.sender`'s balance. Pulls via `transferFrom` and checks
    ///         the balance delta. VerifiedTokenDock reverts unless `isWhitelisted(msg.sender, token)`.
    function deposit(address token, uint256 amount) external;

    /// @notice Debit `msg.sender`'s balance and send to `to`. NEVER gated.
    function withdraw(address token, uint256 amount, address to) external;

    /// @notice `owner`'s stored balance of `token`.
    function balanceOf(address token, address owner) external view returns (uint256);

    /// @notice Total of `token` held for all owners.
    function totalReserved(address token) external view returns (uint256);

    /// @notice Whether `vault` may deposit `token`. Verified: pair whitelist. Open: always 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 Extra surface on the verified (labeled) dock only.
interface IVerifiedTokenDock is ITokenDock {
    /// @notice Whitelist the `(vault, token)` pair. Callable ONLY by the Portal.
    function addToWhitelist(address vault, address token) external;

    event FlapTokenDockWhitelisted(address vault, address token);
}

Vault data schema

vaultDataSchema() (the factory's creation form) and vaultUISchema() (the vault's user-facing methods) use the same IVaultSchemas structs as bonding-curve vaults β€” nothing changes for Pool Launch. See the official reference: UI schema reference (IVaultSchemasV1).

Example vaults

Three sketches of what a Pool Launch Vault can do. They are pseudo-code: every function body is a comment that says what to implement. They share the same revenue setup and differ only in what they do with the reserve. The animation for each example is in What you can build.

Shared setup (all three examples):

  • The launch routes 80% of the supply to the vault (distribution.target = VAULT). newVault enforces this and reverts otherwise β€” see Enforcing the distribution.
  • 1% of the supply vests to the dev linearly over 30 days. newVault records the dev address (creator).
  • Quote revenue: 20% goes to the dev until the dev has received 1.5Γ— the quote they seeded (poolQuote); everything else is deposited into the token's dividend contract as the holders' reward. After the cap, 100% goes to holders.
  • The remaining 79% is what each example uses differently.

Example A β€” Stake to mine

The 79% is a mining pool, emitted linearly to stakers. Stakers choose a lock period; a longer lock gets a higher weight multiplier.

Factory:

contract StakeMineVaultFactory is DirectLaunchVaultFactoryBase {
    uint16 constant RESERVE_BPS = 8000;   // 80% of supply must come to the vault
    uint16 constant DEV_VEST_BPS = 100;   // 1% of supply vests to the dev
    // MINING = RESERVE_BPS - DEV_VEST_BPS = 79% of supply

    function newVault(address token, address creator, uint8 launchKind, bytes calldata launchParams)
        external
        override
        onlyHub
        returns (address vault)
    {
        // 1. (p, d) = _decodeLaunchParams(launchKind, launchParams).
        // 2. Enforce the reserve: d.target == VAULT and
        //    d.amount == p.totalSupply * RESERVE_BPS / 10_000; otherwise revert.
        // 3. Enforce the quote: isQuoteTokenSupported(p.quoteToken); otherwise revert.
        // 4. Enforce the dividend: p.feeConfigs has a DIVIDEND slot whose dividendToken is the
        //    one this vault deposits into (e.g. the quote); otherwise revert.
        // 5. Read this factory's vaultData via _vaultDataFor(address(this), p.feeConfigs)
        //    (e.g. the stake tiers), and validate it.
        // 6. Deploy the vault deterministically from `token` (beacon proxy) and initialize it with:
        //    token, raw quote, dev = creator, devCap = p.poolQuote * 3 / 2 (1.5x the seed),
        //    devVest = p.totalSupply * DEV_VEST_BPS / 10_000, vestStart = block.timestamp,
        //    vestDuration = 30 days.
        // 7. Return the vault. Never call `token` here; it has no code yet.
    }

    // isQuoteTokenSupported / factorySpecVersion ("pl-v1") / vaultDataSchema /
    // tokenCreationPolicies / upgradeVaultImplementation (onlyGuardian): as in the base contract.
}

Vault:

contract StakeMineVault is DirectLaunchVaultBase, IStrategy {
    /// Quote revenue (the VAULT slot's share of the tax). Keep it light and never revert.
    receive() external payable {
        // 1. newRevenue = balance - accountedQuote (balance-delta accounting); return if 0.
        // 2. devShare = newRevenue * 20%, capped at (devCap - devPaid). If devShare > 0:
        //    devPaid += devShare; send devShare to `dev`.
        // 3. Deposit the rest (newRevenue - devShare) into the token's dividend contract
        //    (IDividend.deposit) as the holders' dividend reward.
        // 4. Dev vesting: vested = devVest * min(now - vestStart, vestDuration) / vestDuration;
        //    if vested > devReleased, send (vested - devReleased) tokens straight to `dev`
        //    (through the Token Dock) and update devReleased.
    }

    /// Permissionless poke for revenue that arrived without a call (e.g. an ERC-20 quote).
    function sync() external {
        // Same as receive(), steps 1-4.
    }

    /// IStrategy: the Portal hands the 80% reserve to the vault in the launch transaction.
    function initializeDistribution(address token, uint256 amount, bytes calldata, bytes32)
        external
        onlyHub
    {
        // Require token == taxToken, single use; pull `amount` with transferFrom and verify the
        // balance delta; deposit it into the Token Dock; emit FlapDistributionInitialized.
    }

    /// Stake `amount` of the launch token for `lockTier`
    /// (e.g. 0 = 30 days x1, 1 = 90 days x1.5, 2 = 180 days x2).
    function stake(uint256 amount, uint8 lockTier) external {
        // Accrue rewards; pull `amount` of the token from the caller; record the position
        // (amount, unlockAt, weight = amount * multiplier[lockTier]); add to totalWeight.
    }

    /// Claim mined tokens accrued so far.
    function claimRewards() external {
        // Accrue the emission: rewardPerWeight += emissionRate * elapsed / totalWeight;
        // pay the caller's pending tokens from the mining pool (withdraw from the Token Dock).
    }

    /// Withdraw a stake after its lock has expired.
    function unstake(uint256 positionId) external {
        // Require now >= unlockAt; claim pending rewards; remove the weight; return the tokens.
    }

    /// View: pending rewards of `user`.
    function pendingRewards(address user) external view returns (uint256) {
        // Same accrual as claimRewards, without state changes.
    }

    /// IDirectLaunchVault
    function description() public view override returns (string memory) {
        // Disclose: 20% of quote revenue to the dev until 1.5x the seed (show paid / cap), the
        // rest to holders as dividends; the dev's 1% vesting (released / total); and what
        // happens to the remaining reserve.
    }

    function vaultUISchema() public pure override returns (VaultUISchema memory) {
        // List every user-facing method above with its inputs and required approvals.
    }
}

Example B β€” Release the reserve as the market cap grows

The 79% is not mined. The vault posts fixed-price sell orders at rising market-cap levels: for example 1% of the supply at a $200K market cap, the next 1% at $300K, and so on. Tokens are released only when the market reaches each level, so the supply grows with the token's performance. The quote paid for these orders goes to holders as dividends, streamed gradually (for example over 7 days) rather than in one lump, so nobody can buy in just before a large fill and capture it.

The factory is the same as Example A, plus validation of the order ladder in vaultData (levels strictly increasing, total ≀ 79% of supply).

contract PriceLadderVault is DirectLaunchVaultBase, IStrategy {
    struct Order {
        uint256 marketCapUsd; // e.g. 200_000e18, 300_000e18, ...
        uint256 amount;       // e.g. 1% of supply each
        uint256 sold;
    }

    /// Quote revenue (the VAULT slot's share of the tax). Keep it light and never revert.
    receive() external payable {
        // 1. newRevenue = balance - accountedQuote (balance-delta accounting); return if 0.
        // 2. devShare = newRevenue * 20%, capped at (devCap - devPaid). If devShare > 0:
        //    devPaid += devShare; send devShare to `dev`.
        // 3. Deposit the rest (newRevenue - devShare) into the token's dividend contract
        //    (IDividend.deposit) as the holders' dividend reward.
        // 4. Dev vesting: vested = devVest * min(now - vestStart, vestDuration) / vestDuration;
        //    if vested > devReleased, send (vested - devReleased) tokens straight to `dev`
        //    (through the Token Dock) and update devReleased.
    }

    /// Permissionless poke for revenue that arrived without a call (e.g. an ERC-20 quote).
    function sync() external {
        // Same as receive(), steps 1-4.
    }

    /// IStrategy: the Portal hands the 80% reserve to the vault in the launch transaction.
    function initializeDistribution(address token, uint256 amount, bytes calldata, bytes32)
        external
        onlyHub
    {
        // Require token == taxToken, single use; pull `amount` with transferFrom and verify the
        // balance delta; deposit it into the Token Dock; emit FlapDistributionInitialized.
    }

    /// Buy from an open order whose market-cap level has been reached.
    function buy(uint256 orderId, uint256 minTokensOut) external payable {
        // Require the current market cap (pool price x supply, from the V2 pool) >= the order's
        // level; price = order.marketCapUsd / totalSupply; take the quote, send tokens from the
        // Token Dock, update order.sold. Do NOT deposit the quote into dividends at once: add it to
        // `streamPending` and re-base the stream (streamRate = (streamPending + unstreamed) /
        // STREAM_DURATION, e.g. 7 days; streamEnd = now + STREAM_DURATION).
    }

    /// Permissionless: move the part of the sale proceeds that has streamed so far into dividends.
    function dripDividends() public {
        // due = streamRate * (min(now, streamEnd) - lastDrip); lastDrip = now;
        // deposit `due` into the dividend contract (IDividend.deposit) for holders.
        // Also called from receive() / sync() so the stream advances with normal activity.
    }

    /// View: every order with its level, size, amount left, and whether it is open now.
    function orders() external view returns (Order[] memory) {
        // Return the ladder.
    }

    /// IDirectLaunchVault
    function description() public view override returns (string memory) {
        // Disclose: 20% of quote revenue to the dev until 1.5x the seed (show paid / cap), the
        // rest to holders as dividends; the dev's 1% vesting (released / total); and what
        // happens to the remaining reserve.
    }

    function vaultUISchema() public pure override returns (VaultUISchema memory) {
        // List every user-facing method above with its inputs and required approvals.
    }
}

Example C β€” Vault as a hook for many single-sided pools

The vault acts as a hook for PancakeSwap Infinity or Uniswap v4 pools. With the 79% reserve, it opens a virtual pool β€” priced by a custom curve inside the hook β€” against many supported quote tokens. No quote has to be bought to seed them: each pool is single-sided and backed by the reserve, so the token can be paired with any number of quotes.

The factory is the same as Example A, plus validation of the quote-token list and curve parameters in vaultData.

contract HookPoolsVault is DirectLaunchVaultBase, IStrategy {
    /// Quote revenue (the VAULT slot's share of the tax). Keep it light and never revert.
    receive() external payable {
        // 1. newRevenue = balance - accountedQuote (balance-delta accounting); return if 0.
        // 2. devShare = newRevenue * 20%, capped at (devCap - devPaid). If devShare > 0:
        //    devPaid += devShare; send devShare to `dev`.
        // 3. Deposit the rest (newRevenue - devShare) into the token's dividend contract
        //    (IDividend.deposit) as the holders' dividend reward.
        // 4. Dev vesting: vested = devVest * min(now - vestStart, vestDuration) / vestDuration;
        //    if vested > devReleased, send (vested - devReleased) tokens straight to `dev`
        //    (through the Token Dock) and update devReleased.
    }

    /// Permissionless poke for revenue that arrived without a call (e.g. an ERC-20 quote).
    function sync() external {
        // Same as receive(), steps 1-4.
    }

    /// IStrategy: the Portal hands the 80% reserve to the vault in the launch transaction.
    function initializeDistribution(address token, uint256 amount, bytes calldata, bytes32)
        external
        onlyHub
    {
        // Require token == taxToken, single use; pull `amount` with transferFrom and verify the
        // balance delta; deposit it into the Token Dock; emit FlapDistributionInitialized.
    }

    /// Open a single-sided TOKEN / `quote` pool on PCS Infinity or Uni v4, with this vault as hook.
    function openPool(address quote) external {
        // Require `quote` is supported and not opened yet; initialize the pool with this contract
        // as its hook and the custom-curve flags; record the curve state for `quote`.
    }

    /// Hook callback: price every swap with the custom curve instead of the AMM.
    function beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
        external
        returns (bytes4, BeforeSwapDelta, uint24)
    {
        // Only the pool manager; compute the output from the curve state for this quote; settle
        // tokens from the reserve (Token Dock) and take the quote; return the delta that replaces
        // the AMM swap.
    }

    /// Quote revenue from these pools goes through the same 20% / 80% split as receive().
    function collect(address quote) external {
        // Take the quote accumulated by the hook for `quote` and run the revenue split.
    }

    /// IDirectLaunchVault
    function description() public view override returns (string memory) {
        // Disclose: 20% of quote revenue to the dev until 1.5x the seed (show paid / cap), the
        // rest to holders as dividends; the dev's 1% vesting (released / total); and what
        // happens to the remaining reserve.
    }

    function vaultUISchema() public pure override returns (VaultUISchema memory) {
        // List every user-facing method above with its inputs and required approvals.
    }
}

Checklist

  • newVault is restricted to the Portal (onlyHub).
  • newVault rejects unsupported launchKind values.
  • If the vault relies on the reserve, newVault checks distribution.target == VAULT and the exact distribution.amount, and reverts otherwise.
  • Launch validation happens before the Vault is deployed.
  • The Vault is deterministic (one Vault per token) and has code.
  • newVault never calls the token (it has no code yet).
  • isQuoteTokenSupported matches newVault.
  • vaultDataSchema matches the decoded vaultData.
  • factorySpecVersion returns "pl-v1" (starts with pl).
  • The factory has no upgrade path; the Vault upgrade path is Guardian-only.
  • The Vault initializes once and disables initializers in its constructor.
  • The Vault recognizes revenue by balance delta, not by raw balance.
  • receive() cannot revert.
  • Privileged Vault functions also accept the Guardian.
  • description() discloses every fee and recipient.
  • vaultUISchema() lists every user-facing method and its approvals.
  • If the Vault takes the remainder, initializeDistribution is onlyHub, single-use, and verifies the pulled balance.