FABRKNT
Step 4. Funding:決定論的数学パイプラインと Funding ステートマシンの構築
Clock state machine
レッスン 11 / 12·CONTENT25 分50 XP
コース
Step 4. Funding:決定論的数学パイプラインと Funding ステートマシンの構築
レッスンの役割
CONTENT
順序
11 / 12

レッスン10 — No-catch-up 不変条件 — 1 テストで設計哲学

問い

validator が 10 時間ダウンして reboot し、次の tick()now - last_settled_at が 10 interval になっている。素朴には「10 tick を replay して追いつく」と考える。だがそれは公平か? clock が 遅れた とき、何が正しい semantics か?

原理(最小モデル)

  • No-catch-up は公平性の不変条件。 10 interval のギャップ後は 1 度だけ settle して now に advance。現在のスナップショットで 10 回 replay すると、ギャップ中に position を閉じられなかった負け側に 10 倍の懲罰が集中する。funding の目的は equilibration であって遡及的強制でない。
  • now へ advance、last_settled + interval でない。 deadline は実際の settlement 時刻にリセット、見逃した interval を完全に忘れる。これがこのテストで pin する設計判断。
  • 同じ now での second tick は最も厳しいテスト。 2 呼び出しの間で時間が 1ms も経たず、変わったのは clock の内部 state だけ。遅れた tick で last_settled_at を更新し忘れる実装を全部捕まえる。
  • catch-up ポリシーは clock の外側に。 必要な呼び出し側は中間時点のスナップショットで tick() を繰り返し呼ぶ wrapper を書く。clock は過去 state にアクセスできない。primitive はミニマルに、policy は呼び出し側に。
  • 設計哲学は doc・コード・テストの 3 箇所に住む。 module doc が約束し、self.last_settled_at = now が強制し、no_catchup_after_long_gap が証明する。

具体例

時間が大きく飛んだ直後の Choice A vs B:

1_003_600 ── 正常 Tick 成功 ──► last_settled_at = 1_003_600
   ░░ 障害! 10 時間チェーン停止。trader は position を閉じられない。mark が乖離し続ける ░░
1_039_600 ── 遅れてきた Tick
       ❌ Choice A (catch-up replay): 現在スナップショットで 10 回連続 settle
            負け側 (longs) に毎時 cap 上限が 10 連発、閉じる手段はなかった → retroactive 強制
       🟢 Choice B (openhl 採用): 現在スナップショットで 1 度だけ settle、見逃した 9 interval はスキップ
            last_settled_at = 1_003_600 ──► 1_039_600 ✨ (1 ステップ)

Choice B では last_settled_at の遷移が常に 1 ステップ(10 時間 gap でも 10 秒 gap でも tick() は 1 回呼ばれ 1 回 advance)= path-independence。これがテスト 1 本で不変条件全体を pin できる理由。

失敗例(誤解)

「テストでは out.settlements のエントリが 1 つか確かめるべき」は誤り — settlement の個数は positions に依存するもので gap に依存しない。balanced_book() なら gap の長さに関わらず settlement は 2 つ。このテストの仕事は tick が 1 回 fire することの検証であって、settlement の個数でない。


ここまでで「no-catch-up = 1 度 settle して now に advance」は着地。ここからマイルストーンテスト 1 つを足してセクション3 を閉じる(production コード変更なし)。コードは完全形。

🛑 予測。 10 時間 gap 後に 現在 スナップショットで 10 tick を replay すると、一番痛い目に遭うのはどの trader?(答え: 負けていた側が 10 倍の打撃。gap 中 mark が index 上に乖離し続けたなら longs が overpay の状態。Choice A は現在 rate で 10 回 replay、全部 longs から charge。基準の負け側はすでにいたのに 10 倍払い、しかも gap 中は position を閉じられなかった(チェーンが止まっていた)。catch-up は動けなかった時間への retroactive な charge。Choice B はスキップして今から fresh に — revenue には悪いが trader にフェア。)

ステップで組み立てる

Step 1: マイルストーンテストを追加

capped_rate_when_premium_extreme の後に:

    #[test]
    fn no_catchup_after_long_gap() {
        // If 10 intervals elapse before the next tick, we settle ONCE and
        // advance to `now`. We don't replay 10 settlements with stale state.
        let params = FundingParams::hyperliquid_default();
        let mut clock = FundingClock::new(params, 1_000_000);

        let way_later = 1_000_000 + 10 * 3600;
        let out = clock.tick(way_later, MarkPrice(101), IndexPrice(100), &balanced_book());
        assert!(out.is_some(), "elapsed >> interval → tick fires");
        assert_eq!(clock.last_settled_at(), way_later);

        // Immediately ticking again at the same moment does NOT settle.
        let again = clock.tick(way_later, MarkPrice(101), IndexPrice(100), &balanced_book());
        assert!(again.is_none(), "no duplicate settlement at same now");
    }

2 パート: Part 1(長い gap でも settle は 1 度・now に advance — out.is_some() + last_settled_at() == way_later、見逃した interval を完全に忘れる)/ Part 2(同じ now で再 fire しない — again.is_none()、可能な限り最も厳しいテスト:時間が経たず変わるのは state だけ。last_settled_at == way_later なら guard way_later < way_later + 3600 が true で正しく None)。

Step 2: テスト実行

cargo test -p openhl-funding22 pass(手書き 20 + proptest 2)。セクション3 が閉じ、crates/funding/cd94137 に一致。

答え合わせ

cd ~/code/openhl-reference && git checkout cd94137
diff -u ~/code/my-openhl/crates/funding/ ./crates/funding/ --recursive
git checkout main

crates/funding/ 全体が cd94137 と一致、diff は空。セクション3 完了。

合格基準

cargo test -p openhl-funding が 22 pass。よくあるエラー: Part 1 out.is_none()(guard の比較方向間違い)/ Part 1 last_settled_at() != way_laterself.last_settled_at = now でなく += interval の catch-up 版 typo)/ Part 2 again.is_some()(Part 1 の tick で last_settled_at 未更新)。

まとめ(3行)

  • 長い gap でも settle は 1 度・advance 先は now+interval でない)= no-catch-up。replay は負け側に集中懲罰を、position を閉じる機会も与えず課す。funding は equilibration であって遡及強制でない。
  • 同じ now での second tick が最も厳しいテスト(時間が経たず変わるのは state だけ)— 遅れた tick で last_settled_at 更新忘れを全部捕まえる。
  • catch-up ポリシーは clock の外(中間時点スナップショットで繰り返し tick())。primitive はミニマル、policy は呼び出し側。22 tests で crates/funding/cd94137 に一致。

次のレッスン(レッスン11)

capstone。新規コードなし。pipeline をスケッチし、先送りした 5 項目(oracle / balance 更新 / liquidation / multi-market / funding-as-EVM-event)を名指し、それぞれが将来どこに置かれるかをたどる。