Build an Indexer
This page explains how to track every token launched on the bonding curve protocol: which events to index, and which reads to call when you need state that events do not carry.
An indexer for the bonding curve protocol listens to two contracts per chain:
| Contract | What it emits |
|---|---|
Portal | Token creation and configuration, trades on the curve, and DEX migration |
VaultPortal | The vault attached to a tax token, and vault / factory risk levels |
VaultPortal launches a tax token through Portal, so a token launched with a vault emits events from both contracts in the same transaction. Addresses for each chain are in Deployed Contract Addresses β Bonding curve protocol.
All events below are emitted by the Portal or VaultPortal proxy address. Unless noted, their fields are not indexed: decode them from the log data.
1. Index from events
Token creation (Portal)
A launch emits several events in one transaction. Always index TokenCreated, then enrich the token record with the other events of the same transaction for the same token. Older tokens do not emit every event; apply the default listed when an optional event is missing.
| Event | Required | Description Β· default when missing |
|---|---|---|
TokenCreated(uint256 ts, address creator, uint256 nonce, address token, string name, string symbol, string meta) | Required | Emitted for every launch. meta is the IPFS CID of the token metadata (Token metadata). nonce is unique per Portal. |
TokenCurveSetV2(address token, uint256 r, uint256 h, uint256 k) | Optional | Curve parameters (r virtual quote reserve, h virtual token reserve, k = virtual liquidityΒ²). Emitted for every launch since v4.7.0. Missing β read TokenCurveSet. |
TokenCurveSet(address token, address curve, uint256 curveParameter) | Optional | Legacy curve format. Missing β the first CurveType (legacy curve, curveParameter = 16 ether). |
TokenDexSupplyThreshSet(address token, uint256 dexSupplyThresh) | Optional | Circulating supply at which the token migrates. Missing β the first DexThreshType (6.67e8 ether). |
TokenQuoteSet(address token, address quoteToken) | Optional | Quote token of the curve. Missing β native gas token (address(0)). |
TokenMigratorSet(address token, uint8 migratorType) | Optional | Pool shape it migrates into: 0 V3, 1 V2, 2 Uniswap V4, 3 PancakeSwap Infinity CL. Missing β V3_MIGRATOR (0). |
TokenDexPreferenceSet(address token, uint8 dexId, uint8 lpFeeProfile) | Optional | DEX it migrates to (chain-specific, see DEX destination) and V3 fee tier. Missing β DEX0 with the STANDARD fee profile. |
TokenVersionSet(address token, uint8 version) | Optional | Token implementation (Token versions). Missing β legacy version. |
FlapTokenTaxSet(address token, uint256 tax) | Optional | Tax rate in bps; for asymmetric V3 tokens it carries max(buy, sell). Missing β 0 (non-tax token). |
FlapTokenAsymmetricTaxSet(address token, uint256 buyTax, uint256 sellTax) | Optional | Buy and sell tax rates of a Tax Token V3. Missing β buy and sell both equal FlapTokenTaxSet.tax. |
TokenExtensionEnabled(address token, bytes32 extensionID, address extensionAddress, uint8 version) | Optional | The launch carried extension data; extensionID tells you how to read it (Extension encoding). Missing β no extension. |
FlapTokenStaged(uint256 ts, address creator, address token) | Optional | The token is staged (two-step launch): its address is fixed but it is not created yet. Missing β not a staged launch. |
Vault (VaultPortal)
| Event | Required | Description Β· default when missing |
|---|---|---|
FlapTaxVaultTokenCreated(address indexed token, address indexed vault, address indexed vaultFactory) | Required if you show vaults | Emitted once when a tax token is launched with a vault (newTaxTokenWithVault / newTokenV6WithVault / newTokenV7WithVault). vaultFactory is the vault's type key. Missing β the token has no VaultPortal vault. |
FlapTaxVaultFactoryRegistered(address factory, bool enabled, bool official, uint8 riskLevel) | Optional | A vault factory's full current configuration; overwrite your stored value on every emission. Missing β the factory is unregistered (UNVERIFIED). |
AuditReportSubmitted(address indexed taxToken, address indexed auditor, uint8 riskLevel, string ipfsCid) | Optional | A token's own risk level changed after an independent audit. Missing β the token's risk level is inherited from its factory. |
A token inherits its factory's risk level without any event on the token. A token launched from a LOW_RISK factory is LOW_RISK immediately, but only FlapTaxVaultTokenCreated and the factory's FlapTaxVaultFactoryRegistered tell you so. Keep a token β vaultFactory map and a factory β riskLevel map, and let a later AuditReportSubmitted override the token's value.
Risk levels: 0 UNVERIFIED Β· 1 LOW_RISK Β· 2 LOW_MEDIUM_RISK Β· 3 MEDIUM_RISK Β· 4 HIGH_RISK. Do not use FactoryAuditReportSubmitted for a factory's effective risk level: it records a report, not the level new tokens get.
Trading and progress (Portal)
| Event | Required | Description Β· default when missing |
|---|---|---|
TokenBought(uint256 ts, address token, address buyer, uint256 amount, uint256 eth, uint256 fee, uint256 postPrice) | Required for trades | A buy on the curve: tokens out, quote in, fee, and the price after the trade. |
TokenSold(uint256 ts, address token, address seller, uint256 amount, uint256 eth, uint256 fee, uint256 postPrice) | Required for trades | A sell on the curve. |
FlapTokenCirculatingSupplyChanged(address token, uint256 newSupply) | Optional | Circulating supply after a trade. Missing β read circulatingSupply from getTokenV8Safe. |
FlapTokenProgressChanged(address token, uint256 newProgress) | Optional | Progress towards migration (1e18 = 100%). Missing β read progress from getTokenV8Safe. |
Agent ID gate and buy cap (Portal)
A launch can be Agent ID-gated: while the token is on the curve, only wallets holding at least one NFT from a chosen ERC-721 collection (the "Agent ID") can buy or sell it, and each wallet can buy at most 2% of the supply. In short:
- The gate is checked on
tx.origin, collection-wide (any NFT of the collection counts). It applies only to curve trades: transfers are free, and after migration the DEX is open to everyone. - Gated launches get a buy cap of 200 bps of the max supply per
tx.origin. What counts against it ismax(netBought, balanceOf(origin)): curve buys add tonetBoughtand curve sells subtract from it (never below zero), while tokens received by transfer count through the balance. A buy above the remaining allowance is filled up to the cap and the excess input is refunded. Sells are never capped. - Only collections on the Portal's whitelist can be used, and an admin can remove a token's gate (never add or change one) after launch.
- The gate is set at launch through the extension fields; see Extension encoding.
| Event | Required | Description Β· default when missing |
|---|---|---|
FlapNFTGated(address token, address nft) | Required for gated tokens | Emitted once at launch: the token is Agent ID-gated by the collection nft. Missing β the token is not gated. |
FlapTokenMaxBuyPerOriginSet(address indexed token, uint16 bps, uint256 maxBuyAmount) | Optional | A per-origin buy cap was set (200 bps for gated launches). Emitted only when bps > 0. Missing β no cap (read maxBuyPerOrigin to be sure; an admin can also cap a non-gated token). |
FlapBuyQuotaUpdated(address indexed token, address indexed origin, uint256 bought, uint256 remaining) | Optional | After every curve buy on a capped token: what counts against origin's cap and what it can still buy. Not emitted on sells or transfers, which also move the quota. Missing β read buyQuotaOf. |
FlapNFTGateUpdated(address token, address oldNft, address newNft) | Optional | An admin removed the token's gate (newNft is always address(0)): treat it as non-gated from then on; its cap becomes a plain holding cap. Missing β the gate is unchanged. |
FlapNFTWhitelistUpdated(address nft, bool allowed) | Optional | A collection was added to or removed from the whitelist of collections new launches may use. |
Migration to DEX (Portal)
| Event | Required | Description Β· default when missing |
|---|---|---|
LaunchedToDEX(address token, address pool, uint256 amount, uint256 eth) | Required | The token reached its supply threshold and its liquidity was added to a DEX: amount tokens and eth quote. For V2 / V3 migrators pool is the pool; for the two CL migrators it is the Uniswap V4 PoolManager or the PancakeSwap Infinity vault, not the pool. |
FlapTokenCLPoolCreated(address token, bytes32 poolId, uint160 sqrtPriceX96) | Required for CL migrators | The real pool id for V4_UNI_MIGRATOR and PCS_INFINITY_CL_MIGRATOR, and its initial price. Missing β the token did not migrate to a CL pool; use LaunchedToDEX.pool. |
DEX destination
You know where a token will migrate as soon as it is created: TokenDexPreferenceSet.dexId picks the DEX and TokenMigratorSet.migratorType the pool shape.
| Chain | DEX0 | DEX1 | DEX2 |
|---|---|---|---|
| BNB Chain | PancakeSwap | β | β |
| Robinhood Chain | Uniswap | PancakeSwap | β |
| X Layer | PotatoSwap | β | β |
| Monad | Uniswap | PancakeSwap | Monday |
migratorType | Destination |
|---|---|
V3_MIGRATOR (0) | V3 pool on the DEX selected by dexId |
V2_MIGRATOR (1) | V2 pool on the DEX selected by dexId |
V4_UNI_MIGRATOR (2) | Uniswap V4 (dexId is not used) |
PCS_INFINITY_CL_MIGRATOR (3) | PancakeSwap Infinity CL (dexId is not used; BNB Chain only) |
dexId maps to a different DEX on each chain (on BNB Chain DEX0 is PancakeSwap, on Robinhood Chain it is Uniswap). Never hard-code one mapping across chains. Tax tokens always migrate to a V2 pool.
Suggested flow
- Listen to
TokenCreatedonPortalandFlapTaxVaultTokenCreatedonVaultPortal. - For each new token, collect the optional creation events of the same transaction and apply the defaults above for any that are missing.
- Store the token record, its metadata CID and (if any) its
vaultandvaultFactory. - Follow
TokenBought/TokenSoldfor trades, andLaunchedToDEX(plusFlapTokenCLPoolCreated) for migration. - Keep factory and token risk levels from
FlapTaxVaultFactoryRegisteredandAuditReportSubmitted. - Mark gated tokens from
FlapNFTGated(and un-mark them onFlapNFTGateUpdated).
2. Read state from the chain
When you need the current state of one token, or want to fill gaps in your indexed data, read it directly. These calls are views; batch them with Multicall.
Token state: getTokenV8Safe
Portal.getTokenV8Safe(token) returns the whole state of a token in one call. Enum fields come back as uint8, so a newer enum value never breaks your ABI decoding.
struct TokenStateV8Safe {
uint8 status; // TokenStatus
uint256 reserve; // quote reserve held by the curve
uint256 circulatingSupply;
uint256 price; // current price (quote per token, 18 decimals)
uint8 tokenVersion; // TokenVersion
uint256 r; // curve parameters
uint256 h;
uint256 k;
uint256 dexSupplyThresh; // circulating supply at which it migrates
address quoteTokenAddress; // address(0) = native gas token
bool nativeToQuoteSwapEnabled;
bytes32 extensionID; // bytes32(0) = no extension
uint256 buyTaxRate; // bps, 0 for non-tax tokens
uint256 sellTaxRate;
address pool; // DEX pool, address(0) before migration
uint256 progress; // towards migration, 1e18 = 100%
uint8 lpFeeProfile; // V3LPFeeProfile
uint8 dexId; // DEXId
}
function getTokenV8Safe(address token) external view returns (TokenStateV8Safe memory state);status: 0 Invalid (unknown token) Β· 1 Tradable (on the curve) Β· 4 DEX (migrated) Β· 5 Staged. Values 2 and 3 are obsolete.
getTokenV8 / getTokenV8Safe are available on BNB Chain and BNB Chain Testnet. On other chains use getTokenV7, which returns the same fields with a single taxRate instead of buyTaxRate / sellTaxRate (and enums as Solidity enums). The older getTokenV2βgetTokenV6 remain for existing integrations; see the archived reference for their layouts.
Token versions
tokenVersion (from getTokenV8Safe or TokenVersionSet) tells you which token implementation a token uses:
| Value | Name | Description |
|---|---|---|
| 0, 1 | TOKEN_LEGACY_MINT_NO_PERMIT | Legacy token without permit (both values are the same implementation) |
| 2 | TOKEN_V2_PERMIT | Standard token with EIP-2612 permit |
| 3 | TOKEN_GOPLUS | GoPlus-integrated token (not used in current deployments) |
| 4 | TOKEN_TAXED | Tax Token V1 (legacy) |
| 5 | TOKEN_TAXED_V2 | Tax Token V2 (legacy) |
| 6 | TOKEN_TAXED_V3 | Tax Token V3: asymmetric buy/sell rates. See Bonding Curve Tax Token V3 |
| 7 | TOKEN_V3_PERMIT | Non-tax TokenV3 with permit, for Uniswap V4 / PancakeSwap Infinity CL migration |
Tax token details: TaxTokenHelper
For a tax token, TaxTokenHelper.getTaxTokenInfoV2(token) returns its tax split, cumulative statistics, dividend token and vault in one call. It is what the Flap website uses.
struct MarketingWalletInfo {
address addr; // the marketing wallet or vault receiving revenue
address factory; // vault factory, zero if not a Flap Vault
uint8 riskLevel; // 0 UNVERIFIED Β· 1 LOW Β· 2 LOW_MEDIUM Β· 3 MEDIUM Β· 4 HIGH
bool isOfficialVault;
bool isVault; // registered through VaultPortal
bool isAIConsumer; // uses the Flap AI Oracle
}
struct TaxTokenInfoV2 {
uint16 marketBps; // tax split, in bps of the tax
uint16 deflationBps;
uint16 lpBps;
uint16 dividendBps;
uint16 buyTaxRate; // bps
uint16 sellTaxRate; // bps
uint256 burntTokenAmount; // cumulative statistics
uint256 totalQuoteSentToDividend;
uint256 totalQuoteAddedToLiquidity;
uint256 totalTokenAddedToLiquidity;
uint256 totalQuoteSentToMarketing;
address dividendToken; // address(0) = native gas token
address quoteToken;
uint256 minimumShareBalance; // minimum balance to earn dividends
MarketingWalletInfo vaultInfo;
}
function getTaxTokenInfoV2(address taxToken) external view returns (TaxTokenInfoV2 memory info);
function getDividendInfo(address taxToken, address user) external view returns (uint256 claimed, uint256 pending);vaultInfo.riskLevel is always the live value, whether it was inherited from the factory or set by the token's own audit, so it answers "is this token low risk?" without any indexing. For Tax Token V1, only marketBps (always 10000), the tax rate, vaultInfo.addr, quoteToken and totalQuoteSentToMarketing are filled in. The helper's address per chain is in Deployed Contract Addresses.
Vault: VaultPortal.tryGetVault
struct VaultInfo {
address vault;
address vaultFactory; // zero if unknown
string description; // human-readable, for display only
bool isOfficial;
uint8 riskLevel;
}
function tryGetVault(address taxToken) external view returns (bool found, VaultInfo memory info);
function vaultFactories(address factory) external view returns (bool enabled, bool official, uint8 riskLevel, bytes29 reserved);tryGetVault never reverts (found = false when the token has no vault), so it is safe in batch calls. description is not a stable format: read typed values from the vault itself, branching on vaultFactory. For one vault type worked end to end, see Index & Query a Stocks Vault.
Agent ID gate and buy cap
// The token's Agent ID collection; address(0) = not gated.
function nftGate(address token) external view returns (address nft);
// The token's per-origin buy cap; bps == 0 (and maxBuyAmount == 0) = no cap.
function maxBuyPerOrigin(address token) external view returns (uint16 bps, uint256 maxBuyAmount);
// What counts against `origin`'s cap and what it can still buy; remaining == type(uint256).max = no cap.
function buyQuotaOf(address token, address origin) external view returns (uint256 bought, uint256 remaining);
// Whether a collection may gate new launches.
function nftWhitelisted(address nft) external view returns (bool allowed);- A token is gated only while it is on the curve (
status == 1) andnftGate(token) != address(0). - Whether a user may trade is
IERC721(nft).balanceOf(user) >= 1; check it yourself.quoteExactInputenforces the cap but not the NFT check, and must be called withfrom = user(the cap is read ontx.origin). nftGateand the other reads revert on Portals older thanv5.27.0; call them in atry/catch.- Trade errors to handle:
NFTGatedOnly(address wallet)(no NFT),BuyCapExceeded(address origin, uint256 held, uint256 cap)(cap reached).
How the gate and the cap work, and how to trade a gated token: Agent ID Gate.
Token metadata
TokenCreated.meta, and the token's own metaURI(), return the IPFS CID of a metadata JSON:
{
"description": "OKIE,OKIE",
"image": "bafkreibwjuzzns6yf4wytfr5nk6vujb4yja4iejff2k76nshn2z5jkmiy4",
"website": null,
"twitter": null,
"telegram": null,
"creator": "0x7F403F924dDE32bFd4D4432E3996291aeFCe2f89"
}image is itself a CID. Fetch both through the Flap gateway, https://flap.mypinata.cloud/ipfs/<CID>; see Flap IPFS Gateway.
Migration destination after the fact
Once status is 4 (DEX), pool from getTokenV8Safe is the pool for V2 / V3 migrators. For the CL migrators use the poolId from FlapTokenCLPoolCreated.
3. Extension encoding
A launch carries optional features in two fields of its launch parameters: bytes32 extensionID and bytes extensionData. An indexer reads them back from TokenExtensionEnabled (or extensionID in getTokenV8Safe) to know which features a token was launched with.
Extension IDs
| Name | extensionID / extensionId | Payload (extensionData) |
|---|---|---|
| Extension array | keccak256("flap.extensions.v1") = 0xc87a159bf445e83477134d6374216c321695c2f3e23ee3351c8d3df6ec740253 | abi.encode(Extension[]): a list of the extensions below |
| Agent ID gate | keccak256("flap.extension.nft_gated.v1") = 0x9c312f81d9a1b68ba5472ca93c5447d57f897fbf4a8c468b315a67fe8016d1ab | abi.encode(address nft): the Agent ID collection |
| Signed curve price | keccak256("flap.curve.auth.v2") = 0x0f1f338177d85790dd81a6e795b59ae3a205cb498e034240ce2261bf753f13a4 | abi.encode(QuoteCurveAuthV2): a Flap-signed price that lets a token launch with a quote token not yet on the allowlist |
| Signed curve price, v1 (retired) | keccak256("flap.curve.auth.v1") = 0x3526f6a0e790306d64c896eeda5ec66052ea17889e20632ff0cf512923153dad | abi.encode(uint8 curveType, uint256 price, uint256 deadline, bytes signature). No longer accepted, but common in historical launches |
struct Extension {
bytes32 extensionId;
bytes extensionData;
}
struct QuoteCurveAuthV2 {
uint256 price; // signed USD reference price (18 decimals) the curve is derived from
uint256 deadline; // signature expiry (unix seconds)
bytes signature; // EIP-712 signature by a Flap curve-auth signer
}Two forms
- Array form (recommended).
extensionID = keccak256("flap.extensions.v1")andextensionData = abi.encode(Extension[]). Several extensions can be combined, for example an Agent ID gate together with a signed curve price. For each extension the first entry with itsextensionIdis used. - Single form (legacy).
extensionIDis the extension's own ID andextensionDatais its payload directly, so only one extension per launch.
extensionID == bytes32(0) means no extension.
// Array form: Agent ID gate
Extension[] memory exts = new Extension[](1);
exts[0] = Extension({extensionId: NFT_GATED_EXTENSION_ID, extensionData: abi.encode(nft)});
params.extensionID = EXTENSION_ARRAY_ID;
params.extensionData = abi.encode(exts);
// Single form: the same gate
params.extensionID = NFT_GATED_EXTENSION_ID;
params.extensionData = abi.encode(nft);Decoding an extension
To find one extension's payload (this is what the Portal does):
import { decodeAbiParameters, keccak256, toHex } from "viem";
const ARRAY_ID = keccak256(toHex("flap.extensions.v1"));
function findExtension(extensionID: `0x${string}`, extensionData: `0x${string}`, wantedId: `0x${string}`) {
if (extensionID !== ARRAY_ID) {
// single form: the whole payload belongs to extensionID
return extensionID === wantedId ? extensionData : null;
}
const [exts] = decodeAbiParameters(
[{ type: "tuple[]", components: [{ name: "extensionId", type: "bytes32" }, { name: "extensionData", type: "bytes" }] }],
extensionData,
);
return exts.find((e) => e.extensionId === wantedId)?.extensionData ?? null;
}TokenExtensionEnabled only carries extensionID, not the payload. For the Agent ID gate you do not need to decode anything: index FlapNFTGated or read nftGate(token). To read a launch's full extensionData, decode the launch transaction's input.