Flap Shared Indexer (FSI)

FSI is the backend layer of Flap Apps, and works the same way for Vaults, factories and any other contract.

Beta. FSI is in beta. The event signatures below are the fsi-v1 templates. Rollout order is BNB Chain Testnet (97) β†’ BNB Chain Mainnet (56) β†’ Robinhood Chain (4663). We are not publishing the FSI endpoint yet β€” if you want access or to join the test, please reach out to us.

Once a contract emits events that follow FSI's fixed templates, its data becomes queryable and sortable off-chain β€” leaderboards, claimed amounts, epoch statistics, post timelines, claim / compound / burn logs β€” without the developer running an indexer, a database, or a query API. Flap runs one indexer per chain that listens for these templates, stores each row, and serves it over HTTP to your Flap App UI, Vault UI, or any third party.

FSI works for any contract β€” a Vault, a factory, or a Flap App's business contract β€” under exactly the same rules. No registration is required.

Templates

NeedTemplateEventPrimary keyWhat is stored
Current balance, points, claimed amount, or share for an addressAFlapSharedIndexerAddrUint(emitter, ns, key)One latest value per address
The same, scoped to a round / epochBFlapSharedIndexerUintAddrUint(emitter, ns, round, account)One latest value per round and account
An ordered history you must keep: a post plus updates, announcements, buyback recordsLFlapSharedIndexerFeed(emitter, ns, id, seq)Every record, with a contract-assigned sequence number
A low-cost activity log: claim, compound, burn, auto-claim, a single tradeGFlapSharedIndexerLog(emitter, txHash, logIndex)Every log, keyed by its on-chain position; the contract stores no state for it
event FlapSharedIndexerAddrUint(bytes32 ns, address key, uint256 value, string data);                                         // A
event FlapSharedIndexerUintAddrUint(bytes32 ns, uint256 round, address account, uint256 value, string data);                // B
event FlapSharedIndexerFeed(bytes32 ns, uint256 id, uint256 seq, address actor, address related, uint256 value, string data); // L
event FlapSharedIndexerLog(bytes32 ns, uint256 id, address actor, address related, uint256 amount, uint256 extra);          // G
Templatetopic0
A0x6d92bb4f14ee2a433bdba0d337dc697cf6017066878befb1a42edf326092dba3
B0x9cdf0eeb5c96afc4a86997e5b946603d187dfc2f1119862ad053c413de36dd28
L0x262063c773bcbfa80d8648888e2f288454339e80d08c1c8f78aa6e55c2db75cb
G0x0483a030f7f57a91177910ed00ae51f19170eb9db2129394c858e7e3bc188aed

No parameter is indexed (this saves gas). The indexer filters on topic0 only and decodes the rest. Events that match none of the templates are ignored.

FieldMeaning
nsNamespace (bytes32). One contract uses different ns values as separate logical tables, e.g. "points" and "claimed". Use bytes32(0) if you only need one.
key (A)Row key (an address).
round, account (B)Round / epoch / ID, and the user address. Use account = address(0) for pure round stats (round total, global counter).
id, seq (L)The object the record belongs to (e.g. a post ID) and its sequence number within that object β€” assigned by the contract: 0 for the first record, then 1, 2, …
id (G)The object or a type code, used for filtering (a pool, an epoch, 0 = claim / 1 = compound).
actor, related (L, G)The submitter / operator, and another bound address (e.g. a beneficiary). Use address(0) when unused.
value (A, B, L)A, B: the row's current absolute value, sortable. L: the sort field, usually block.timestamp.
amount, extra (G)Values of this action (not cumulative). amount is sortable and filterable; extra is returned as-is.
data (A, B, L)Optional string, not sortable, returned as-is. Truncated past 1024 bytes; keep it under 256 bytes. For L it is usually the CID of a content JSON.

Two rules that always apply

  1. The event must be emitted by your contract itself. The indexer takes emitter from log.address, so only your contract can write your contract's rows.
  2. FSI is an off-chain cache, never a settlement source. Logic that involves funds (e.g. how much a user can claim) must read on-chain state. Never settle based on an FSI response.

Base contract

Flap provides a dependency-free abstract contract, FlapSharedIndexerV1. Inherit it and use the helpers β€” they make "emit the absolute value" the default:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

