dapp

代码库中的应用是 cairn-app/:一个使用 App Router 的 Next.js 16 前端,配合 Tailwind v4,钱包部分使用 wagmi 和 viem,本地运行在 3100 端口。它于 2026-10-02 取代了此前的 React + Vite dapp。它采用项目的新名称 Cairn.fun,在这一名称下,协议代币是 $CAIRN;本书保留 StockFun 这一名称。

在配置好部署之前,应用运行在标注为“Illustrative”的演示数据上,而且尚未部署任何东西。本页描述的是它目前的状态。

页面

路由 页面
/ 落地页:下一次空投、市场行情条、公开的空投记录、费用计算器、篮子、协议代币、常见问题
/markets 发现:国库动态、协议代币的信息条、筛选、排序和搜索、市场表格
/market/[slug] 市场详情:图表、空投面板、成交记录、在签名之前显示费用分配的交易面板、区间 1 的进度、合约、钱包的持仓
/launch 发射表单:身份、篮子选择器、实时预览、不可逆性检查清单。自第八个审计循环起,它只提供从链上读取的篮子,并在发送之前再次与链上核对所选的篮子
/claim 持有者的空投领取,按时间窗口逐一列出,以及创建者费用
/drops 每一次空投,按时间窗口逐一列出
/docs 机制、费用、篮子、空投、协议代币、局限

Treasury Ratio 自空投决定以来已经废弃,此前的 dapp 在 2026-09-28 仍显示它,而代码库中的应用不再出现它。取代它的指标仍待确定。

领取页面

自 2026-10-05 起,/claim 按市场逐一列出已连接钱包可以领取的每一个时间窗口:时间窗口的结束时间、其中的股票、数额及其美元价值。这些数额只来自空投合约(claimable),在页面打开期间每 60 秒重新读取一次,并在钱包的每一笔交易之后重新读取。空投合约在一次紧急转移之后短缺的股票会被标记为“awaiting settlement”,并被排除在领取之外。

列表下方是总额和“Claim all”,后者以每批五个时间窗口的方式发送 claimMany(在以太坊的 Glamsterdam 升级于 2026-10-06 到达 Sepolia 之前为十个,见下文);对于含有一只等待结算股票的时间窗口,则发送列出其他股票的 claim。每一个调用都先经过模拟:一个会失败的批次会被拆分为其中的各个时间窗口,一个时间窗口会被拆分为其中的其他股票,因此当下无法支付的时间窗口或股票会被跳过并注明,绝不会拖垮其余部分。无法读取回执的交易会保留其哈希,并显示为已发送、确认尚未读取;不会重新发送任何东西,页面会一直跟踪它,直到它本身或在它的位置上的一笔交易被打包:自第十个审计循环起,钱包中的加速会被视为这次领取本身,“View tx”指向它,而取消或任何其他替换会把这次领取作为未完成结束,“View tx”指向被打包的那笔交易(见下文)。结果会说明已支付的部分、被延后且仍然待付的股票,以及被跳过的时间窗口及其原因。

自 2026-10-06 起:

  • 只有钱包的拒绝才会中止运行,自第十个审计循环起,钱包中的取消或替换也会中止运行。失败的交易会被计入(“did not go through”),它所涵盖的部分仍保留在列表中,其他交易照常发送
  • 为仍在途中的股票留出余量。claimMany 会支付时间窗口在其执行时列出的每一只股票,而一个仍接受送达的时间窗口,可能在估算与打包之间再被计入一只股票:应用会为篮子中该时间窗口尚未列出的每只股票增加 400,000 gas(在 Glamsterdam 升级之前为 100,000)。未使用的 gas 不会被收取
  • 按 Glamsterdam 确定的 gas。Sepolia 于 2026-10-06 启用了以太坊的 Glamsterdam 升级,这让一个新的存储槽的 gas 成本约为原来的五倍;主网当时还没有确定日期。一次领取所支付的每只股票可能写入三个新的存储槽,因此应用为每只仍在途中的股票留出 400,000 gas,并且每笔交易领取五个时间窗口,而十个各含五只股票的时间窗口可能需要多达约 1,400 万 gas
  • 每笔交易自己的 gas。在这次升级下,一个从零开始写入的存储槽约需 110,000 gas,而一笔交易可能遇到它的估算从未见过的存储写入:刚刚被一次领取清空的 Hook 手续费累计(其中两种领取任何人都可以调用)、下一个小时的供应量标记、交易者自己在同一区块中被改动的记录。自第九个审计循环起,应用给每笔交易其估算值加 50,000 gas,而对于在最新区块上估算的交易,还会加上每一项仍可能发生的此类写入的 gas,并在同一区块上读取(每项累计 112,000,供应量标记 150,000,记录 135,000;读取失败时全部加上)。估算失败的交易会使用按其最重情形确定的固定上限发送,绝不使用钱包自己的估算,因为后者没有余量;此时发射不会被发送,页面会提示重试。在此之前,每笔交易在其估算之上获得 150,000 gas(升级之前为 35,000)。钱包在签名之前会预留上限乘以其费用的金额;只收取实际使用的 gas
  • 上次领取时未支付的股票。因代币拒绝向该钱包转账(例如发行方冻结)而被某次领取延后的股票,会由浏览器记住,显示为“not paid at your last claim”,并被排除在“Claim all”之外。每次领取都会先单独尝试它,一旦它的领取能够成功,就把它重新纳入;当没有其他待领取的部分时,按钮会显示“Try the assets not paid again”
  • 未经检查,不等于被拒绝。只有当节点说明调用为何会失败时,模拟结果才算数。检查时遇到 RPC 错误的时间窗口仍会列出,标为尚未检查;当没有任何模拟作出回应时,不会发送任何东西
  • 未读取不等于没有。数据无法读取的市场会被注明,绝不会显示为不欠任何东西:只有在每个市场都已读取时才会出现“Nothing to claim”,创建者部分会说明其市场无法读取,而不是显示“You haven't launched a market”

