Build on the Bonding Curve Protocol
On the bonding curve protocol you build a Tax Vault: a contract that receives a tax token's revenue and decides what happens to it. A vault comes with a vault factory, which VaultPortal calls to create one vault per token at launch (see Launch Tokens → Tax token, with vault), and optionally a custom UI shown on the token's page.
This page covers the contracts, the UI and examples.
1. Contracts
1.1 The interfaces
A vault inherits VaultBaseV3; a factory inherits VaultFactoryBaseV2. Copy both, with the files they import, from FlapVaultExample/src/flap. The full NatSpec there is the normative spec; below is the surface you implement.
Vault: VaultBaseV3
abstract contract VaultBase {
/// Portal and Flap Guardian of the current chain (BNB Chain, BNB Testnet, Robinhood, Base, X Layer, ...).
function _getPortal() internal view returns (address);
function _getGuardian() internal view returns (address);
/// A human-readable, dynamic description of the vault's current state.
function description() public view virtual returns (string memory);
}
abstract contract VaultBaseV2 is VaultBase {
/// The vault's user-facing methods, for the generic UI (see 1.6).
function vaultUISchema() public pure virtual returns (VaultUISchema memory schema);
}
abstract contract VaultBaseV3 is VaultBaseV2 {
/// The currency this vault's revenue arrives in: address(0) = native, otherwise the ERC-20.
/// MUST equal the token's quote token, never change, and never revert.
function vaultQuoteToken() public view virtual returns (address quoteToken);
function vaultSpecVersion() public pure virtual returns (string memory) { return "v3"; }
}What a vault must do:
receive()takes the revenue. Native revenue arrives as a value transfer. ERC-20 revenue is transferred first, then the vault is woken with an empty, zero-value call toreceive(). So:- recognize revenue by balance delta: keep a baseline (
accountedQuote), treatbalance - accountedQuoteas new revenue, and decrement the baseline on every outflow (forgetting this freezes the vault: it stops seeing new revenue, with no error); - a wake with no new revenue is a silent no-op, since a vault can be woken more than once, and by anyone;
- keep
receive()light (account, emit, return) and never rely on reverting it: the funds arrive anyway, and a heavyreceive()makes the token's tax dispatch fail. Put swaps, buybacks and staking behind separate functions.
- recognize revenue by balance delta: keep a baseline (
vaultQuoteToken(): set once at initialization from thequoteTokenthe factory receives.description(): the current state, in plain words, and every fee or commission the vault takes (see 1.3).vaultUISchema(): every method a user or keeper calls.- The Guardian: if the vault has permissioned functions (for example a buyback that must not be public because it can be sandwiched), the Guardian (
_getGuardian()) must hold the same permission, and no one but the Guardian may remove it. With OpenZeppelinAccessControl, overriderevokeRoleto refuse the Guardian.
uint256 public accountedQuote; // recognized, not yet spent
receive() external payable { _sync(); }
function sync() external { _sync(); } // permissionless: recovers revenue that arrived without a wake
function _sync() internal {
address q = vaultQuoteToken();
uint256 bal = q == address(0) ? address(this).balance : IERC20(q).balanceOf(address(this));
if (bal <= accountedQuote) return; // nothing new: no-op
uint256 revenue = bal - accountedQuote;
accountedQuote = bal;
_onRevenue(revenue); // light work only
}
// Every payout: accountedQuote -= amount; in the same transaction.Factory: VaultFactoryBaseV2
interface IVaultFactory {
/// Called by VaultPortal during the launch. `taxToken` is the PREDICTED address: the token
/// does not exist yet. `creator` is the wallet that called VaultPortal.
function newVault(address taxToken, address quoteToken, address creator, bytes calldata vaultData)
external returns (address vault);
/// Must be truthful: VaultPortal rejects a launch whose quote the factory does not support.
function isQuoteTokenSupported(address quoteToken) external view returns (bool supported);
}
abstract contract VaultFactoryBaseV2 is IVaultFactory, IVaultFactoryValidationV2 {
/// The layout of `vaultData`, for the launch form (see 1.6).
function vaultDataSchema() public pure virtual returns (VaultDataSchema memory schema);
/// Pre-launch check. Override `_validateBeforeLaunch`, not this.
function onBeforeLaunch(bytes calldata validationData) external view returns (bool success, string memory reason);
function _validateBeforeLaunch(LaunchValidationDataV1 memory data)
internal view virtual returns (bool success, string memory reason);
/// UI hints that mirror `_validateBeforeLaunch`. Informational only.
function tokenCreationPolicies() public pure virtual returns (FactoryPolicy[] memory policies);
/// Which spec generation the factory implements. Override it: return "v2.4".
function factorySpecVersion() public pure virtual returns (string memory);
function _getVaultPortal() internal view returns (address);
function _getGuardian() internal view returns (address);
}
struct LaunchValidationDataV1 { // the launch, normalized from newTokenV6WithVault / newTokenV7WithVault
TokenVersion tokenVersion;
address quoteToken;
uint16 buyTaxRate;
uint16 sellTaxRate;
uint16 vaultBps; // the vault's share (mktBps)
uint16 deflationBps;
uint16 dividendBps;
uint16 lpBps;
address dividendToken;
uint256 minimumShareBalance;
}What a factory must do:
newVault: accept calls from_getVaultPortal()only, decodevaultData, deploy and initialize the vault (withquoteToken), and return it. Never calltaxToken: it has no code yet._validateBeforeLaunch: return(false, reason)for any launch your vault does not support (token version, quote, tax rates, shares, dividend token). VaultPortal calls it withstaticcallbefore creating the vault and reverts with yourreason.factorySpecVersion(): return"v2.4". VaultPortal then runsonBeforeLaunchand, afternewVault, checks that the vault'svaultQuoteToken()equals the launch's quote token. The base contract's default is an older"v2.2".isQuoteTokenSupported: answer for the quotes your vault really handles. VaultPortal checks it on every launch, native included.- Computed dividend token (optional): if the dividend token can only be known once the tax token's address is (for example its LP pair), also implement
IVaultFactoryDividendV23.resolveDividendToken(predictedToken, launchVersion, launchParams). Launches then passdividendToken = MAGIC_DIVIDEND_COMPUTED(0xC0Dec0dec0DeC0Dec0dEc0DEC0DEC0DEC0DEC0dE) and VaultPortal asks your factory for the real address. Guide: LP or child token as the dividend token.
1.2 Beacon proxy (required)
Build every vault behind a beacon proxy, with the Flap Guardian as the only upgrade authority. This is required for the low-risk badge, so plan for it from the start.
Why: an audit reads the code but cannot catch every issue that only shows in production. With a beacon, Flap can upgrade the implementation of every vault of your factory at once to fix a bug, without asking any token creator to migrate. A vault that is not upgradeable stays broken forever.
- Implementation: an upgradeable contract (OpenZeppelin
Initializableand the…Upgradeablevariants) that calls_disableInitializers()in its constructor and sets all state ininitialize(...)(initializer). - Factory constructor: deploy the implementation and an
UpgradeableBeaconpointing to it; the factory owns the beacon. newVault: deploy aBeaconProxy(beacon, abi.encodeCall(Vault.initialize, (...))).- Upgrade authority: the factory exposes
upgradeVaultImplementation(address)andlockVaultUpgrades(), both callable only by the Guardian, and nothing else can upgrade: no owner, proxy admin, upgrader role, deployer key or multisig.lockVaultUpgrades()renounces the beacon, which makes the vaults immutable for good.
contract MyVaultFactory is VaultFactoryBaseV2 {
address public immutable beacon;
constructor() {
beacon = address(new UpgradeableBeacon(address(new MyVaultUpgradeable())));
}
function newVault(address taxToken, address quoteToken, address creator, bytes calldata vaultData)
external override returns (address vault)
{
require(msg.sender == _getVaultPortal(), "Only VaultPortal");
(uint256 param) = abi.decode(vaultData, (uint256));
vault = address(new BeaconProxy(
beacon, abi.encodeCall(MyVaultUpgradeable.initialize, (taxToken, quoteToken, creator, param))
));
}
function upgradeVaultImplementation(address impl) external {
require(msg.sender == _getGuardian(), "Only Guardian");
UpgradeableBeacon(beacon).upgradeTo(impl);
}
function lockVaultUpgrades() external {
require(msg.sender == _getGuardian(), "Only Guardian");
UpgradeableBeacon(beacon).renounceOwnership();
}
// ...plus isQuoteTokenSupported, vaultDataSchema, factorySpecVersion ("v2.4") and _validateBeforeLaunch (see 1.1).
}The factory itself stays immutable: to change it, deploy a new factory (which needs its own audit). The full reference, with tests for the upgrade and lock paths, is FreeCoinV3Beacon.sol and the beacon case study.
1.3 Commission
A vault may keep a commission on the revenue it receives. Whatever it takes must be stated in description(). The recommended rate scales with the token's tax rate, so the commission works out to about 0.06% of the traded volume at any tax rate:
| Token tax rate | Commission (of the revenue the vault receives) |
|---|---|
| 1% or less | 6% |
| 2% | 3% |
| 3% | 2% |
| 10% | 0.6% |
// taxRateBps: read once from the token, ITaxToken(taxToken).taxRate() (the higher of the buy and sell rates)
uint256 fee = taxRateBps <= 100 ? revenue * 600 / 10_000 : revenue * 6 / taxRateBps;Flap's own production vaults use this formula. A different formula is possible, but the audit will ask you to justify it.
1.4 Audit and the low-risk badge
Anyone can deploy a factory and launch with it: VaultPortal is permissionless. Until your factory passes Flap's audit, its vaults are shown with a risk warning. Passing the audit gives them the low-risk badge on Flap and on partner terminals.
Contact the Flap team to submit your code. The process:
- Prepare. The vault and factory follow this page, the vaults are behind the beacon with the Guardian as the only upgrader, and your fork tests pass. At minimum: the factory creates a vault through
newTokenV6WithVault; a buy on the bonding curve and a dispatch reach the vault; after the token migrates to the DEX, a sell and a dispatch reach it again; the vault's own actions work and refuse bad calls. Run theflap-vault-spec-checkeragent skill first and fix every failure it reports. - Submit and pay. Send the code to the Flap team. The audit fee is paid on-chain through
VaultPortal.payAudit(orderId)with the order ID you receive: 600 USDT on BNB Chain today (approveVaultPortalfor the USDT first). It is paid once per factory. - Review rounds. The auditors send findings and you fix them. It usually takes a few rounds: we ask for changes until every issue is resolved. The fee covers the whole review, not one pass, so you do not pay again for each round.
- Badge. Once the audit passes, Flap records the result on-chain and the factory's vaults show as low risk.
You can launch before the audit: since the vaults are behind the beacon, a finding can still be fixed by upgrading them. It is easier to fix things before launch, though.
1.5 Use Flap infrastructure
Before writing your own backend, check the shared services vaults can call: scheduled callbacks (Trigger Service), AI decisions (AI Oracle), X post checks (X Verifier) and MEV-protected swaps (VaultEX). See Flap Infrastructures.
1.6 UI schemas
vaultDataSchema() (factory) and vaultUISchema() (vault) describe your contracts so Flap can render a generic UI for them: a launch form that builds vaultData, and a vault page that lists your methods. They use the structs in IVaultSchemasV1.sol:
function vaultDataSchema() public pure override returns (VaultDataSchema memory s) {
s.description = "Gives the vault's revenue to callers of claim(), once per address, with a cooldown.";
s.fields = new FieldDescriptor[](2);
s.fields[0] = FieldDescriptor("maxReward", "uint256", "Maximum reward per claim", 18);
s.fields[1] = FieldDescriptor("cooldown", "uint256", "Cooldown between claims, in seconds", 0);
s.isArray = false; // vaultData = abi.encode(uint256, uint256)
}Set isArray = true when vaultData is an array of these tuples (for example a list of (recipient, bps)). Field types are Solidity ABI types, plus "time" (a timestamp, shown as a date) and "msg.value" (the native amount sent with a write). decimals scales numbers for display and input. Write methods can list approvals ("taxToken" or "lpToken") that the UI executes before the call. The full reference, with worked examples, is the UI Schema Reference.
The schemas are still required, and the generic UI still works. We strongly recommend building a custom UI as well: a generic form can only list your methods, while a custom UI can explain the mechanism, show the numbers that matter and guide the user through each action. See 2. UI.
2. UI
A vault shows up on Flap through two UI components, one before the launch and one after it. This section explains what each one is for and how they connect. To build them, follow the Vault UI guide in the flap-vault-component-template.
It works like the Pool Launch UI, simplified: there is no Launcher stage, so the launch page itself creates the token and its vault.
2.1 Overview
| UI component | Page on Flap | Purpose |
|---|---|---|
| 1. Vault Factory Config | /launch?vaultfactory={vault factory address}&chain=bnb&lang=en | The vault part of the token's launch page. The creator fills in the fields your Vault Factory needs, and Flap encodes them as vaultData and sends the launch. |
| 2. Tax info | /bnb/{token address}/taxinfo | Shown on the launched token. It shows the token's vault: its tax revenue and the actions it offers its users, such as claims. |
Each component shows data from its own contract, the Vault Factory or the vault. Flap prepares every transaction, and the user confirms it in their wallet. Without a custom UI, Flap renders a generic form from the factory's vaultDataSchema() and a generic vault page from the vault's vaultUISchema() (see 1.6 UI schemas).
2.2 Launching a token with your vault
- Launch with your vault. The launch page (
/launch?vaultfactory={vault factory address}&chain=bnb&lang=en) opens with your Vault Factory selected. The creator fills in the token and your Vault Factory Config, then launches. One transaction creates the token and its vault. - The token goes live. The token has its token page at
/bnb/{token address}. Its trades pay tax, and the vault receives its share of the revenue. - Holders use the vault on the tax info page. Your Vault UI is shown at
/bnb/{token address}/taxinfo, with the vault's risk status from Flap's audit at the top.
On BNB Chain Testnet, use chain=bnb-testnet on the launch page; the token pages are under /bnb-testnet/ instead of /bnb/.
3. Examples
| Example | What it shows |
|---|---|
| FreeCoin V3 (tests) | The reference vault: beacon proxy with Guardian-only upgrades, VaultBaseV3, balance-delta accounting for native and ERC-20 revenue, both schemas. Start here. |
| FreeCoin beacon (BNB Chain fork tests) | Fork tests for the beacon surface: the factory accepts only VaultPortal, the initializer is locked, only the Guardian can upgrade or lock, and the schemas are complete. FlapBSCFixture.sol wires the live Portal and VaultPortal for your own launch and dispatch tests. |
| LP or child token as the dividend token | A factory that computes the dividend token with resolveDividendToken. |
| Basket token for several dividend assets | Paying holders in several assets through the single dividend token. |
| Template UIs | example (deposit, approve, claim, oracle), dex-listed-example (an action available only after listing), action-gallery-example (every button state), community-buyback-example, flapixel-example (an NFT vault). |