abstract contract FlapSharedIndexerV1 {
    event FlapSharedIndexerAddrUint(bytes32 ns, address key, uint256 value, string data);
    event FlapSharedIndexerUintAddrUint(bytes32 ns, uint256 round, address account, uint256 value, string data);
    event FlapSharedIndexerFeed(
        bytes32 ns, uint256 id, uint256 seq, address actor, address related, uint256 value, string data
    );
    event FlapSharedIndexerLog(
        bytes32 ns, uint256 id, address actor, address related, uint256 amount, uint256 extra
    );

    function flapSharedIndexerSpecVersion() public pure virtual returns (string memory) {
        return "fsi-v1";
    }

    /// Template A: absolute value for one address.
    function _fsiSetAddr(bytes32 ns, address key, uint256 absoluteValue) internal {
        emit FlapSharedIndexerAddrUint(ns, key, absoluteValue, "");
    }

    /// Template B: absolute value for one user in one round. Pass account = address(0) for pure round stats.
    function _fsiSetRound(bytes32 ns, uint256 round, address account, uint256 absoluteValue) internal {
        emit FlapSharedIndexerUintAddrUint(ns, round, account, absoluteValue, "");
    }

    /// Template L: seq is unique under the same (ns, id). 0 is the first record. value is block.timestamp.
    function _fsiAppend(bytes32 ns, uint256 id, uint256 seq, address actor, address related, string memory data)
        internal
    {
        emit FlapSharedIndexerFeed(ns, id, seq, actor, related, block.timestamp, data);
    }

    /// Template G: no sequence number required; the contract stores no counter.
    function _fsiLog(bytes32 ns, uint256 id, address actor, address related, uint256 amount, uint256 extra)
        internal
    {
        emit FlapSharedIndexerLog(ns, id, actor, related, amount, extra);
    }
}

flapSharedIndexerSpecVersion() declares which FSI version your contract is built on. Before development, confirm the target chain already supports the templates you use (GET /fsi/versions, see Querying FSI).

Write semantics

Templates A and B β€” latest absolute value. For each primary key the indexer keeps only the latest value. It does not accumulate deltas and does not keep history.

  1. value must be the current value after your state update. Update storage first, then emit the value read back from storage.
  2. A stored value is overwritten only by an event at a strictly later (block, logIndex). Older or duplicate deliveries never roll data back. Within one block, the greatest logIndex wins.
  3. To fix a wrong value, just emit the correct current value again β€” no migration needed.
  4. There is no delete event: emitting value = 0 clears a row (it still counts toward the quota; filter it out with minValue=1).
Wrong (delta)Right (absolute value)
_fsiSetAddr("points", user, 50) where 50 is this increment_fsiSetAddr("points", user, points[user])
Re-emitting an ERC20 Transfer(from, to, amount)One event each for from and to, with each balance after the transfer
Claimed(user, amount)_fsiSetAddr("claimed", user, claimed[user]) β€” the cumulative amount

Template L β€” append only. Each (emitter, ns, id, seq) is an independent record; the original and every update are kept. If a primary key repeats, the earliest on-chain occurrence wins and later duplicates are dropped β€” so always use a new seq. There is no delete; to hide an object, keep a flag with template B (e.g. ns = "hidden", round = id, account = address(0), value = 1). Permission to create or append is enforced by your contract, not by the indexer.

Template G β€” append only, no sequence number. Each log is stored once per (txHash, logIndex); one transaction may emit several. A G event writes no on-chain state and costs roughly 2,000–3,000 gas. Records can't be edited or deleted β€” emit a correcting record under a convention you define. On a chain reorganization, logs from rolled-back blocks are removed and re-added from the canonical chain; your contract doesn't need to handle this. Use A/B for cumulative totals; G is for per-action values.

Example: a Flap App contract using all four templates

A small game that tracks lifetime points (A), per-round scores and a round total (B), posts with author-only updates (L), and claim logs (G):

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {FlapSharedIndexerV1} from "./FlapSharedIndexerV1.sol";

contract MiniGame is FlapSharedIndexerV1 {
    bytes32 constant NS_POINTS = "points";
    bytes32 constant NS_SCORE = "score";
    bytes32 constant NS_POST = "post";
    bytes32 constant NS_CLAIM = "claim";

    mapping(address => uint256) public points;
    mapping(uint256 => mapping(address => uint256)) public score;
    mapping(uint256 => uint256) public totalScore;

    uint256 public postCount;
    mapping(uint256 => uint256) public updateCount;
    mapping(uint256 => address) public authorOf;

    function play(uint256 round, uint256 delta) external {
        // A: lifetime points β€” absolute value after the update
        points[msg.sender] += delta;
        _fsiSetAddr(NS_POINTS, msg.sender, points[msg.sender]);

        // B: this user's score this round, plus a pure round stat (account = address(0))
        score[round][msg.sender] += delta;
        totalScore[round] += delta;
        _fsiSetRound(NS_SCORE, round, msg.sender, score[round][msg.sender]);
        _fsiSetRound("roundTotal", round, address(0), totalScore[round]);
    }

    // L: a post (seq 0) followed by ordered updates (seq 1, 2, 3...)
    function createPost(string calldata cid) external returns (uint256 postId) {
        postId = ++postCount;
        authorOf[postId] = msg.sender;
        _fsiAppend(NS_POST, postId, 0, msg.sender, address(0), cid);
    }

    function appendUpdate(uint256 postId, string calldata cid) external {
        require(authorOf[postId] == msg.sender, "not author"); // permissions are enforced by the contract
        _fsiAppend(NS_POST, postId, ++updateCount[postId], msg.sender, address(0), cid);
    }

    // G: one activity log per claim β€” the value of this action, not a cumulative total
    function claim(uint256 poolId) external {
        uint256 amount = _payout(msg.sender, poolId);
        _fsiLog(NS_CLAIM, poolId, msg.sender, address(0), amount, 0);
    }

    function _payout(address, uint256) internal virtual returns (uint256) {
        return 0; // your payout logic
    }
}

