Agent ID-Gated Internal Market — Terminal Integration Guide

This guide covers the agent-gated bonding-curve market. An Agent ID is an ERC-721 collection used as an on-chain credential: a wallet holding an NFT from the collection can trade the agent's token on the internal curve. An Agent ID-gated launch also gets a 2% per-origin max-buy cap. It targets Portal v5.27.2; the entry point is the Portal proxy used for swapExactInput.

Contents


What is an Agent ID

An Agent ID is an ERC-721 collection used as the on-chain identity of an agent. The NFT is the technical primitive, not the agent itself. Holding at least one NFT from the collection is the credential to trade that agent's token on the internal curve.

  • The gate is collection-wide: any NFT from the collection counts; it does not check a specific tokenId.
  • Any ERC-721 collection can serve as an Agent ID. Flap only enables whitelisted collections for launches. The ABI keeps the legacy nftGate / nftWhitelisted / NFT_WHITELIST_ROLE names.
  • The gate only applies to the internal curve. After graduation, DEX trading is open to everyone.

Overview

  • A launch can opt into an Agent ID gate. While the token is on the curve, every buy and sell requires tx.origin to hold at least 1 NFT from the configured Agent ID collection.
  • Agent ID-gated launches are created with a 2%-of-maxSupply max-buy cap per tx.origin (20,000,000 tokens for a 1B-supply launch). Enabling the gate sets the initial cap to 200 bps; it is not a separate launch flag.
  • The cap uses held = max(netBought, balanceOf(tx.origin)): selling reduces the counter (clamped at zero), transferring out does not reduce netBought, and transferring in consumes the recipient's quota via the balance component.
  • Selling is not capped: selling more than netBought (for example tokens received by transfer) is allowed, and the counter clamps at zero.
  • Detect with nftGate(token): it returns the Agent ID collection address, or address(0) for a normal token.
  • The Agent ID gate is curve-only. After graduation (status == 4) the Portal swap path reverts TokenNotTradable and the DEX is open to everyone.
  • quoteExactInput enforces the cap but not the Agent ID check. Check the collection's balanceOf(user) yourself before enabling the buy/sell button.
  • Enforcement is keyed on tx.origin. Direct EOA transactions work; relayers, meta-transactions and ERC-4337 UserOps normally fail because their tx.origin is the relayer or bundler.

1. Semantics

1.1 Where the gate applies

Token stategetTokenV8Safe(token).statusPortal curve tradeAgent ID gate
On the curve1 (Tradable)swapExactInput / swapExactInputV3Enforced
Graduated4 (DEX)reverts TokenNotTradableNot applicable
Unknown0 (Invalid)revertsNot applicable

The Agent ID gate applies only to internal-curve buys and sells. It does not apply to ERC-20 transfers or DEX trades.

1.2 Agent ID check

Every curve trade on an Agent ID-gated token requires:

nft.balanceOf(tx.origin) >= 1
  • The check uses tx.origin, not msg.sender.
  • The balanceOf call is a STATICCALL capped at 100,000 gas and reads at most 32 bytes, so a large return payload cannot grief the caller.
  • Fail-open corner case: if balanceOf reverts, runs out of gas, returns fewer than 32 bytes, or the contract is self-destructed or upgraded to something unreadable, the check is skipped and the trade proceeds. This keeps a broken NFT from causing a DoS on the curve, but it also means the gate is not enforced while the collection is unreadable.
  • The Agent ID collection address is validated at launch. A zero or codeless address reverts InvalidNFTGate() (a launch-side error).

1.3 Agent ID buy cap (gated launches only)

An Agent ID-gated launch stores a per-tx.origin buy cap of 200 bps = 2% of maxSupply (20,000,000 tokens for a 1B launch).

Non-gated capped tokens use the pure holding cap:

held      = token.balanceOf(tx.origin)
remaining = max(0, cap - held)

Agent ID-gated tokens use a hybrid:

