09-16 开始做 pmwallets,以实体为单位还原 Polymarket 账本,提供榜单和订阅钱包的成交推送。最初设计还包括从首次上榜日起累计的 forward 指标。产品主要提供钱包和实体级交易数据,榜单半公开。技术栈是 TypeScript + NestJS,账本和统计放 ClickHouse,账户、计费、订阅、游标和锁放 Postgres,前端 Next.js。这篇记 09-16 到 09-19 的架构和早期问题。
为什么不用官方接口
仓库 README 记录了当时遇到的官方接口限制:/trades?user= 只返回 taker 一侧,offset 到 1 万截断,/markets?slug= 对 5 分钟市场返回空,WebSocket 投递不完整。少了 maker 腿,就不能据此还原钱包的完整现金流。这些是当时的使用记录,不代表之后的接口版本仍有相同行为。
所以第一天定下的第一条规则是:成交、持仓、结算、赎回、费用,全部从 Polygon 日志解码得来。官方接口只用来补市场名称、分类这类元数据,缓存落库,缺了显式标记,不拿它们生成成交行,也不拿它们算 PnL。
数据契约
README 开头那一节我写成了「不可协商」,后面所有改动都要先对照它:
- 链是唯一真相源。
- 解码严格失败。已知 topic 解不出来就抛错,绝不静默跳过一笔成交;未知 topic 忽略。
- 撮合组必须自洽。
- 成交额按 taker 腿统计,全腿相加会双计。
- 提交模型在没有事务的情况下也要崩溃安全。
- 记录链上时间、看到区块头、拿到日志、落库四个时间戳,供延迟看板计算。后续看板改用收到区块头的时间作延迟起点。
第 3 条是我对解码结果最主要的校验。Polymarket 交易所的一次撮合由 N 个 maker 腿、1 个 taker 腿和 1 个 OrdersMatched 组成。maker 腿分两类:同一 token、方向相反的是对手盘(complementary);不同 token、方向相同的是交易所代为拆分或合并(mint/merge)。两条恒等式:
1
2
Σ maker.shares == taker.shares
taker.usdc == Σ_complementary maker.usdc + Σ_mint/merge (maker.shares − maker.usdc)
src/ledger/match.ts 的注释里记着,我拿 09-12 一天的 142 万个撮合组验证过,全部精确成立。不成立的组照样入库,同时写进 invalid_groups,数字要让用户自己能核。没有 taker 的 maker 序列和孤立的 OrdersMatched 各自成一个 invalid 组,不替它们编一个 taker 出来。
提交模型
ClickHouse 没有跨表事务,而一个区块的数据要写进 fills、ctf_transfers、resolutions、redemptions、splits_merges 等好几张表。重组和崩溃都可能发生在写到一半的时候。
做法是三步,顺序固定:
- 写这个块所有的事件行,每行带
blockHash和一个进程内严格单调的 version; - 写
ingest_blocks标记(chain, block) → blockHash,这一步决定哪个哈希是 canonical; - 删掉同一高度其他哈希的行,这一步只是清理。
读侧只许读 *_canonical 视图,视图是事件表按 (chain, block, blockHash) JOIN 标记表。崩在第 1 步和第 2 步之间,新行不可见,旧块仍然有效;崩在第 2 步之后,新块已经生效,剩下的孤儿行只是清理欠账,启动时 sweepOrphans 处理。裸表和 FINAL 都不算 canonical。
这一套是 09-16 当天和 Codex 来回六轮改出来的。第一版只是「有重组就删旧行」,后面陆续补上:每张表的行键里都带 blockHash(schema v4,配了一个崩溃点测试);新链更短时,高于新链头的孤块要整高度撤销。整高度撤销是因为 ReplacingMergeTree 有个坑:按哈希只删最新版本,旧版本会复活。version 的下限从库里已持久化的最大值起算,防止时钟回拨后复用。live 从 ingest_blocks 读游标并恢复最近 256 个哈希;库里没有游标、启动时也没给 --start 的话,拒绝启动。
两条输入
1
2
3
历史: S3 原始归档(zstd JSONL,200 块/段,finalized-only) ─┐
├─ processBlock ─ ClickHouse
实时: QuickNode WS newHeads ─ eth_getLogs(block) ────────┘
历史数据来自我另一个采集服务维护的原始链归档,每段 200 块,只收已最终确认的块。读的时候每段都过四条自检:行数等于区间长度、第 i 行的块号等于起点加 i、段与段之间区间连续、段内和跨段的 parentHash 连成链。任何一条不过就抛 ArchiveError,读了一半的段不会交给下游。
实时数据走 QuickNode 的 WebSocket 订阅区块头,每个头再用 eth_getLogs 拉日志。早期 README 记录过区块时间戳到拿到日志 p50 0.25s、p90 0.76s,后来发现区块时间戳可能超前墙钟,这组数不能当作实际处理延迟。范围扫描时 getLogs 单次不超过 2000 块,超限自动二分;单次返回达到 1 万条,怀疑被静默截断,继续拆到单块,并用 eth_getBlockReceipts 重建比对,对不上直接失败。扫描前后各取一次区块头,两次不一致就整批重跑。
token 属于哪个市场
成交日志里只有 ERC-1155 的 tokenId,要算到某个市场(condition)上,得知道 tokenId 和 conditionId 的对应关系。CTF 的 position id 是 keccak(collateral, collectionId),而 collectionId 来自链上的 bn128 曲线运算。曲线运算我没有自己实现,直接对 CTF 合约批量 eth_call 它的 view 函数 getCollectionId / getPositionId。
这里有个前提:抵押品是 position id 的一部分,市场可能用到的每一种抵押品都必须列出来。第一天我列了 USDC.e、NegRisk 的 wCOL 和 PUSD 三种,后来发现少了两种,榜单因此好几天发不出来,这件事放到下一篇写。
实体:代理钱包归到所有者
项目笔记记录,交易钱包大量使用代理合约。解析器支持 Polymarket 的信标代理、EIP-1967 代理,以及单一 owner 的 Gnosis Safe;多 owner 的 Safe 和未知合约不合并。
resolve-wallets 取运行时字节码,按模板判断类型。Safe 代理比代码哈希;Polymarket 的两种代理末尾嵌着 owner 地址,抹零后比模板哈希,再与 owner() 的返回值交叉核对。只有模板匹配的钱包才发 owner 查询;不匹配的合约保留为 contract。
实体 = owner EOA;解析不出来的钱包(直接交易的 EOA、多签 Safe、未知合约)各自成为一个实体。v1 不做任何猜测式合并,资金流和提现地址这类线索留到以后。实测下来实体和钱包基本是 1:1,所以「实体级」目前主要的作用是:成员之间的直接转账按同一时点、同一价格计价,在实体层抵消。
PnL 的口径
最小核算单位是 (钱包, 条件),期间 (T0, T1] 的盈亏:
1
pnl = 期间现金流 + 期末持仓价值 − 期初持仓价值
现金流包括买卖、费用、拆分合并、赎回,以及按公允价对称计入的直接转账。估值优先读结算 payout,再读标记价、互补 token 的价格;都拿不到就标 unpriced,排除并单独计数。价格不变的未结算买入,其现金流与期末价值抵消;期初已有仓位,先扣期初价值。docs/pnl-model.md 中从首次上榜日起算的 forward 指标属于最初设计,不能混同于后来发布的 30d / 7d 窗口统计。
这份口径写在 docs/pnl-model.md。compute-pnl 用 ClickHouse 的 INSERT…SELECT 实现,合成账本测试覆盖核算场景。09-16 的提交记录显示整个测试集达到 81 个测试,并非全是 PnL 规格用例。
上线头几天
09-17 部署到一台 2 核 4G 的机器上,问题集中出现:
- live 在 pm2 下显示 online,却没有执行
main()。入口用process.argv[1]判断是否直接执行,而 pm2 fork 容器通过import()加载脚本,argv[1]是ProcessContainerFork.js。修复去掉 argv 判断,并加了通过 loader 加载 CLI 的回归测试。 - 对 2000 块的日志集合用了展开运算符传参,调用栈溢出。
rpc-scan把整个区间解码出来的行都留在内存里做摘要,补 4.5 万块时涨到 2.2GB,在 4GB 的机器上被 OOM 杀掉,摘要改成流式统计。- PnL SQL 里裸写的列名
from,ClickHouse 26.8 会当成 FROM-first 查询的开头,报 SYNTAX_ERROR;我本机的 24.8 能接受,所以本地测试一直是绿的。 - compute-pnl 跑一天的数据超过了客户端默认的 30 秒超时。
这台机器的 ClickHouse 内存上限只有 1.63 GiB,compute-pnl 一直失败。09-18 我迁到了内存更大的机器;之后还要处理映射、发布校验和 SQL 内存问题,榜单才发布成功。
09-19 加了 RPC 故障转移。端点先用 eth_chainId 校验链 ID 为 137;账本读取还要求端点在受信列表中,链身份正确不等于日志完整性已验证。WS 给的区块头要按高度核对是否在主链上,因为孤块也可能按哈希查到。
另一处是 S3 读取挂起。archive-scan 卡了半小时,请求既不报错也不结束,SDK 不重试,外层的重试循环也没用,因为进程根本没退出。我加了建连的 abortSignal 和读取期间的空闲计时器。第一版有两个问题:超时抛的是 ArchiveError,而 archive-sync 把它当成归档永久损坏直接退出;计时还覆盖了整个循环,下游一次慢的 ClickHouse 落库也会被算成下载停滞。改成单独的 ArchiveStalled 类型、按瞬时故障退避重试,计时只覆盖等待源数据的那一段,yield 出去之后停表。
pmwallets 工程笔记(1/7)