Posts 稳定币收款的两次事故:精确金额、币种等价和一张过期订单
Post
Cancel

稳定币收款的两次事故:精确金额、币种等价和一张过期订单

  outcometick 除了 Stripe,也支持稳定币付款。买家把 USDT 或 USDC 转到我在币安的充值地址,后台每 30 秒轮询一次充值记录,对上订单就开通权限。这一周(09-05 到 09-11),这条链路出了两次事故,排查时又翻出一个藏了很久的问题。三次都是买家已经付款,代码却没接住。

先说识别方式

  收款地址只有一个,所有订单、所有币种、所有网络共用。链上转账带不了订单号,所以唯一能把一笔充值和一张订单对上的东西是金额:订单价后面加一个 6 位小数的随机尾数,比如订单价 6 美元,报给买家的是 6.000xxx。只要同一时刻不存在两张金额相同的待付订单,一笔充值就只能属于一张单。

  几个常量:

1
2
3
export const RESERVE_MS = 2 * 60 * 60 * 1000;   // 金额建单后 2 小时内不再报给别人
export const ORDER_TTL_MS = 60 * 60 * 1000;     // 订单 1 小时过期
export const LATE_GRACE_MS = 30 * 60 * 1000;    // 到账晚于过期时间 30 分钟以内仍认

  另外一个前提:稳定币这条轨上,key 不是结算时发给买家的。结算只开通权限,key 由买家停留的 /pay/<订单号> 页面轮询到「已付款」后自己领取(claim),领取那一刻才 mint。这样一来,「买家还在不在页面上」就成了交付的一部分。

09-05:钱到了,key 从来没被签发

  这天有一位买家在回测页写完策略,免费校验过了两次,先点了卡支付,又改成稳定币买最小那档 credits。

  第一笔他转了一个整数金额,没带那 6 位小数。这笔撞不上任何订单,触发了「无法对应订单的付款」告警。4 分钟后他又转了一笔,这次金额一分不差,正确匹配。

  币安显示的第二笔到账时间,和我这边的结算时间相隔 13 分 45 秒。延迟来自链上确认和币安入账,和 30 秒轮询无关。买家在 /pay 页面只等了大约 3 分半就离开了。

  结果是 credits 已经入账,key 却从来没有 mint。库里那张订单的 api_key_id 是空的。稳定币订单不收邮箱,我没办法主动联系他。

  查这件事时我发现了两个问题。

  第一,没有任何巡检会因为这种情况报警。已有的巡检 claimUnackedKeyOrders 的条件是 api_key_id IS NOT NULL,它管的是「key 已经 mint 了,但页面上没有确认收到」。「付了款、key 根本没 mint」不在它的范围里。

  第二,成交告警的文案写着「已自动开通并签发 key」。稳定币这条轨上结算只开通权限,key 一律靠页面领取,这句话会让人以为已经交付了。

  再看 08-28 那两笔稳定币 credits 订单,它们都在付款后 2 到 3 分钟内结算,买家还留在页面上,所以都领到了 key。

  这次我没有马上改代码,先确认怎么判断是否真正交付:看 api_key_id(有没有 mint)和 key_acked_ms(有没有在页面上显示给买家),不能看告警。买家其实可以自己恢复:重新打开 /pay/<订单号>,页面第一次轮询就会发现订单已付款、key 还没领,然后自动 mint 并显示,不需要人工处理。

  昨天(09-11)这位买家发来邮件,说 key 丢了,还附上链上交易哈希。我拿 tx hash 精确匹配订单表里的 deposit_tx,对上了。credits 那笔不用改库,把 /pay 链接发给他就能自己领。那笔没匹配上的整数金额,我折成 credits 手工补给了他,用 tx hash 作幂等引用。买家说「两次都失败了」,库里实际是「一次没匹配、一次匹配了但晚了十几分钟」。