held      = max(netBought[token][tx.origin], token.balanceOf(tx.origin))
remaining = max(0, cap - held)           // read from buyQuotaOf(token, origin)
  • netBought is credited on every curve buy to tx.origin, regardless of who receives the minted tokens. It is reduced on every curve sell, clamped at zero.
  • Selling is not capped. Selling more than netBought (for example tokens received by transfer) is allowed; the counter simply stops at zero.
  • Transfer out does not reduce netBought. It can lower the balance component down to the netBought floor, but it never restores the counter.
  • Transfer in increases balanceOf(origin), so it consumes the recipient's quota (the recipient may be unable to buy until it sells or moves the tokens). This is the same holding-cap behavior as non-gated tokens.
  • A buy counts against tx.origin whoever receives the tokens. Routing the buy to another wallet or through a helper contract still uses up the origin's 2%.
  • Shortcut for terminals: if a wallet only ever buys and sells on the curve itself (never transfers the token out, never receives it by transfer, never routes buys to another address), netBought equals its token balance, so remaining = cap - balanceOf(wallet) is safe to use. Otherwise read buyQuotaOf.
  • A buy from an origin with remaining == 0 reverts BuyCapExceeded(origin, held, cap).
  • A buy from an origin with remaining > 0 fills up to the cap and refunds the excess input in the input currency (native or quote ERC-20).
  • Edge case: when remaining is only a few wei of tokens, the buy may return outputAmount == 0 with a full refund instead of reverting. Treat a zero output as "cap reached".
  • The creator's quoteAmt launch buy is a normal curve buy: it requires the NFT and is capped.
  • Non-gated tokens have no automatic cap, but an admin can set one. Always read maxBuyPerOrigin; never hardcode 2%. A cap on a non-gated token is a holding cap based on the live balance (transfers do move it), not net buys.
  • If an admin removes a token's Agent ID gate (removeTokenNFTGate), the curve opens to everyone and the 2% cap switches to that balance-based holding cap. A gate can only be removed, never added or swapped after launch. Watch FlapNFTGateUpdated.
  • On tokens gated before v5.27.2 every origin starts with netBought = 0, so held starts from balance; an origin whose bag was already moved out can buy one full new cap.
  • FlapBuyQuotaUpdated is emitted only on buys when a cap is active. For Agent ID-gated tokens bought is max(netBought, balanceOf(origin)) after the mint. Sells and transfers change quota without this event; reconstruct with TokenBought/TokenSold/Transfer or read buyQuotaOf.

1.4 Agent ID whitelist

New Agent ID-gated launches only accept collections on the Portal whitelist; otherwise the launch reverts NFTNotWhitelisted(nft). Launch tooling can call nftWhitelisted(nft) before submitting.

Only collections approved by Flap can be used as an Agent ID, so a launch cannot point at a malicious NFT contract.


2. Read API (Portal)

FunctionSelector
nftGate(address token)0x9b95f8dc
maxBuyPerOrigin(address token)0x4b02c8e9
buyQuotaOf(address token, address origin)0x929b098c
getTokenV8Safe(address token)0x62fafcca
nftWhitelisted(address nft)0x0bfdb80d
// address(0) = not gated
function nftGate(address token) external view returns (address nft);

// token-level cap; bps == 0 (and maxBuyAmount == 0) means no cap
function maxBuyPerOrigin(address token) external view returns (uint16 bps, uint256 maxBuyAmount);

// origin-level live allowance; remaining == type(uint256).max when no cap is set
function buyQuotaOf(address token, address origin)
    external view returns (uint256 bought, uint256 remaining);

// status: 1 = Tradable (curve), 4 = DEX (graduated); other fields unchanged
function getTokenV8Safe(address token) external view returns (TokenStateV8Safe memory);

// launch whitelist: true = collection may be used as a gate for new launches
function nftWhitelisted(address nft) external view returns (bool allowed);