创建者费用在同一页面上领取,逐个市场进行。

数据不可用

自 2026-10-05 起,如果一个国库的金库不响应其自身的视图函数(例如在一次有缺陷的升级之后),该国库会显示“Figures unavailable”,而不是它的数据:在市场列表的行和卡片中、在协议代币的横幅上,以及在市场页面上。它的股票和 ETH 仍会列出,市场页面上的总额会说明它排除了多少个国库。应用从 Lens 得知这一点,而 Lens 在单独的调用中读取每个金库:参见架构。

自 2026-10-06 起,Hook 和流动性锁定合约在金库拒收一笔支付之后为该金库保留的 ETH,会计入其国库、其价值和各项总额:显示为一行“ETH owed”,并附注说明它尚未进入金库,一旦金库接收就会转入。它在 Hook 和锁定合约上读取,因此即使金库不响应也能保持最新。一个不响应且从未被读取过的金库,会采用协议跨链桥配置所隐含的通道,空投面板会说明这一点:它的股票所在位置是推断出来的,其持有的资产可能不完整。当 Lens 无法读取协议代币的国库时,协议代币的市场会以最后一次读取的数据留在屏幕上,并标为过时,而不是显示为尚未发射。

市场页面显示的内容

自 2026-10-06 起:

  • 已回收的池子没有图表。一旦锁定合约的终止模式取出了某个池子的流动性,任何人都可以免费推动它的价格。页面原本就不为它显示价格和交易;现在也不再显示 24 小时涨跌和图表,并说明原因,Worker 也停止对它的价格采样
  • 一笔交易用于股票的份额,是这笔交易自身的各项税费份额发往国库的部分,按它支付时的设置计算,而不是按今天的设置;未知时显示“—”
  • 市值按流通供应量计算,即总量减去销毁地址持有的部分,页眉和图表中都是如此;自第七个审计循环起,当供应量无法读取时,它显示“—”,绝不显示 $0
  • 无法读取的余额绝不显示为零:持仓面板会说明它无法读取,交易面板会显示最后一次读取的余额,标为“last read”,且不会拒绝高于该余额的卖出;交易的模拟会拒绝真正的透支
  • 以最后一次读取的数据保留的协议代币市场会说明这一点。当 Lens 无法读取其国库时,它的价格和市值在所有显示的位置都会标为“(last read)”,不显示 24 小时涨跌,它的图表显示“Last read”而不是“Now”。在 Lens 重新响应之前,Worker 不会为它记录任何价格,因此这次中断不会在图表上增加任何数据点。它当前的税率未知:页面不显示防狙击标签,交易面板显示当前生效的正常税率,没有超出部分那一行。其代币自身的数据仍在代币上读取,因此落地页上的“Burned by buyback”保持最新;它的市值是最后一次读取的数值。自第七个审计循环起,这次中断在图表上显示为一段空缺,最后一次读取的价格标有其距今的时间;无论市场是被读取还是以最后一次读取的数据保留,代币的数据在没有响应时都采用最后一次读取的数值,绝不显示为零或空名称;协议中某个合约在一次有缺陷的升级之后失效,也不再会让它们变成空白

自第七个审计循环(2026-10-06)起:

  • 图表把每个数据点放在它的时间上。每个价格样本都位于其时间在范围上所对应的位置:1H、24H 和 7D 以当下为终点,“All”从第一个样本开始,而不是从市场的存续时长开始。悬停的数据点会显示它距今多久(“3h 20m ago”),在缺少样本的地方(一次中断、一个没有人打开页面的夜晚)线条会断开,静止时图表显示市场的价格、“Now”,或者对于以最后一次读取的数据保留的市场显示“Last read”。在此之前,数据点均匀分布,并按其位置标注时间,因此中断之前最后一次读取的价格会显示为当前价格
  • 在无法统计时,24 小时交易量显示“—”。当 Worker 对交易日志的读取持续失败时,它的交易窗口会停止前移;这时,交易量在每个市场和各项总额中都显示“—”,而不是一个仿佛交易已经停止般不断缩小的数字,并在第一次赶上进度的读取时恢复