With just these events, the Flap App UI can show a global points leaderboard, a per-round leaderboard, a post feed with full update history, and a "my claims" list β€” without any developer-run backend.

Querying FSI

There is one FSI instance per chain; the path carries no chain ID (the instance's chain is reported by GET /fsi/versions). Addresses are lowercase hex.

GET /fsi/versions
GET /fsi/v1/{emitter}/A/{ns}?sort=value_desc&limit=100&cursor=&minValue=&maxValue=
GET /fsi/v1/{emitter}/A/{ns}/{key}
GET /fsi/v1/{emitter}/B/{ns}?round=7&sort=value_desc&limit=100&cursor=
GET /fsi/v1/{emitter}/B/{ns}/{round}/{account}
GET /fsi/v1/{emitter}/L/{ns}?seq=0&actor=&related=&sort=value_desc&limit=100&cursor=
GET /fsi/v1/{emitter}/L/{ns}/{id}?limit=100&cursor=
GET /fsi/v1/{emitter}/G/{ns}?id=&actor=&related=&minValue=&maxValue=&sort=time_desc&limit=100&cursor=
GET /fsi/v1/{emitter}/G/{ns}/{id}?limit=100&cursor=
GET /fsi/v1/{emitter}/G?ns=&id=&actor=&related=&sort=time_desc&limit=100&cursor=
GET /fsi/v1/by-key/{address}?templateId=A|B|L|G

Key behaviors:

  • {ns} is either a readable string (e.g. points, encoded as a left-aligned zero-padded bytes32) or a 0x-prefixed 64-hex bytes32.
  • limit defaults to 100, max 1000. Page with the returned nextCursor. Lists may be cached for a few seconds.
  • A / B sort by value_desc (default), value_asc, updated_desc, or key. rank is returned only on value-sorted lists (B must include round); equal values share a rank. A single-key query returns 404 {"error":"not_found"} if the key was never written.
  • L lists support seq=0 (only the first record of each object, e.g. a post list), and actor / related filters. The detail endpoint returns every record of one object in ascending seq.
  • G lists filter by id / actor / related, filter amount with minValue / maxValue, and sort by time_desc (default), time_asc, value_desc, or value_asc. GET /fsi/v1/{emitter}/G merges every ns of one contract into a single activity stream.
  • by-key returns one address's rows across all emitters (with role=actor|related|both for L and G).
  • Numeric values (value, amount, extra, id, round) are returned as decimal strings to preserve uint256 precision. Every row carries blockNumber, logIndex, txHash, and updatedAt so callers can judge freshness.
  • REST returns only confirmed data (confirmed: true). During a short reorg, results may briefly show the pre-fork value and then recover.

Example β€” a points leaderboard (GET /fsi/v1/{emitter}/A/points):

{
  "emitter": "0xabc...",
  "template": "A",
  "ns": "points",
  "items": [
    {
      "emitter": "0xabc...", "ns": "points", "key": "0x111...", "value": "12345", "data": "",
      "blockNumber": 123, "logIndex": 4, "txHash": "0x...", "updatedAt": 1760000000,
      "confirmed": true, "trusted": false, "rank": 1
    }
  ],
  "nextCursor": "..."
}

Indexing scope, trusted, and quotas

  • Any contract that emits a matching event is indexed. No VaultPortal registration is needed β€” Vaults and non-Vault contracts (factory stats, Flap App business contracts) follow the same rules.
  • Every row carries a trusted flag: true when the emitter is a Vault registered on VaultPortal, otherwise false. It is informational only β€” it doesn't affect indexing, queries, sorting, or quotas. If a Vault registers after emitting, its existing rows simply start returning trusted: true.

Draft default quotas (same for every emitter; may change during testnet):

LimitDefault
data lengthTruncated past 1024 bytes
ns per emitter32
Rows per (emitter, ns)1,000,000

Events beyond the ns count or row cap are dropped; once a namespace is full, existing A/B keys can still be updated but new keys are rejected. L and G rows only ever grow β€” for hot contracts, split records across namespaces by type or time range, keeping the 32-ns limit in mind.

Versioning

  • FSI is versioned as a whole (fsi-v1, fsi-v2, …). Each version is a superset of the previous one.
  • A published template is frozen: its event signature, table structure, and write rules never change. New behavior ships as a new template. /fsi/v1/... paths stay available long-term, and contracts never need to migrate.
  • fsi-v1 = templates A, B, L, G. Further templates (composite keys, address-to-address relations, two-metric rows, an explicit delete event) are reserved and not indexed yet.