Notes:

  • On Agent ID-gated tokens, buyQuotaOf.bought is max(netBought, balanceOf(origin)). netBought changes on buys and sells (clamped at zero); the balance component changes on transfers. Transfers in consume quota; transfers out never reduce netBought (they can only lower the balance component). For a wallet that only buys and sells on the curve itself, it equals balanceOf(wallet); otherwise read buyQuotaOf.
  • On non-gated capped tokens, buyQuotaOf.bought is balanceOf(origin).
  • buyQuotaOf.remaining == type(uint256).max means "no cap configured".
  • Example Agent ID collection: Binance Agent ID (0x60B9262456dE011F7d15a54CdD8c48bAaa8B022F).
  • nftGate reverts on pre-v5.27.0 implementations because the selector does not exist. Feature-detect it:
async function readNftGate(portal: Contract, token: string): Promise<string | null> {
  try {
    return await portal.nftGate(token);
  } catch {
    return null; // feature not live on this Portal implementation
  }
}

3. Events

Eventtopic0
FlapNFTGated(address token, address nft)0x2179156b3d2ade0f0de96eff28a7c29d924002784e113e69108d91a02cbf9098
FlapTokenMaxBuyPerOriginSet(address indexed token, uint16 bps, uint256 maxBuyAmount)0xafcebb99a3d0047f96f0d1cc8872781aaeef86f4f5e7e1c6a33e5907d6a3ed82
LaunchedToDEX(address token, address pool, uint256 amount, uint256 eth)0x6e4f47630b8745b8cacbd44f42a8a33e7eea7cc08ef22fc7630f4f385784ff7d
FlapNFTWhitelistUpdated(address nft, bool allowed)0x9b31e0304b48df87633ad2120618592c707a1830e269c0bc8b435c5ee2f3a47d
FlapBuyQuotaUpdated(address indexed token, address indexed origin, uint256 bought, uint256 remaining)0x6d7a33547efbe801870d2426853a4aa2ae198acb815783fb3fc3d853a944202b
FlapNFTGateUpdated(address token, address oldNft, address newNft)0xef4dac2f9b1190c744c50eaead877aa2f222bafb1ef8bb2c60baed13f4b63cd8
  • FlapNFTGated is emitted once at launch when the gate is configured. It has no indexed fields; decode (token, nft) from data.
  • FlapTokenMaxBuyPerOriginSet is emitted at launch only when bps > 0; no event when bps == 0 (no cap). maxBuyPerOrigin is the authoritative value.
  • LaunchedToDEX marks graduation. After it, stop applying the gate and route to the DEX.
  • FlapNFTWhitelistUpdated fires when the admin whitelist adds or removes a collection.
  • FlapBuyQuotaUpdated fires after every curve buy on a capped token. On Agent ID-gated tokens bought is max(netBought, balanceOf(origin)) after this buy. It is not emitted on sells or transfers; those can change the quota, so call buyQuotaOf (or track TokenBought/TokenSold/Transfer) to refresh remaining.
  • FlapNFTGateUpdated fires when an admin removes a token's Agent ID gate. newNft is always address(0). Treat the token as non-gated from then on.
  • TokenBought / TokenSold are unchanged.

4. Integration recipes (ethers v6)

4.1 Detect the Agent ID gate and the user's eligibility

const portal = new ethers.Contract(PORTAL_ADDRESS, PORTAL_ABI, provider);

const [state, nft, cap] = await Promise.all([
  portal.getTokenV8Safe(token),
  portal.nftGate(token).catch(() => ethers.ZeroAddress), // pre-v5.27.0 Portal
  portal.maxBuyPerOrigin(token),
]);

const onCurve = state.status === 1n; // Tradable
const isGated = onCurve && nft !== ethers.ZeroAddress;

let userHoldsNft = true;
let remaining = ethers.MaxUint256;
if (isGated) {
  const erc721 = new ethers.Contract(nft, ["function balanceOf(address) view returns (uint256)"], provider);
  userHoldsNft = (await erc721.balanceOf(user)) > 0n;
  [, remaining] = await portal.buyQuotaOf(token, user);
}

const canBuy = !isGated || (userHoldsNft && remaining > 0n);
// sell: only the Agent ID holder check matters

4.2 Quote (always pass from)

