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:

ContractWhat it emits
PortalToken creation and configuration, trades on the curve, and DEX migration
VaultPortalThe 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.

EventRequiredDescription Β· default when missing
TokenCreated(uint256 ts, address creator, uint256 nonce, address token, string name, string symbol, string meta)RequiredEmitted 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)OptionalCurve 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)OptionalLegacy curve format. Missing β†’ the first CurveType (legacy curve, curveParameter = 16 ether).
TokenDexSupplyThreshSet(address token, uint256 dexSupplyThresh)OptionalCirculating supply at which the token migrates. Missing β†’ the first DexThreshType (6.67e8 ether).
TokenQuoteSet(address token, address quoteToken)OptionalQuote token of the curve. Missing β†’ native gas token (address(0)).
TokenMigratorSet(address token, uint8 migratorType)OptionalPool 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)OptionalDEX it migrates to (chain-specific, see DEX destination) and V3 fee tier. Missing β†’ DEX0 with the STANDARD fee profile.
TokenVersionSet(address token, uint8 version)OptionalToken implementation (Token versions). Missing β†’ legacy version.
FlapTokenTaxSet(address token, uint256 tax)OptionalTax rate in bps; for asymmetric V3 tokens it carries max(buy, sell). Missing β†’ 0 (non-tax token).
FlapTokenAsymmetricTaxSet(address token, uint256 buyTax, uint256 sellTax)OptionalBuy 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)OptionalThe launch carried extension data; extensionID tells you how to read it (Extension encoding). Missing β†’ no extension.
FlapTokenStaged(uint256 ts, address creator, address token)OptionalThe token is staged (two-step launch): its address is fixed but it is not created yet. Missing β†’ not a staged launch.

Vault (VaultPortal)

EventRequiredDescription Β· default when missing
FlapTaxVaultTokenCreated(address indexed token, address indexed vault, address indexed vaultFactory)Required if you show vaultsEmitted 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)OptionalA 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)OptionalA 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)

EventRequiredDescription Β· default when missing
TokenBought(uint256 ts, address token, address buyer, uint256 amount, uint256 eth, uint256 fee, uint256 postPrice)Required for tradesA 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 tradesA sell on the curve.
FlapTokenCirculatingSupplyChanged(address token, uint256 newSupply)OptionalCirculating supply after a trade. Missing β†’ read circulatingSupply from getTokenV8Safe.
FlapTokenProgressChanged(address token, uint256 newProgress)OptionalProgress 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 is max(netBought, balanceOf(origin)): curve buys add to netBought and 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.
EventRequiredDescription Β· default when missing
FlapNFTGated(address token, address nft)Required for gated tokensEmitted 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)OptionalA 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)OptionalAfter 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)OptionalAn 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)OptionalA collection was added to or removed from the whitelist of collections new launches may use.

Migration to DEX (Portal)

EventRequiredDescription Β· default when missing
LaunchedToDEX(address token, address pool, uint256 amount, uint256 eth)RequiredThe 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 migratorsThe 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.

ChainDEX0DEX1DEX2
BNB ChainPancakeSwapβ€”β€”
Robinhood ChainUniswapPancakeSwapβ€”
X LayerPotatoSwapβ€”β€”
MonadUniswapPancakeSwapMonday
migratorTypeDestination
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

  1. Listen to TokenCreated on Portal and FlapTaxVaultTokenCreated on VaultPortal.
  2. For each new token, collect the optional creation events of the same transaction and apply the defaults above for any that are missing.
  3. Store the token record, its metadata CID and (if any) its vault and vaultFactory.
  4. Follow TokenBought / TokenSold for trades, and LaunchedToDEX (plus FlapTokenCLPoolCreated) for migration.
  5. Keep factory and token risk levels from FlapTaxVaultFactoryRegistered and AuditReportSubmitted.
  6. Mark gated tokens from FlapNFTGated (and un-mark them on FlapNFTGateUpdated).

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:

ValueNameDescription
0, 1TOKEN_LEGACY_MINT_NO_PERMITLegacy token without permit (both values are the same implementation)
2TOKEN_V2_PERMITStandard token with EIP-2612 permit
3TOKEN_GOPLUSGoPlus-integrated token (not used in current deployments)
4TOKEN_TAXEDTax Token V1 (legacy)
5TOKEN_TAXED_V2Tax Token V2 (legacy)
6TOKEN_TAXED_V3Tax Token V3: asymmetric buy/sell rates. See Bonding Curve Tax Token V3
7TOKEN_V3_PERMITNon-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) and nftGate(token) != address(0).
  • Whether a user may trade is IERC721(nft).balanceOf(user) >= 1; check it yourself. quoteExactInput enforces the cap but not the NFT check, and must be called with from = user (the cap is read on tx.origin).
  • nftGate and the other reads revert on Portals older than v5.27.0; call them in a try/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

NameextensionID / extensionIdPayload (extensionData)
Extension arraykeccak256("flap.extensions.v1") = 0xc87a159bf445e83477134d6374216c321695c2f3e23ee3351c8d3df6ec740253abi.encode(Extension[]): a list of the extensions below
Agent ID gatekeccak256("flap.extension.nft_gated.v1") = 0x9c312f81d9a1b68ba5472ca93c5447d57f897fbf4a8c468b315a67fe8016d1ababi.encode(address nft): the Agent ID collection
Signed curve pricekeccak256("flap.curve.auth.v2") = 0x0f1f338177d85790dd81a6e795b59ae3a205cb498e034240ce2261bf753f13a4abi.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") = 0x3526f6a0e790306d64c896eeda5ec66052ea17889e20632ff0cf512923153dadabi.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") and extensionData = 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 its extensionId is used.
  • Single form (legacy). extensionID is the extension's own ID and extensionData is 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.