09-09:下的 USDT 单,付的是 USDC

  四天后又来一笔。一位买家下单时报价是 USDT,62 秒后就付款了,用的是 Polygon 上的 USDC。金额一分不差,识别尾数也对,地址也是那个唯一的收款地址。币安立刻入账,订单却干等一个小时后过期了。

  原因很直接。applyDeposit 捞候选订单时带着 WHERE asset = $1($1 是充值的币种),matchDeposit 又比较了一次 o.asset === coin。两道过滤,同一个假设:你必须付下单时选的那种币。

  付款页写的是 1 USDT = 1 USDC = 1 USD、Send only USDT or USDC、Ethereum 和 Polygon 都能到账。前端的币种切换默认是 USDT,他钱包里是 USDC,这次付款符合页面提示,却没有拿到 key。

  选择 USDC 下单再支付 USDC 一直能正常结算,但匹配逻辑没有实现文案里的币种等价。之前几笔都没跨币种,没有触发这个问题。

  先处理这一单。我在一个事务里手工做了轮询器和 claim 接口该做的事:SELECT ... FOR UPDATE 锁住订单,确认 deposit_tx 和 api_key_id 都为空、这笔 tx 没有付过别的单;按订单上自带的档位快照开通(不读档位表的当前值);因为买家已经离开页面,只能在这里 mint key;最后把订单改成已付款。明文 key 在 COMMIT 之后打印,同时写一份权限 0600 的临时文件,防止 ssh 断掉明文就永远没了,交给买家后立刻删除。

匹配和金额唯一性一起改

  去掉两处币种条件后,金额唯一性也需要调整。

  原来的金额唯一索引是按币种分开的:一张 USDT 单和一张 USDC 单可以报出同一个金额。以前匹配也看币种,所以没问题。匹配一旦不看币种,同一笔充值就可能对应两个客户的订单。

  所以等价是两件事一起做:匹配不看币种,金额唯一性也提升到跨币种。唯一索引从按币种换成 idx_crypto_pending_amount_any,也就是 (amount) WHERE status='pending';建单挑金额时也跨币种查重。

迁移索引时我错了四次

  换索引要在启动时迁移:先建新索引,确认建好了,再删旧的,中间不能有窗口。真遇到两张未过期的待付订单金额相同,就拒绝启动,不自动作废任何一单,因为被作废的那个客户可能已经把钱转出来了。

  麻烦在「确认新索引建好了」这一步。我前后写错了四次,复审一轮抓一个:

  1. 只用 to_regclass 检查名字存在。
  2. 没看 indisvalid / indisready。一次失败的 CREATE INDEX CONCURRENTLY 会留下一个什么都不强制、但看起来完全正常的索引。
  3. 没看 indrelid。to_regclass 按 search_path 解析,可能解析到别的表上的同名索引。
  4. 用正则在索引定义里搜关键词判断谓词。可 WHERE status <> 'pending' 同样包含 status 和 'pending',而它索引的正好是相反那批行。

  最后的写法是结构化地读 pg_index:唯一性、有效性、所属表、键列数、列名、access method、默认 opclass,谓词整串比较。少任何一条,一个同名但错误的索引都能骗过校验,然后旧索引被删掉,唯一性就静默失效了。

  array_agg(a.attname) 的类型是 name[],node-pg 没有这个类型的解析器,原样返回字符串 "{amount}"。于是 Array.isArray() 永远是 false,正确的索引被判成不合格,迁移抛错,API 起不来。要写成 a.attname::text 拿到 text[]。这个问题由「冒名索引不能骗掉旧索引」那条测试检出。