自第八个审计循环(2026-10-06)起:

  • 白名单钱包看到的是正常税率。在一个市场的防狙击期间,已连接且在该市场白名单上的钱包通过 StockFun 路由器支付正常税率,而交易面板正是通过这个路由器进行交易:面板现在不再为它显示防狙击的超出部分,并在一条附注中说明原因。报价本来就是正确的;那一行超出部分与之矛盾。在白名单或钱包未知时,面板显示不计豁免的税率
  • 为什么一只股票没有价格。在 Robinhood Chain 上,预言机会在公司行动期间暂缓给出该股票的价格,并在排序器宕机或刚刚恢复时暂缓给出每只股票的价格,后一项检查在 Chainlink 为该链发布在线状态喂价之前保持关闭(参见Robinhood 通道)。当一只股票的价值因此缺失时,应用会说明这一点:“No price for NVDA right now: corporate action in progress”,或者 Robinhood Chain 的排序器已宕机、正在恢复或其状态未知,显示在空投面板的篮子表格下方和落地页的空投卡片下方,以及领取页面上该股票的旁边。落地页的卡片过去把这样的股票显示为 $0.00;现在它显示“—”
  • 24 小时交易量对每个区块只统计一次。由比前一个节点落后几个区块的节点所响应的一次 Worker 读取,不再会让交易窗口把相同的区块统计两次。在这一次读取中,最新的交易可能会从数据流中缺失;下一次读取会把它们补回

自第九个审计循环(2026-10-06)起:

  • 作为估算值的国库金额会说明这一点。当国库的一部分无法读取或定价时(一只其价格被预言机暂缓给出的股票、一个过期的 ETH/USD 喂价、一个以最后一次读取的数据保留的镜像金库),它的数值只计入能够定价的部分,并且现在无论显示在哪里都带有“+”:市场列表的行和卡片、协议代币的卡片、国库动态、落地页、空投面板以及持有者的份额。市场列表会说明这样的国库金额按能够定价的部分排序,国库动态会说明它以这种方式计入了多少个国库。在此之前,只有空投面板会显示“an estimate”
  • 无法读取的授权不等于零。通过 StockFun 路由器卖出之前,需要先给该路由器授权额度。过去,读取授权失败会被当作没有授权:已经授权过的持有者会被要求再次授权,需要两次签名,而第二次失败会发出一笔毫无用处的授权。交易面板现在会说明授权无法读取,不给出任何报价,并且在读取成功之前既不发送授权,也不发送卖出。自第十个审计循环起,紧随授权之后的卖出会在不早于该授权所在区块的区块上读取授权额度、给出报价并接受检查(见下文),因此落后一个区块的节点不再可能回答为零

自第十个审计循环(2026-10-06)起:

  • 钱包取消的交易绝不会显示为已完成。钱包可以取消一笔待处理的交易(在同一 nonce 上向自己转账零金额),也可以加速它(以更高的费用发送同一调用)。应用现在会读取在它的位置上被打包的内容:加速会被视为该操作本身,“View tx”指向它;取消或任何其他替换会把该流程作为未完成结束,显示“Cancelled in your wallet.”或“Replaced by another transaction in your wallet.”,“View tx”指向被打包的那笔交易,并像钱包的拒绝一样中止一次领取运行。在此之前,被取消的交易会显示“Done”,被取消的授权会被算作已授权,被取消的创建者领取会显示“Claimed”并在本次会话期间隐藏该 ETH,被取消的发射会显示“$TICKER is live.”以及演示数据中虚构的代币地址;在通常的三分钟等待之后才被加速的交易永远不会被确认,面板会一直锁定,直到页面重新加载
  • 等待之后继续跟踪。一旦通常的等待在三分钟后放弃,应用就会按 nonce 跟踪这笔交易:当钱包已发送交易的计数越过该 nonce 而这笔交易没有回执时,它会查找在该 nonce 上被打包的内容,绝不在发送之前的区块中查找,并适用同样的规则。取消或替换只会依据实际在区块中找到的交易来判定,绝不依据缺失的回执
  • 只有链上这样说时,发射才算上线。发射会从工厂合约自身的事件中读取市场的创建;没有该事件时,它会带着交易的链接回到表单,演示数据中的地址绝不会出现在演示之外
  • 一分钟内,以你最后一笔交易的区块为准。在它自己的一笔交易被打包之后的 60 秒内,应用会在不早于该区块的区块上检查、估算并报价它接下来要在该链上发送的内容,并在那里读取卖出的授权额度。负载均衡的端点可能由落后一个区块的节点作答:紧随授权之后的卖出过去会在那里被拒绝(“Approve it first”),而第二次尝试可能发出第二笔授权。尚未到达该区块的节点现在会被视为落后并被再次询问;如果在几秒内没有节点到达该区块,就不会发送任何东西。对于紧随授权之后的卖出,页面此时会显示“Your approval went through, but no quote could be read from the pool, so the sale was not sent…”(它的报价是在那里进行的第一次读取);而自身的检查无法到达该区块的写入交易(授权、领取、发射)会显示“This could not be checked against the block of your last transaction just now, so nothing was sent. Try again in a moment.”
  • 仍然存在的情况。只有在一种狭窄的情形下,仍可能出现错误的“Replaced”,需要以下条件同时成立:没有任何节点显示这笔卖出,因此应用根据钱包的计数猜测了它的 nonce;一个落后于其他节点、但不落后于发送前所读取区块的节点回答了这一计数;同一钱包的另一笔交易(该节点未曾看到)占用了猜测的 nonce;并且这笔通过私有中继发送的卖出,在三分钟以及约 36 秒的宽限期之后仍未被打包。取消绝不会显示为已完成,也不会有任何东西保持锁定

