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
| Need | Template | Event | Primary key | What is stored |
|---|---|---|---|---|
| Current balance, points, claimed amount, or share for an address | A | FlapSharedIndexerAddrUint | (emitter, ns, key) | One latest value per address |
| The same, scoped to a round / epoch | B | FlapSharedIndexerUintAddrUint | (emitter, ns, round, account) | One latest value per round and account |
| An ordered history you must keep: a post plus updates, announcements, buyback records | L | FlapSharedIndexerFeed | (emitter, ns, id, seq) | Every record, with a contract-assigned sequence number |
| A low-cost activity log: claim, compound, burn, auto-claim, a single trade | G | FlapSharedIndexerLog | (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| Template | topic0 |
|---|---|
| A | 0x6d92bb4f14ee2a433bdba0d337dc697cf6017066878befb1a42edf326092dba3 |
| B | 0x9cdf0eeb5c96afc4a86997e5b946603d187dfc2f1119862ad053c413de36dd28 |
| L | 0x262063c773bcbfa80d8648888e2f288454339e80d08c1c8f78aa6e55c2db75cb |
| G | 0x0483a030f7f57a91177910ed00ae51f19170eb9db2129394c858e7e3bc188aed |
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.
| Field | Meaning |
|---|---|
ns | Namespace (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
- The event must be emitted by your contract itself. The indexer takes
emitterfromlog.address, so only your contract can write your contract's rows. - 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.
valuemust be the current value after your state update. Update storage first, then emit the value read back from storage.- 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 greatestlogIndexwins. - To fix a wrong value, just emit the correct current value again β no migration needed.
- There is no delete event: emitting
value = 0clears a row (it still counts toward the quota; filter it out withminValue=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-paddedbytes32) or a0x-prefixed 64-hexbytes32.limitdefaults to 100, max 1000. Page with the returnednextCursor. Lists may be cached for a few seconds.- A / B sort by
value_desc(default),value_asc,updated_desc, orkey.rankis returned only on value-sorted lists (B must includeround); equal values share a rank. A single-key query returns404 {"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), andactor/relatedfilters. The detail endpoint returns every record of one object in ascendingseq. - G lists filter by
id/actor/related, filteramountwithminValue/maxValue, and sort bytime_desc(default),time_asc,value_desc, orvalue_asc.GET /fsi/v1/{emitter}/Gmerges everynsof one contract into a single activity stream. - by-key returns one address's rows across all emitters (with
role=actor|related|bothfor L and G). - Numeric values (
value,amount,extra,id,round) are returned as decimal strings to preserveuint256precision. Every row carriesblockNumber,logIndex,txHash, andupdatedAtso 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
trustedflag:truewhen the emitter is a Vault registered on VaultPortal, otherwisefalse. It is informational only β it doesn't affect indexing, queries, sorting, or quotas. If a Vault registers after emitting, its existing rows simply start returningtrusted: true.
Draft default quotas (same for every emitter; may change during testnet):
| Limit | Default |
|---|---|
data length | Truncated past 1024 bytes |
ns per emitter | 32 |
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.