The cap is evaluated on tx.origin, so the eth_call must set from to the real trader. Without from, tx.origin is address(0), whose balance and netBought are 0, so the quote assumes the full cap.

const params = {
  inputToken: ethers.ZeroAddress, // native BNB/ETH
  outputToken: token,
  inputAmount: parseEther("1"),
};
const out = await portal.quoteExactInput.staticCall(params, { from: user });
  • quoteExactInput does not check the Agent ID. A non-holder still gets a quote; gate the UI with balanceOf (section 4.1).
  • If the user is at or over the cap, the quote reverts BuyCapExceeded. Treat it as "cap reached".
  • If the input exceeds the remaining allowance, the quote reflects the capped fill; the excess is refunded on execution. The quote returns only the token output, not the refund.

4.3 Swap

The trade ABI is unchanged:

struct ExactInputParams {
    address inputToken;     // address(0) = native
    address outputToken;    // address(0) = native
    uint256 inputAmount;
    uint256 minOutputAmount;
    bytes permitData;
}

function swapExactInput(ExactInputParams calldata params)
    external payable returns (uint256 outputAmount);

Buy with native quote: inputToken = address(0), outputToken = token, msg.value = inputAmount. Sell: inputToken = token, outputToken = address(0); approve or permit the Portal first.

const tx = await portal.swapExactInput(
  {
    inputToken: ethers.ZeroAddress,
    outputToken: token,
    inputAmount: parseEther("1"),
    minOutputAmount: (out * 99n) / 100n,
    permitData: "0x",
  },
  { value: parseEther("1") }
);

The Agent ID check runs before payment and before any native↔quote swap, so a failed gate reverts without moving funds. The cap check runs inside the curve math; its revert also unwinds the whole transaction.

4.4 Refund on a capped buy

A below-cap buy is filled up to the cap and the excess input is refunded to msg.sender in the input currency:

  • Native input → native refund.
  • ERC-20 quote input → quote-token refund.

Do not derive minOutputAmount from the full input when the input exceeds the remaining allowance: the actual fill is smaller and a too-high minimum reverts SlippageTooHigh. Quote with from = user and set the minimum from that result. If the UI needs an exact refund preview, simulate the swap and diff the caller's native or quote balance (eth_simulateV1, a trace, or a local fork); otherwise show an approximate "up to X refunded" note.


5. Errors

SelectorErrorCauseSuggested UI
0xf38fb4a7NFTGatedOnly(address wallet)wallet holds no NFT"This market requires an Agent ID. Hold at least 1 NFT from {collection} to trade on the curve."
0xc866db66BuyCapExceeded(address origin, uint256 held, uint256 cap)origin's held (balance or netBought) is >= cap"Cap reached: {held} of {cap}. Sell or move tokens to free quota."
0x76baaddaSlippageTooHigh(uint256 actual, uint256 minimum)capped fill below minOutputAmountRe-quote with from = user and lower the minimum.
0x6e8698f2TokenNotTradable(address token)token graduated or not on the curveRoute the trade to the DEX pool.
0x9843f01fInvalidNFTGate()launch config had a zero or codeless NFTLaunch-side error; not expected during trading.
0x5166d46cNFTNotWhitelisted(address nft)launch used a collection not on the whitelistLaunch-side error; pick a whitelisted collection.

ethers v6 decode:

try {
  await portal.swapExactInput(params, { value });
} catch (e: any) {
  const data = e?.data ?? e?.info?.error?.data;
  const selector = data?.slice(0, 10);
  if (selector === "0xf38fb4a7") showNftGateModal();
  if (selector === "0xc866db66") showCapModal();
}

6. Launch encoding (for launch tooling)

The Agent ID gate travels in the existing extensionID / extensionData launch fields. Two encodings are accepted.

EXTENSION_ARRAY_ID         = keccak256("flap.extensions.v1")
                           = 0xc87a159bf445e83477134d6374216c321695c2f3e23ee3351c8d3df6ec740253
NFT_GATED_EXTENSION_ID     = keccak256("flap.extension.nft_gated.v1")
                           = 0x9c312f81d9a1b68ba5472ca93c5447d57f897fbf4a8c468b315a67fe8016d1ab