发射

发射表单只提供从链上注册表读取的篮子,并使用注册表自己的编号:在第一次读取之前,它不提供任何篮子(“Reading the baskets from the chain…”),而在合约部署之前,它会说明发射将在合约上线之后开放。就在发送之前,它会从工厂合约再次读取创建费和所选的篮子(其名称以及其股票和权重),只要其中任何一项与它所显示的内容不同,就拒绝发送,什么也不发出;提示信息会指出链上在该编号下保存的是哪一个篮子。在第八个审计循环之前,表单在第一次读取之前提供的是配置中的篮子及其配置编号,而在一个注册表以其他方式为篮子编号的部署上,可能会在一个与所显示的篮子不同的篮子上发射市场。表单只支付创建费:它没有创建者自己的买入,也不指定防狙击白名单,并会说明这一点(工厂合约在直接调用时两者都接受:参见发射市场)。确认信息会写明发送时核对过的篮子。

视觉方向

应用的设计规则保存在它自己的 DESIGN.md 中:白色画布上的温暖浅色主题,赤陶色用于空投和关键数字,界面使用 Poppins,大号金融数值使用 Instrument Serif,表格中使用等宽数字。

数据从何而来

费用、发射参数、供应量和篮子构成从不写死在组件中:它们保存在应用的配置中,或者来自链上,而各项费用份额在启动时会与从 @stockfun/shared 生成的常量进行核对,后者与合约保持一致。在页面里手动键入一个百分比,正是一个产品最终宣传合约并未实现的费率结构的由来。自 2026-10-05 起,这些数值都是所有者的设置:shared 包保存它们的默认值,实时数值则从持有它的合约中读取。

协议状态来自链上;没有索引器。一个 Worker,即 cairn-worker/,为所有打开的页面只读取一次协议状态:先是一份快照,然后是一个只推送变化的 WebSocket。当 Worker 失效时,页面会通过一个公共 RPC 自行读取链上数据。钱包自身的数据,从余额到可领取的部分,都由浏览器读取。自第七个审计循环起,Worker 只读取声明自己服务于其所在链的端点:位于另一条链上的端点被视为不可用,因此一个位于错误网络上的备用端点永远不会让所有市场消失。自第八个审计循环起,它发布第八版数据模式,其中增加了股票价格缺失的原因;读取旧版数据模式的应用仍然可以工作,只是没有这一原因。自第九个审计循环起,Worker 的监视器在其 Cloudflare 对象于两次读取之间休眠时,仍会保留它对其 RPC 所了解的情况(哪个端点在失败以及从何时开始、需要退避多久、每个端点服务于哪条链),但绝不保存任何端点的地址:主 RPC 的一次中断,每经过一次加倍的暂停只花费一次探测,而不是每次读取都发出三个请求,而一次正常的读取不会重复询问任何东西。它还记录 Robinhood Chain 自身的区块号,而过去它记录的是 Robinhood Chain 进行结算的那条链的区块。

文案审计

pnpm audit:copy 扫描 backend、keeper、shared 和合约的源代码。只要出现一个禁用词,或触犯一项可静态检查的视觉禁令,它就会失败。它按需运行,并不属于 pnpm build。自从此前的 dapp 离开代码库之后,它不再扫描任何前端:在应用中,禁用词汇是一条审阅规则,写在它的 PRODUCT.md 中。

它不是风格 linter:禁用词汇是一项法律约束。