Flap 共享索引器(FSI)

FSI 是 Mini App 的后端层,对 Vault、工厂合约及其他任何合约同样适用。

Beta。 FSI 目前处于 beta 阶段。下文的事件签名为 fsi-v1 模板。上线顺序为 BNB Chain 测试网(97)→ BNB Chain 主网(56)→ Robinhood Chain(4663)。我们暂不公开 FSI 端点 —— 如需访问或参与测试,请联系我们。

合约只要按 FSI 的固定模板发出事件,其数据即可在链下被查询和排序 —— 排行榜、已领取金额、轮次统计、帖子时间线、领取 / 复投 / 销毁日志 —— 开发者无需运行索引器、数据库或查询 API。 Flap 在每条链上运行一个索引器,监听这些模板、存储每一行数据,并通过 HTTP 提供给你的 Mini App UI、Vault UI 或任何第三方。

FSI 适用于任何合约 —— Vault、工厂,或 Mini App 的业务合约 —— 规则完全相同,无需注册。

模板

需求模板事件主键存储内容
某地址当前的余额、积分、已领取金额或份额AFlapSharedIndexerAddrUint(emitter, ns, key)每个地址一个最新值
同上,但按轮次 / epoch 划分BFlapSharedIndexerUintAddrUint(emitter, ns, round, account)每个轮次 + 账户一个最新值
必须保留的有序历史:帖子及其更新、公告、回购记录LFlapSharedIndexerFeed(emitter, ns, id, seq)每一条记录,序号由合约分配
低成本活动日志:领取、复投、销毁、自动领取、单笔交易GFlapSharedIndexerLog(emitter, txHash, logIndex)每一条日志,以其链上位置为键;合约不为其存储任何状态
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
模板topic0
A0x6d92bb4f14ee2a433bdba0d337dc697cf6017066878befb1a42edf326092dba3
B0x9cdf0eeb5c96afc4a86997e5b946603d187dfc2f1119862ad053c413de36dd28
L0x262063c773bcbfa80d8648888e2f288454339e80d08c1c8f78aa6e55c2db75cb
G0x0483a030f7f57a91177910ed00ae51f19170eb9db2129394c858e7e3bc188aed

所有参数都不使用 indexed(以节省 gas)。索引器只按 topic0 过滤,其余字段在后端解码。不匹配任何模板的事件会被忽略。

字段含义
ns命名空间(bytes32)。同一合约用不同的 ns 区分多张逻辑表,例如 "points" 和 "claimed"。只需要一张表时可用 bytes32(0)。
key(A)行主键(地址)。
round、account(B)轮次 / epoch / ID,以及用户地址。纯轮次统计(轮次总量、全局计数器)使用 account = address(0)。
id、seq(L)记录所属对象(例如帖子 ID),以及该对象内的序号 —— 由合约分配:首条记录为 0,之后依次为 1、2……
id(G)所属对象或类型码,用于过滤(某个池子、某个 epoch,0 = 领取 / 1 = 复投)。
actor、related(L、G)提交者 / 操作者,以及另一个绑定地址(例如受益人)。不使用时传 address(0)。
value(A、B、L)A、B:该行的当前绝对值,可排序。L:排序字段,通常为 block.timestamp。
amount、extra(G)本次操作的数值(非累计值)。amount 可排序、可过滤;extra 原样返回。
data(A、B、L)可选字符串,不可排序,原样返回。超过 1024 字节的部分会被截断;建议控制在 256 字节以内。L 中通常为内容 JSON 的 CID。

始终适用的两条规则

  1. 事件必须由你的合约自身发出。 索引器从 log.address 取得 emitter,因此只有你的合约能写入你合约的数据行。
  2. FSI 是链下缓存,绝不能作为结算依据。 涉及资金的逻辑(例如用户可领取多少)必须读取链上状态。绝不能依据 FSI 的响应进行结算。

基础合约

Flap 提供一个无依赖的抽象合约 FlapSharedIndexerV1。继承它并使用其辅助函数 —— 它们让"发出绝对值"成为默认行为:

// 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() 声明合约所基于的 FSI 版本。开发前请先确认目标链已支持你所用的模板(GET /fsi/versions,见查询 FSI)。

写入语义

模板 A 和 B —— 最新绝对值。 对每个主键,索引器只保留最新值。它不累加增量,也不保留历史。

  1. value 必须是状态更新完成后的当前值。先更新存储,再把从存储读回的值写入事件。
  2. 只有链上位置 (block, logIndex) 严格更晚的事件才会覆盖已存储的值。更旧的或重复投递的事件不会让数据回滚。同一区块内,logIndex 最大的事件生效。
  3. 写错了只需再发一次正确的当前值即可修正 —— 无需数据迁移。
  4. 没有删除事件:发出 value = 0 即视为清空该行(该行仍占用配额;查询时可用 minValue=1 过滤掉)。