CURVE_AUTH_EXTENSION_ID_V2 = keccak256("flap.curve.auth.v2")
                           = 0x0f1f338177d85790dd81a6e795b59ae3a205cb498e034240ce2261bf753f13a4
struct Extension {
    bytes32 extensionId;
    bytes extensionData;
}

Extension[] memory exts = new Extension[](1);
exts[0] = Extension({
    extensionId: NFT_GATED_EXTENSION_ID,
    extensionData: abi.encode(nftAddress)
});

params.extensionID = EXTENSION_ARRAY_ID;
params.extensionData = abi.encode(exts);

TypeScript (viem):

import { encodeAbiParameters, keccak256, toHex } from "viem";

const NFT_GATED_ID = keccak256(toHex("flap.extension.nft_gated.v1"));
const ARRAY_ID = keccak256(toHex("flap.extensions.v1"));

const extensionData = encodeAbiParameters(
  [{ type: "tuple[]", components: [
      { name: "extensionId", type: "bytes32" },
      { name: "extensionData", type: "bytes" },
  ]}],
  [[{
    extensionId: NFT_GATED_ID,
    extensionData: encodeAbiParameters([{ type: "address" }], [nft]),
  }]]
);
// params.extensionID = ARRAY_ID; params.extensionData = extensionData;

6.2 Legacy single-extension form

params.extensionID = NFT_GATED_EXTENSION_ID;
params.extensionData = abi.encode(nftAddress);

Rules:

  • nftAddress must have code at launch; zero or codeless reverts InvalidNFTGate().
  • nftAddress must be on the launch whitelist for new launches; otherwise the launch reverts NFTNotWhitelisted(nft).
  • Only the first entry matching NFT_GATED_EXTENSION_ID is used.
  • A launch without either encoding has nftGate == address(0) and no automatic cap.
  • Agent ID-gated launches set maxBuyBpsPerOrigin = 200 (2%). The creator's quoteAmt buy also requires the NFT and is capped by max(netBought, balanceOf(origin)).

7. Caveats and limitations

  1. tx.origin identity. The Agent ID gate assumes an EOA calls the Portal directly.
    • Backend relayers / meta-transactions: tx.origin is the relayer.
    • ERC-4337 UserOps: tx.origin is the bundler EOA.
    • Safe / smart-account execution through a relayer has the same problem.
    • In all these cases a real holder's trade reverts NFTGatedOnly.
    • EIP-7702 delegated EOAs keep the EOA as tx.origin; the check still works.
  2. Buys always count against tx.origin. A buy routed through a helper contract or sent to another wallet still uses up the origin's 2%, even though the origin never holds the tokens.
  3. Hybrid cap for gated tokens. held = max(netBought, balanceOf(origin)). Only curve buys and sells change netBought; transferring out does not free the counter, while transferring in consumes quota via the balance component. A user can be pushed over the cap by an airdrop/gift without buying. A legacy bag moved out before the upgrade is not counted by netBought; that origin can buy one full new cap.
  4. Sells are not capped. Selling more than netBought (for example tokens received by transfer) is allowed and clamps the counter at zero. A recipient's later sale does not reduce the sender's netBought.
  5. Agent ID transferred away = cannot sell on the curve. Token transfers remain allowed. The holder must re-acquire the NFT or wait for graduation.
  6. Gas-capped balanceOf. The call is capped at 100,000 gas. If a collection's balanceOf reverts, runs out of gas, returns a short value, or the contract is self-destructed, the gate fails open and the trade proceeds. Standard ERC-721 implementations are fine.
  7. Quote is not the gate. quoteExactInput never checks the Agent ID; it does check the cap. Gate the UI yourself and simulate with from = user.

