`repayPoolDebt` is the one value-moving exit H-32 never windowed: 1-base-unit repays write two permanent `#delta` rows each at ingress speed, so an aggregate >100 calls/s holds the ship-queue shed floor at 1 and refuses every L0 user's non-exit updates

オープン
#86 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
18/100
issue の種類
バグ
明瞭さ
おおむね明確
活発さ
活発
技術スタック
devtools
領域
backend

調査の方向性

Start in src/backend/main.mo at repayPoolDebt (around lines 15035-15053), where only amount == 0 is refused and exitFolds / foldDustExit are not called, then compare with withdrawMarginPool near lines 14999-15001. The shedding path is shedTierOf (around 9412) and the ledger flush in drainLedgerJournal (around 11720). Done means a sub-dust repay is coalesced in the window like the other exits and a repro shows the ship queue not reaching floor 1; this touches shedding design, so get maintainer direction first.

索引モデルが issue の本文から書いたものです。

説明

Severity (proposed): Medium. An availability guarantee breaks (H-32's own Verify criterion, and the invariant comment at main.mo:1274-1276), venue-wide for every L0 principal, by a few ordinary registered accounts and at no value cost. It is bounded: the reachable effect is floor 1 (not floor 2 or the shear), L1+ users and every exit stay admitted, and it lasts only while the calls continue. Same code on #production.

Category: Availability (ship-queue exhaustion past the shed defences). Secondary: permanent archive growth.

Summary

H-32 (F12) bounded the tape inflow of the never-shed exit set per principal with a 30 s dust-coalescing window (exitFolds / foldDustExit), wired into the four exits it called "value-moving": withdraw, withdrawMarginPool, unstakeInsurance, withdrawLp. repayPoolDebt landed in 1.75, is in EXIT_METHOD_SET, and is admitted pre-consensus at every shed floor. It also moves value: each call debits the pool and credits the vault, so it appends two Accounts journal rows, and drainLedgerJournal turns each row into its own permanent chained #delta event. Its only size guard is amount == 0, and it never calls exitFolds.

H-32's completion note cleared it explicitly: "repayPoolDebt by the debt it retires (pay = min(amount, debt, free))". That bounds value, not call count. With amount = 1, a pool holding debt next to free funds gets min(debt, free) base units' worth of calls — 5×10⁹ calls for 50 ICPUSD free — and that state is stable (nothing auto-repays idle debt). Above an aggregate 100 calls/s (200 events/s, the below-soft-tier ship drain), the ship queue climbs to SHED_SOFT_SHIP (50k) and floor 1 engages; the burst drain only runs above 50k, so it pulls the queue just under 50k and stops, and hysteresis holds floor 1 until the queue falls below 35k — which never happens while the calls continue.

Affected surface (commit 7bcdde6)

  • src/backend/main.mo:15035-15053 — repayPoolDebt: only amount == 0 is refused (:15038); no j0 / exitFolds / foldDustExit.
  • src/backend/main.mo:7010-7021 — repayPoolFromFree: pay = min(debt, free), then min(pay, cap); cap = ?1 pays one base unit.
  • src/backend/lib/BorrowEngine.mo:426-427 — repay: subtractBalance(pool) + addBalance(vault), two journal rows via Accounts.setBalance (lib/Accounts.mo:42-48).
  • src/backend/main.mo:11720-11729 — drainLedgerJournal: one emitEventRaw(#delta) per row, every beat.
  • src/backend/main.mo:11591 — exitFolds; its only call sites are :11699 (withdraw), :14999 (withdrawMarginPool), :16990 (unstakeInsurance), :21798 (withdrawLp).
  • src/backend/main.mo:12868-12872 and :13105-13113 — "repayPoolDebt" is in EXIT_METHOD_SET; inspect returns true for it before the levelRank(...) >= _shedFloor check.
  • src/backend/main.mo:1274-1281 — the invariant comment ("its own tape inflow is bounded per principal by the dust-coalescing window (H-32)"), SHED_SOFT_SHIP = 50_000, SHED_SOFT_SHIP_LOWER = 35_000.
  • src/backend/main.mo:9412-9418 (shedTierOf hysteresis), :11226 (SHIP_BATCH_MAX = 2_000), :9120 (HB_SHIP_NS = 10 s), :11241 (SHIP_BURST_MAX = 8), :12265-12266 (burst only while the queue is above the soft tier).
  • docs/tasks/done/H-32-a-single-principal-s-tape-inflow-through.md — the Verify criterion and the "Adjacent sites enumerated" clearance quoted above.

Mechanism

// main.mo:15038, 15046-15052 — no dust floor, no window
if (amount == 0) { return #err("Amount must be positive") };
...
let paid = repayPoolFromFree(poolP, token, earmark, ?amount, now);
if (paid == 0) { return #err("No free " # token # " in the pool to repay with ...") };
reprovisionPool(poolP);
bumpUserVersion(msg.caller);
#ok(paid)
// main.mo:11722-11727 — each journal row becomes its own permanent chained event
flushExitWindows(false);
if (List.size(accounts.journal) > 0) {
  for ((p, tok, d) in List.values(accounts.journal)) {
    emitEventRaw(p, null, #delta { token = tok; amount = d });
  };
  List.clear(accounts.journal);
};

The sibling exit with the same two-#delta shape coalesces a sub-dust call:

// main.mo:14999-15001 (withdrawMarginPool; j0 taken at :14990)
if (exitFolds(msg.caller, Time.now(), amount < EXIT_DUST_USD and amount < poolAvail)) {
  foldDustExit(msg.caller, j0, #none);
};

Below 50k the shipper drains one 2,000-event batch per 10 s (200 events/s). shedTierOf raises floor 1 at ≥ 50k and holds it anywhere in [35k, 50k). The burst drain (:12265) runs only while the queue is above 50k, so with sustained inflow above 200 events/s the queue oscillates just under/over 50k and the floor never drops.

Reproduction

The main.mo path (inspect :13105 → :15036-15052 → :7017 → BorrowEngine.mo:426-427 → drainLedgerJournal :11722-11726) is traced, not run on a replica. The per-call row count is executed against the real BorrowEngine / Accounts modules; the queue/floor behaviour is an executed model of shedTierOf and the ship drain using the constants above.

Steps (#play, non-controller):

  1. Register principals A, B, C (each L0).
  2. Each calls createMarginPool, then fundMarginPool(pool, 1_000 ICPUSD).
  3. Each calls openPosition(pool, "ICP-ICPUSD", #buy, <≈2× margin>, ...). At release the fill borrows the ICPUSD shortfall and spends the quote; the pool now has ≈1,000 ICPUSD debt and 0 free quote.
  4. Each calls fundMarginPool(pool, 50 ICPUSD); it does not repay, so min(debt, free) = 5×10⁹ one-unit repays per account.
  5. Each repeatedly calls repayPoolDebt(pool, "ICPUSD", 1) at 50 calls/s (the per-client rate H-32's own sizing assumed). Every call returns #ok(1) and adds 2 #delta rows; total inflow 300 events/s against a 200 events/s drain.
  6. After ≈8 minutes from an empty queue (sooner with organic traffic), _shedFloorShip → 1. From then on inspect refuses placeLimitOrder, swap, depositLp, stakeInsurance, fundMarginPool, AI and every other non-exit update for every L0 caller, while the repayPoolDebt calls keep passing.

Run from the repo root: moc $(mops sources) --package mdex src/backend --implicit-package=core -r repro.mo

repro.mo (moc 1.9.0, commit 7bcdde6)
// Part A drives the real BorrowEngine.repay + Accounts journal with main.mo's
// repayPoolFromFree arithmetic copied verbatim (main.mo:7010-7021), and counts the
// #delta events drainLedgerJournal (main.mo:11720-11729) emits: one per journal row.
// Part B replays shedTierOf (main.mo:9412-9418) and the ship drain (2000 / 10 s,
// + up to 8 burst batches only while queue > 50k, main.mo:12265-12266).
import BE "mo:mdex/lib/BorrowEngine";
import ME "mo:mdex/lib/MarginEngine";
import Accounts "mo:mdex/lib/Accounts";
import Types "mo:mdex/lib/Types";
import SafeMath "mo:mdex/lib/SafeMath";
import Debug "mo:core/Debug";
import Runtime "mo:core/Runtime";
import Principal "mo:core/Principal";
import List "mo:core/List";
import Nat "mo:core/Nat";

let vault = Principal.fromText("aaaaa-aa");
let pool  = Principal.fromText("tz2ag-zx777-77776-aaabq-cai");
let ICPUSD = Types.QUOTE_TOKEN;
func px(t : Types.TokenId) : ?Nat { switch (t) { case ("BTC") { ?10_000_000_000_000 }; case (_) { null } } };
func noReserved(_ : Principal, _ : Types.TokenId) : Nat { 0 };

let loans = BE.emptyState();
let mg = ME.emptyState();
let acc = Accounts.emptyState();
Accounts.setBalance(acc, vault, ICPUSD, 100_000_000_000_000);
switch (ME.open(mg, pool, 0)) { case (#ok(_)) {}; case (#err(e)) { Runtime.trap(e) } };
Accounts.setBalance(acc, pool, "BTC", 100_000_000);
// State after step 3: pool borrowed 1000 ICPUSD at the fill and spent it.
switch (BE.borrow(loans, mg, acc, noReserved, vault, pool, ICPUSD, 100_000_000_000, 0, px)) {
  case (#ok(_)) {}; case (#err(e)) { Runtime.trap("borrow: " # e) };
};
ignore Accounts.subtractBalance(acc, pool, ICPUSD, 100_000_000_000);
// Step 4: fundMarginPool(50 ICPUSD) does not repay.
Accounts.addBalance(acc, pool, ICPUSD, 5_000_000_000);
List.clear(acc.journal);

// main.mo:7010-7021 verbatim (no staged reservations, earmark 0)
func repayPoolFromFree(poolP : Principal, token : Types.TokenId, earmark : Nat, cap : ?Nat, now : Int) : Nat {
  let debt = BE.loanOf(loans, poolP, token);
  if (debt == 0) { return 0 };
  let free = SafeMath.subOrZero(Accounts.getBalance(acc, poolP, token), earmark);
  var pay = Nat.min(debt, free);
  switch (cap) { case (?c) { pay := Nat.min(pay, c) }; case null {} };
  if (pay == 0) { return 0 };
  switch (BE.repay(loans, acc, vault, poolP, token, pay, now, px)) {
    case (#ok(_)) { pay };
    case (#err(_)) { 0 };
  };
};

let debt0 = BE.loanOf(loans, pool, ICPUSD);
let free0 = Accounts.getBalance(acc, pool, ICPUSD);
Debug.print("A. start: debt=" # Nat.toText(debt0) # " free=" # Nat.toText(free0) # " journal=" # Nat.toText(List.size(acc.journal)));
var events : Nat = 0;
var okCalls : Nat = 0;
let N = 1_000;
var i = 0;
while (i < N) {
  let now : Int = 1_000_000 + i;
  BE.accrueAll(loans, pool, now);
  let paid = repayPoolFromFree(pool, ICPUSD, 0, ?1, now);
  if (paid == 1) { okCalls += 1 };
  i += 1;
  if (i % 50 == 0) { events += List.size(acc.journal); List.clear(acc.journal) };
};
events += List.size(acc.journal); List.clear(acc.journal);
Debug.print("A. after " # Nat.toText(N) # " repayPoolDebt(pool, ICPUSD, 1): #ok(1) x" # Nat.toText(okCalls)
  # ", debt=" # Nat.toText(BE.loanOf(loans, pool, ICPUSD)) # " (-" # Nat.toText(debt0 - BE.loanOf(loans, pool, ICPUSD)) # ")"
  # ", free=" # Nat.toText(Accounts.getBalance(acc, pool, ICPUSD))
  # ", permanent #delta events emitted=" # Nat.toText(events));
Debug.print("A. calls remaining on this budget = min(debt, free) = " # Nat.toText(Nat.min(BE.loanOf(loans, pool, ICPUSD), Accounts.getBalance(acc, pool, ICPUSD))));
if (events != 2 * N) { Runtime.trap("expected 2 events per call") };

// Part B: queue / floor model
func shedTierOf(depth : Nat, cur : Nat, soft : Nat, hard : Nat, softLower : Nat, hardLower : Nat) : Nat {
  if (depth >= hard) { 2 }
  else if (depth >= soft) {
    if (cur == 0) { 1 } else if (cur == 2 and depth < hardLower) { 1 } else { cur }
  }
  else if (depth < softLower) { 0 }
  else { cur };
};
func simulate(label_ : Text, inflowPerSec : Nat, secs : Nat) {
  var q : Nat = 0; var floor : Nat = 0; var t : Nat = 0;
  var firstFloor1 : ?Nat = null; var secsAtFloor1 : Nat = 0; var minQAfter : Nat = 1_000_000_000; var maxQ : Nat = 0;
  while (t < secs) {
    q += inflowPerSec;
    floor := shedTierOf(q, floor, 50_000, 100_000, 35_000, 70_000);
    if (t % 10 == 9) {
      q := SafeMath.subOrZero(q, 2_000);
      var burst = 0;
      while (burst < 8 and q > 50_000) { q := SafeMath.subOrZero(q, 2_000); burst += 1 };
      floor := shedTierOf(q, floor, 50_000, 100_000, 35_000, 70_000);
    };
    if (floor >= 1) {
      secsAtFloor1 += 1;
      switch (firstFloor1) { case null { firstFloor1 := ?t }; case _ { if (q < minQAfter) { minQAfter := q } } };
    };
    if (q > maxQ) { maxQ := q };
    t += 1;
  };
  Debug.print("B. " # label_ # ": inflow " # Nat.toText(inflowPerSec) # " ev/s over " # Nat.toText(secs) # " s → floor first 1 at t="
    # debug_show firstFloor1 # " s, seconds at floor≥1 = " # Nat.toText(secsAtFloor1)
    # ", queue min after floor rose = " # Nat.toText(minQAfter) # ", queue max = " # Nat.toText(maxQ) # ", final floor = " # Nat.toText(floor));
};
simulate("3 principals @50/s", 300, 7_200);
simulate("1 principal @50/s ", 100, 7_200);
simulate("1 principal @101/s", 202, 7_200);
A. start: debt=100000000000 free=5000000000 journal=0
A. after 1000 repayPoolDebt(pool, ICPUSD, 1): #ok(1) x1000, debt=99999999000 (-1000), free=4999999000, permanent #delta events emitted=2000
A. calls remaining on this budget = min(debt, free) = 4999999000
B. 3 principals @50/s: inflow 300 ev/s over 7200 s → floor first 1 at t=?479 s, seconds at floor≥1 = 6721, queue min after floor rose = 48300, queue max = 52700, final floor = 1
B. 1 principal @50/s : inflow 100 ev/s over 7200 s → floor first 1 at t=null s, seconds at floor≥1 = 0, queue min after floor rose = 1000000000, queue max = 900, final floor = 0
B. 1 principal @101/s: inflow 202 ev/s over 7200 s → floor first 1 at t=null s, seconds at floor≥1 = 0, queue min after floor rose = 1000000000, queue max = 16198, final floor = 0

At 202 events/s the queue gains ~2 events/s net (≈7 h to 50k); at 300 events/s it takes 8 minutes.

Impact

Confirmed (trace + executed lib/model):

  • Each repayPoolDebt(…, 1) writes 2 permanent #delta rows, with no dust window, no per-principal throttle (RateLimit.check is wired only to the AI path), and admission at every shed floor.
  • Sustained aggregate >100 calls/s holds _shedFloorShip = 1: every L0 principal (everyone without a feeLevels entry) is refused all non-exit updates pre-consensus — order entry, swaps, LP deposits, staking, fundMarginPool, AI.
  • No value is spent: each call retires one unit of the caller's own debt with one unit of their own free quote. Budget is min(debt, free) base units per pool.
  • Every row is permanent hash-chained history (≈26M archive events/day at 300 events/s), paid for by the venue.

Not affected / bounded: no value is minted or lost; L1+ users, every exit and queries are unaffected; the L3 shear also requires memory pressure since H-32, so a healthy archive drops no history; floor 2 would need >1.8k events/s (≈900 calls/s) and is not claimed.

Not established: whether boundary-node rate limiting caps a single client below 100 updates/s (spreading over 3+ principals and clients makes this moot), and how much organic #play traffic lowers the required rate.

Relationship to existing reports

  • F12 / H-32 (docs/hardening-plan-2026-09.md, docs/tasks/done/H-32-…): same root cause — a never-shed exit with no dust floor writes permanent rows at ingress speed. H-32 windowed four sibling exits and cleared repayPoolDebt with a value bound; this shows that bound does not bound call count. tests/test_exit_coalesce.sh has no repayPoolDebt case. repayPoolDebt itself landed in 1.75 (a225a74). This is an incomplete fix on a sibling path, not a new class.
  • #27.1 / W1-05 and D2 (exits are never refused): this report keeps that doctrine — it asks for the tape to be windowed, not for the exit to be shed.
  • #52.2 (event shedding on a healthy-but-backlogged queue) and #54.1 (exit set never shed, season-seal race): different mechanisms and sites.
  • No issue or advisory mentions repayPoolDebt, repayPoolFromFree, exitFolds or foldDustExit as a tape/shed vector.

Suggested fix

Window repayPoolDebt's tape exactly as withdrawMarginPool does (main.mo:14990, :14999-15001; base-token valuation as in recordWithdrawal, :11695-11698). The repay still executes in full and is never refused (D2); only its two #delta rows coalesce in the caller's 30 s window:

// in repayPoolDebt, before repayPoolFromFree:
let debtBefore = BorrowEngine.loanOf(loans, poolP, token);
let freeBefore = SafeMath.subOrZero(getAvailable(poolP, token), earmark);
let j0 = List.size(accounts.journal);   // H-32: rows this exit appends start here
let paid = repayPoolFromFree(poolP, token, earmark, ?amount, now);
...
reprovisionPool(poolP);
let dust = paid < debtBefore and paid < freeBefore and (switch (markValueUsd(token, paid)) {
  case (?v) { v < EXIT_DUST_USD }; case null { false } });
if (exitFolds(msg.caller, now, dust)) { foldDustExit(msg.caller, j0, #none) };

foldDustExit already sums rows of any principal per (principal, token), so the vault-credit row folds the way withdrawLp's AMM rows do. Add a repayPoolDebt(pool, ICPUSD, 1) burst case to tests/test_exit_coalesce.sh, and update the H-32 adjacent-sites note and main.mo:1274-1276 to say five value-moving exits.


Part 19 of 28 from an independent review of MULTI/DEX 1.80 (7bcdde6), carried out with Claude Opus 5.5. Each finding was checked against the existing issues, advisories and fix briefs before filing; the severity is a proposal under CONTRIBUTORS.md. Where a step says "traced", it was read from source and not executed.

主要言語
Shell
スター
19
フォーク
7
PR マージ指標
30日以内にマージされた PR はありません

環境構築

このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

dfinity/public-multidex のほかの issue

dfinity/public-multidex の issue をすべて見る

似ている issue

Shell/Bash の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。