分两次发布

  我本来想一次发布:后端改匹配,前端把付款框的大字从「USDT」改成「USDT or USDC」。我的理由是引导跨币种付款的那句小字改动前就已经在线上了,回滚也不会扩大风险。

  复审时发现这个理由站不住。买家照着做的是付款框里的大字,不是小字备注。把大字改成「USDT or USDC」,会真正提高跨币种付款概率。如果前后端一起回滚,已经打开页面的人仍会按新提示付款,旧后端却认不出来。

  所以拆成两次:先发后端,跨币种付款立刻能结算,页面不变,回滚不会产生新的损失;再发 UI,并把后端那个 commit 定为回滚下限。第一次发布页面上没有任何变化,验证方式改成直接查生产库,确认新索引确实就位、旧索引已经删掉。

09-11:一张过期订单,一笔晚到的钱

  昨天又查到一个生产里发生过的老问题:一笔带 6 位小数尾数的充值(这种金额只可能由某张具体订单生成)进来后变成「无法归属」。钱已经在币安账上,买家只看到订单过期,最后只能人工补发。

  matchDeposit 本来就允许到账时间比订单过期时间晚 30 分钟(LATE_GRACE_MS),注释里写着理由:客户在窗口内发起了转账,只是链上确认慢。但轮询循环 pollOnce 第一件事是 expireStaleOrders,它用的判据是 expires_ms。晚到的那笔出现时,订单已经被标成 expired,而 matchDeposit 只看 pending 的行。

  也就是说这 30 分钟宽限从来没生效过:还是 pending 的行本来就满足 at <= expires_ms,加上 LATE_GRACE_MS 永远改变不了结果。

  第一版我把清理时间也向后挪了 30 分钟,但这只改了一半。匹配资格看充值的到账时间,清理看当前时钟。只要轮询器挂掉,或者 API 重启超过 30 分钟(启动时也会跑同一个清理),订单还是会在读到充值前关闭。它只是把「任何延迟都丢」变成「延迟超过 30 分钟才丢」。第一版测试里,充值时间和轮询时间都是「现在」,根本没覆盖这种情况。

  现在 matchDeposit 接受 pending 和 expired 两种状态(只排除 paid 和 void),按到账时间判断匹配资格:

1
created_ms <= 到账时间 <= expires_ms + LATE_GRACE_MS

  这样清理什么时候跑,就和结算正确性无关了。清理只影响页面显示。

  放开 expired 之后要回答一个问题:金额会被重复使用,一笔充值会不会同时符合两张用过同一个金额的订单?答案由一条常量不等式保证:

1
RESERVE_MS (2h) > ORDER_TTL_MS (1h) + LATE_GRACE_MS (30m)

  一个金额在建单后 2 小时内不会再报给别人,而旧订单的匹配窗口 1 小时 30 分就关了;新订单又只接受晚于它自己 created_ms 的到账。两个窗口不可能重叠。以后谁把 RESERVE_MS 缩小,放开 expired 就不安全了,所以加了一条测试专门钉住这个不等式,因为代码里别的地方没有说过这句话。

  写回归测试时还踩了个坑。createCryptoOrder 永远把 created_ms 设成现在,我一开始只给了一个负的 ttlMs 来模拟「已经过期的订单」,结果造出了「过期时间早于建单时间」的不可能场景,而 matchDeposit 会拒绝早于建单的充值。测试失败的原因和它要测的事毫无关系。正确的做法是把整张单挪到过去,created_ms 和 expires_ms 一起改。

交付还依赖付款页

  09-05 的订单要等买家回到付款页领取 key。09-09 修了跨币种匹配,09-11 修了过期订单的到账宽限。稳定币买家不留邮箱,这一周能把钱和 key 对上,靠的是买家回来联系,或者提供链上 tx hash。


outcometick 工程笔记(12/21)

上一篇:ot run 只回放了 289 个市场里的 2 个,退出码还是 0

下一篇:Predict.fun 的结算判据和平局:一个冻结的 status 字段,和一个第三种结果

This post is licensed under CC BY 4.0 by the author.

ot run 只回放了 289 个市场里的 2 个,退出码还是 0

Predict.fun 的结算判据和平局:一个冻结的 status 字段,和一个第三种结果