8. Worked example

  • Example Agent ID collection: Binance Agent ID (0x60B9262456dE011F7d15a54CdD8c48bAaa8B022F).
  • maxSupply = 1e27 (1B tokens, 18 decimals); gated cap = 1e27 * 200 / 10000 = 2e25 (20,000,000 tokens).
  • Alice holds the Agent ID, balance 0, buys with 100 BNB:
    • The fill stops at 20,000,000 tokens; the excess BNB is refunded.
    • buyQuotaOf(token, alice) → bought = 2e25 (netBought = 2e25), remaining = 0.
  • Alice transfers 5,000,000 tokens to another wallet:
    • Her netBought stays 20,000,000; held = max(2e25, 1.5e25) = 2e25; remaining stays 0.
    • The recipient's balance 5,000,000 counts against the recipient's quota.
  • Alice sells 10,000,000 tokens back to the curve:
    • netBought = 1e25; balance = 5,000,000; held = max(1e25, 5e24) = 1e25;
    • remaining = 1e25 (10,000,000 more). She holds 5,000,000.
  • Bob buys 20,000,000 tokens and transfers all of them to Alice:
    • Alice's balance is 25,000,000; netBought is 10,000,000; held = 2.5e25 > cap; remaining = 0.
    • Alice can sell the 25,000,000 tokens: netBought clamps to 0 and her quota returns to 2e25.
    • Bob's netBought stays 2e25: giving his tokens away did not free his allowance.
  • Alice transfers the Agent ID away:
    • She can still transfer tokens, but a curve sell reverts NFTGatedOnly(alice).
  • The token graduates (LaunchedToDEX, status == 4):
    • The Portal trade path closes; trade on the DEX with no NFT requirement from Flap.

Live examples (BNB Chain)

TokenNFT
0xe0ec675392020EF6e8C871A9822ec50b90B37777Test Agent ID NFT: 0xfDE93258a9E3896CAc41B5cf1f7530aD806961e6. The contract is open source; call mint(address to) to claim one for free.
0x4131Cae06908e7D874cBA44004d8803045367777Binance Agent ID NFT: 0x60B9262456dE011F7d15a54CdD8c48bAaa8B022F.

9. ABI fragments

9.1 Human-readable

function nftGate(address token) view returns (address nft)
function maxBuyPerOrigin(address token) view returns (uint16 bps, uint256 maxBuyAmount)
function buyQuotaOf(address token, address origin) view returns (uint256 bought, uint256 remaining)
function nftWhitelisted(address nft) view returns (bool allowed)

event FlapNFTGated(address token, address nft)
event FlapNFTWhitelistUpdated(address nft, bool allowed)
event FlapTokenMaxBuyPerOriginSet(address indexed token, uint16 bps, uint256 maxBuyAmount)
event LaunchedToDEX(address token, address pool, uint256 amount, uint256 eth)
event FlapBuyQuotaUpdated(address indexed token, address indexed origin, uint256 bought, uint256 remaining)
event FlapNFTGateUpdated(address token, address oldNft, address newNft)

error NFTGatedOnly(address wallet)
error NFTNotWhitelisted(address nft)
error BuyCapExceeded(address origin, uint256 held, uint256 cap)
error InvalidNFTGate()

9.2 JSON ABI (new surface only)