错误(增量)正确(绝对值)
_fsiSetAddr("points", user, 50),其中 50 是本次增量_fsiSetAddr("points", user, points[user])
原样复用 ERC20 Transfer(from, to, amount)为 from 和 to 各发一个事件,值为转账后各自的余额
Claimed(user, amount)_fsiSetAddr("claimed", user, claimed[user]) —— 累计已领取金额

模板 L —— 只追加。 每个 (emitter, ns, id, seq) 都是一条独立记录;原始记录和每次更新都会被保留。主键重复时,链上最早出现的记录生效,后来的重复事件会被丢弃 —— 因此追加时务必使用新的 seq。没有删除操作;如需隐藏某个对象,可用模板 B 维护一个标记(例如 ns = "hidden"、round = id、account = address(0)、value = 1)。创建或追加的权限由你的合约控制,索引器不做权限检查。

模板 G —— 只追加,无需序号。 每条日志按 (txHash, logIndex) 只存储一次;一笔交易可以发出多条日志。G 事件不写入任何链上状态,每条约消耗 2,000–3,000 gas。记录无法编辑或删除 —— 如有错误,请按你自定义的约定再发一条更正记录。发生链重组时,被回滚区块中的日志会被删除,并按新的规范链重新写入;合约无需处理。累计值请用 A/B;G 用于记录单次操作的数值。

示例:同时使用四个模板的 Mini App 合约

一个小游戏,记录累计积分(A)、每轮得分与轮次总分(B)、帖子及仅作者可追加的更新(L),以及领取日志(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
    }
}

仅凭这些事件,Mini App UI 就能展示全局积分排行榜、每轮排行榜、带完整更新历史的帖子信息流,以及"我的领取记录"列表 —— 无需开发者运行任何后端。

查询 FSI

每条链一个 FSI 实例;路径中不包含链 ID(实例所在的链由 GET /fsi/versions 返回)。地址使用小写十六进制。

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

主要行为:

  • {ns} 可以是可读字符串(例如 points,编码为左对齐、右侧补零的 bytes32),也可以是 0x 前缀的 64 位十六进制 bytes32。
  • limit 默认 100,最大 1000。使用返回的 nextCursor 翻页。列表可能会缓存几秒。
  • A / B 支持按 value_desc(默认)、value_asc、updated_desc 或 key 排序。只有按值排序的列表才返回 rank(B 必须带 round);值相同的行排名相同。若某个 key 从未写入,单行查询返回 404 {"error":"not_found"}。
  • L 列表支持 seq=0(只返回每个对象的首条记录,例如帖子列表)以及 actor / related 过滤。详情接口按 seq 升序返回某个对象的全部记录。
  • G 列表可按 id / actor / related 过滤,用 minValue / maxValue 过滤 amount,并支持按 time_desc(默认)、time_asc、value_desc 或 value_asc 排序。GET /fsi/v1/{emitter}/G 会把同一合约的所有 ns 合并为一条活动流。
  • by-key 返回某个地址在所有 emitter 下的数据行(L 和 G 支持 role=actor|related|both)。
  • 数值字段(value、amount、extra、id、round)以十进制字符串返回,以保留 uint256 精度。每一行都带有 blockNumber、logIndex、txHash 和 updatedAt,便于调用方判断数据新鲜度。
  • REST 只返回已确认的数据(confirmed: true)。短暂的链重组期间,结果可能暂时回到分叉前的值,随后自动恢复。

示例 —— 积分排行榜(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": "..."
}

索引范围、trusted 与配额

  • 任何发出匹配事件的合约都会被索引。 无需在 VaultPortal 注册 —— Vault 与非 Vault 合约(工厂统计、Mini App 业务合约)遵循相同规则。
  • 每一行都带有 trusted 标记:emitter 是在 VaultPortal 注册的 Vault 时为 true,否则为 false。它仅供参考 —— 不影响索引、查询、排序或配额。如果 Vault 在发出事件之后才完成注册,其已有数据不变,只是查询结果中的 trusted 随之变为 true。

草案默认配额(对所有 emitter 相同;测试网阶段可能调整):

限制默认值
data 长度超过 1024 字节截断
每个 emitter 的 ns 数量32
每个 (emitter, ns) 的行数1,000,000

超过 ns 数量或行数上限的事件会被丢弃;命名空间写满后,已有的 A/B 键仍可更新,但新键会被拒绝。L 和 G 的行只增不减 —— 高频合约请按类型或时间段拆分到多个命名空间,同时注意每个 emitter 最多 32 个 ns。

版本

  • FSI 整体按版本发布(fsi-v1、fsi-v2……)。每个版本都是前一版本的超集。
  • 模板一经发布即冻结:事件签名、表结构和写入规则都不再改变。新行为以新模板的形式发布。/fsi/v1/... 路径长期可用,合约永远无需迁移。
  • fsi-v1 = 模板 A、B、L、G。后续模板(复合键、两个地址之间的关系、双指标行、显式删除事件)已预留,但暂未索引。