[
  {
    "type": "function",
    "name": "nftGate",
    "stateMutability": "view",
    "inputs": [{ "name": "token", "type": "address" }],
    "outputs": [{ "name": "nft", "type": "address" }]
  },
  {
    "type": "function",
    "name": "maxBuyPerOrigin",
    "stateMutability": "view",
    "inputs": [{ "name": "token", "type": "address" }],
    "outputs": [
      { "name": "bps", "type": "uint16" },
      { "name": "maxBuyAmount", "type": "uint256" }
    ]
  },
  {
    "type": "function",
    "name": "buyQuotaOf",
    "stateMutability": "view",
    "inputs": [
      { "name": "token", "type": "address" },
      { "name": "origin", "type": "address" }
    ],
    "outputs": [
      { "name": "bought", "type": "uint256" },
      { "name": "remaining", "type": "uint256" }
    ]
  },
  {
    "type": "function",
    "name": "nftWhitelisted",
    "stateMutability": "view",
    "inputs": [{ "name": "nft", "type": "address" }],
    "outputs": [{ "name": "allowed", "type": "bool" }]
  },
  {
    "type": "event",
    "name": "FlapNFTGated",
    "inputs": [
      { "name": "token", "type": "address", "indexed": false },
      { "name": "nft", "type": "address", "indexed": false }
    ]
  },
  {
    "type": "event",
    "name": "FlapNFTWhitelistUpdated",
    "inputs": [
      { "name": "nft", "type": "address", "indexed": false },
      { "name": "allowed", "type": "bool", "indexed": false }
    ]
  },
  {
    "type": "event",
    "name": "FlapTokenMaxBuyPerOriginSet",
    "inputs": [
      { "name": "token", "type": "address", "indexed": true },
      { "name": "bps", "type": "uint16", "indexed": false },
      { "name": "maxBuyAmount", "type": "uint256", "indexed": false }
    ]
  },
  {
    "type": "error",
    "name": "NFTGatedOnly",
    "inputs": [{ "name": "wallet", "type": "address" }]
  },
  {
    "type": "error",
    "name": "NFTNotWhitelisted",
    "inputs": [{ "name": "nft", "type": "address" }]
  },
  {
    "type": "error",
    "name": "BuyCapExceeded",
    "inputs": [
      { "name": "origin", "type": "address" },
      { "name": "held", "type": "uint256" },
      { "name": "cap", "type": "uint256" }
    ]
  },
  {
    "type": "event",
    "name": "FlapBuyQuotaUpdated",
    "inputs": [
      { "name": "token", "type": "address", "indexed": true },
      { "name": "origin", "type": "address", "indexed": true },
      { "name": "bought", "type": "uint256", "indexed": false },
      { "name": "remaining", "type": "uint256", "indexed": false }
    ]
  },
  {
    "type": "event",
    "name": "FlapNFTGateUpdated",
    "inputs": [
      { "name": "token", "type": "address", "indexed": false },
      { "name": "oldNft", "type": "address", "indexed": false },
      { "name": "newNft", "type": "address", "indexed": false }
    ]
  }
]

getTokenV8Safe, quoteExactInput, swapExactInput, TokenBought, and TokenSold are unchanged; reuse the ABI you already integrate.


10. Integration checklist

  • Feature-detect nftGate with try/catch; handle pre-v5.27.0 Portals.
  • On token load, read getTokenV8Safe.status, nftGate, maxBuyPerOrigin, and buyQuotaOf(token, user).
  • If status == 1 and nftGate != 0, show the Agent ID banner, the collection, and the user's holder status.
  • Disable buy when nft.balanceOf(user) == 0 or remaining == 0.
  • Disable sell when nft.balanceOf(user) == 0; sells are not capped by netBought.
  • Quote with from = user for a cap-accurate result; remember the quote does not check the NFT.
  • Submit gated trades as direct EOA transactions; avoid relayers and 4337 bundlers.
  • Handle NFTGatedOnly, BuyCapExceeded, SlippageTooHigh, and TokenNotTradable. The ABI keeps the NFT names; Agent ID is the product term.
  • Show a live cap meter (bought / cap, remaining) and refresh it after curve buys, sells, and transfers.
  • For launch tooling, check nftWhitelisted(nft) for the Agent ID collection before submitting; handle NFTNotWhitelisted.
  • Listen for FlapNFTWhitelistUpdated when showing launch eligibility.
  • On LaunchedToDEX or status == 4, switch to DEX routing and remove the gate UI.
  • Index FlapNFTGated so Agent ID-gated tokens are known before the user opens them, and FlapNFTGateUpdated to drop the Agent ID UI when an admin removes it.

11. Contract addresses

ChainChain IDPortal proxy
BNB Smart Chain mainnet560xe2cE6ab80874Fa9Fa2aAE65D277Dd6B8e65C9De0
BNB Smart Chain testnet970x5bEacaF7ABCbB3aB280e80D007FD31fcE26510e9

Other chains use different Portal proxies; take the address from Flap's deployed-contract addresses page. The ABI and semantics above are identical on every chain.