A cross swap's buy leg executes under a band anchored to the lowest ask, below the mid-anchored cap `fokCrossGate` checked, so a fill-or-kill cross swap can sell all its source and buy almost none of the target

Đang mở
#76 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

Đánh giá

Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức phù hợp với người mới
15/100
Loại issue
Lỗi
Độ rõ ràng
Khá rõ ràng
Mức độ hoạt động
Sôi nổi
Lĩnh vực
backend, fintech-quant

Hướng nghiên cứu

Start in releaseCrossSwap in src/backend/main.mo (around lines 4647-4824) and compare the buyCap used by fokCrossGate (main.mo:4616-4641) with the band that executeMarketOrderProtected computes in src/backend/lib/MatchingEngine.mo (around lines 326-341), which is anchored on OrderBook.findBestMatch. Run the repro from the issue, which should show the buy leg stopping with #slippage after the gate passes. Done means the gate and the executed band agree, and the repro cases B through E behave consistently.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

Severity: Medium. Category: matching fairness (the fill-or-kill guarantee of a staged cross swap).

Summary

releaseCrossSwap measures buy-side depth under buyCap = mid × (1 + slip) (fokCrossGate; crossableDepth for non-FOK sizing), but the engine executes under min(lowestAsk × (1 + slip), buyCap): H-20's priceCap is intersected with a band anchored on OrderBook.findBestMatch, the lowest open ask whether funded, expired or the taker's own. An ask below AMM_best_ask / (1 + slip) drops the executed ceiling under the AMM ladder the gate counted: the gate passes, the sell leg settles everything, the buy leg stops with #slippage. The comments at main.mo:4682-4686 and :4793 claiming the executed band is the sized band are false.

Affected surface

Mechanism

The gate credits the low ask zero if unfunded, expired or self, and counts the ladder behind it that the engine cannot reach. The sell leg is safe: it carries the engine's FOK pre-check (:4747) under the real minPrice. The buy leg cannot carry one (W2-03, :4736-4745), so the gate is its only guard. Every low ask sets the band before it is cancelled or consumed; an expired one stays until the next sweepExpiredOrders (:2118), so it can anchor every cross swap in that pass (traced).

Reproduction

Executed with moc -r against unmodified MatchingEngine, OrderBook, Accounts; the gate is a hand-written mirror of walkFillable's buy walk. Not run on a replica; actor sequencing is traced.

Attacker route (repro 2). Mid 100, AMM bids 99.80/99.65/99.50 and asks 100.20/100.35/100.50, 2 BTC each. A victim stages a FOK ICP→BTC swap at 5% ($500 proceeds). Another user stages a limit sell of 6.0106 BTC at 95.10, inside the marketable collar (priceBandCheck, :13962-13987). processDeferred (:2605) runs before processDeferredSwaps (:21155) in one GEPTOR finaliser: the sell takes all AMM bids and rests 0.0106 BTC at 95.10. The gate counts $499.99999999 of $500 under 105.00 and passes, all ICP is sold, the buy band is min(95.10 × 1.05, 105) = 99.855: 0.0106 BTC fills, #slippage at 100.20, recorded filled = true.

Repro 1: every low-ask kind passes the gate and starves the buy leg. Repro 3: at 1% slippage (the minimum) an ask 0.9% under the mid suffices.

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

Repro 1 ([engine] lines belong to the next case; in E the BTC funded the victim's own ask):

repro.mo (moc 1.9.0, commit 7bcdde6)
// repro: FOK cross-swap buy leg. Gate (walkFillable mirror, band = buyCap only)
// vs. the UNMODIFIED engine call releaseCrossSwap makes (main.mo:4792-4794):
//   executeMarketOrderProtected(..., #buy, buyQty, s.maxSlippage, false, now, ctx, ?spend, ?buyCap)
import MX "mo:mdex/lib/MatchingEngine";
import OB "mo:mdex/lib/OrderBook";
import Accounts "mo:mdex/lib/Accounts";
import Types "mo:mdex/lib/Types";
import Fixed "mo:mdex/lib/Fixed";
import Map "mo:core/Map";
import Nat "mo:core/Nat";
import Debug "mo:core/Debug";
import Principal "mo:core/Principal";

let QUOTE = Types.QUOTE_TOKEN;
let mkt = "BTC-ICPUSD";
let base = "BTC";
let third  = Principal.fromText("2vxsx-fae");
let victim = Principal.fromText("aaaaa-aa");
let amm    = Principal.fromText("rrkah-fqaaa-aaaaa-aaaaq-cai");
let treas  = Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai");
let TAKER_BPS : Nat = 10;
func fee(gross : Nat) : Nat { gross * TAKER_BPS / 10_000 };

func mkCtx(acc : Accounts.AccountState, store : OB.OrderStore, expiredId : Nat) : MX.ProtectionCtx = {
  availableBalance = func(p, t) { Accounts.getBalance(acc, p, t) };
  isNonTakeable    = func(_, _) { false };          // fresh release: AMM takeable
  aggressorRole    = #taker;
  onUnfundedMaker  = func(_, _, _, _, _) { Debug.print("    [engine] unfunded maker cancelled") };
  isExpired        = func(id) { id == expiredId };
  quoteFee         = func(_p, gross, role) {
    let bps = switch (role) { case (#takerDebit or #takerCredit) { TAKER_BPS }; case (#makerDebit or #makerCredit) { 5 } };
    (gross * bps) / 10_000
  };
  creditTreasury   = func(f) { if (f > 0) { Accounts.addBalance(acc, treas, QUOTE, f) } };
  onSelfTrade      = func(id, _owner) { Debug.print("    [engine] self-trade: own ask " # debug_show id # " cancelled"); ignore OB.cancelOrder(store, id) };
  beneficialOwner  = func(p) { p };
  onTradeFees      = func(_, _, _) {};
  fundForFill      = func(_, _, _, _, _) { true };
  fundableScorer   = func() { MX.cashScorer(func(p, t) { Accounts.getBalance(acc, p, t) }) };
};

// Mirror of fokCrossGate's buy walk (main.mo walkFillable, #quoteSpend, excl = ctx + taker):
// band test is ONLY o.price <= capPrice; skipped (self / expired / non-takeable) makers count 0 and the walk continues.
func gateSpent(store : OB.OrderStore, acc : Accounts.AccountState, capPrice : Nat, budget : Nat, expiredId : Nat) : Nat {
  let ex = Map.empty<Nat, Bool>();
  var spent : Nat = 0;
  label w loop {
    if (spent >= budget) { break w };
    switch (OB.findBestMatchExcluding(store, mkt, #buy, ?ex)) {
      case null { break w };
      case (?o) {
        if (o.price > capPrice) { break w };
        Map.add(ex, Nat.compare, o.id, true);
        if (o.id == expiredId or Principal.equal(o.owner, victim)) { continue w };
        let rem = OB.remaining(o);
        if (Accounts.getBalance(acc, o.owner, base) < rem) { continue w };   // all-or-nothing funding
        let remB = Nat.sub(budget, spent);
        var take = rem;
        let needs = do { let c = Fixed.mul(take, o.price, true); c + fee(c) };
        if (remB < needs) { take := Nat.min(take, Fixed.div(Fixed.mulDiv(remB, 10_000, 10_000 + TAKER_BPS, false), o.price, false)) };
        if (take == 0) { break w };
        let c = Fixed.mul(take, o.price, false);
        spent += c + fee(c);
      };
    };
  };
  spent
};
func budgetCrumb(capPrice : Nat) : Nat { Fixed.mulDiv(capPrice / Fixed.SCALE + 1, 10_000 + TAKER_BPS, 10_000, true) + 1 };

let mid  : Nat = 100 * Fixed.SCALE;                 // slippageAnchor (AMM refPrice)
let slip : Nat = 5_000_000;                         // victim's maxSlippage = 5%
let buyCap = Fixed.mul(mid, Fixed.SCALE + slip, false);   // crossLegCap(#buy) = 105.00
let grossUsd : Nat = 500 * Fixed.SCALE;             // sell leg proceeds (already SETTLED before the buy leg runs)

func run(label_ : Text, lowOwner : ?Principal, lowFunded : Bool, lowExpired : Bool) {
  let s = OB.emptyStore(); let a = Accounts.emptyState();
  // AMM ask ladder, 20 bps half-spread, 3 levels x 2 BTC, funded
  Accounts.setBalance(a, amm, base, 6 * Fixed.SCALE);
  ignore OB.createOrder(s, mkt, amm, #sell, #limit, 10_020_000_000, 2 * Fixed.SCALE, 1);
  ignore OB.createOrder(s, mkt, amm, #sell, #limit, 10_040_000_000, 2 * Fixed.SCALE, 1);
  ignore OB.createOrder(s, mkt, amm, #sell, #limit, 10_060_000_000, 2 * Fixed.SCALE, 1);
  var expiredId : Nat = 0;
  switch (lowOwner) {
    case (?o) {
      // dust ask 0.0106 BTC @ 95.10 (~$1.01) resting below AMMask/(1+slip) = 95.43
      let ord = OB.createOrder(s, mkt, o, #sell, #limit, 9_510_000_000, 1_060_000, 1);
      if (lowFunded) { Accounts.addBalance(a, o, base, 1_060_000) };
      if (lowExpired) { expiredId := ord.id };
    };
    case null {};
  };
  Accounts.setBalance(a, victim, QUOTE, grossUsd);  // proceeds credited by leg 1
  let ctx = mkCtx(a, s, expiredId);
  // fokCrossGate buy check (main.mo:4631-4640)
  let spent = gateSpent(s, a, buyCap, grossUsd, expiredId);
  let gatePass = not (grossUsd > spent + budgetCrumb(buyCap));
  // releaseCrossSwap buy leg (main.mo:4775-4794): buyQty sized at findBestMatch
  let best = switch (OB.findBestMatch(s, mkt, #buy)) { case (?o) { o.price }; case null { 0 } };
  let spend = grossUsd;
  let buyQty = Fixed.div(Fixed.mulDiv(spend, 10_000, 10_000 + TAKER_BPS, false), best, false);
  let r = MX.executeMarketOrderProtected(s, a, mkt, base, victim, #buy, buyQty, slip, false, 2, ctx, ?spend, ?buyCap);
  Debug.print(label_);
  Debug.print("    gate: spent-within-buyCap=" # debug_show spent # " of " # debug_show grossUsd # " -> " # (if gatePass "PASS (sell leg settles all source)" else "KILL"));
  Debug.print("    engine refPrice(findBestMatch)=" # debug_show best # " bandMax=" # debug_show (Fixed.mul(best, Fixed.SCALE + slip, false)) # " buyCap=" # debug_show buyCap);
  Debug.print("    buy leg: filled=" # debug_show r.totalFilled # " quoteSpent=" # debug_show r.quoteSpent # " stop=" # debug_show r.stopReason
    # " | victim BTC=" # debug_show Accounts.getBalance(a, victim, base) # " ICPUSD left=" # debug_show Accounts.getBalance(a, victim, QUOTE)
    # " | outcome filled=" # debug_show (r.totalFilled > 0));
};

run("A control (no low ask)", null, false, false);
run("B third-party FUNDED dust ask @95.10", ?third, true, false);
run("C third-party UNFUNDED dust ask @95.10", ?third, false, false);
run("D third-party EXPIRED funded ask @95.10", ?third, true, true);
run("E victim's OWN ask @95.10", ?victim, true, false);
A control (no low ask)
    gate: spent-within-buyCap=49_999_999_977 of 50_000_000_000 -> PASS (sell leg settles all source)
    engine refPrice(findBestMatch)=10_020_000_000 bandMax=10_521_000_000 buyCap=10_500_000_000
    buy leg: filled=497_714_214 quoteSpent=49_999_999_977 stop=#budgetExhausted | victim BTC=497_714_214 ICPUSD left=23 | outcome filled=true
B third-party FUNDED dust ask @95.10
    gate: spent-within-buyCap=49_999_999_948 of 50_000_000_000 -> PASS (sell leg settles all source)
    engine refPrice(findBestMatch)=9_510_000_000 bandMax=9_985_500_000 buyCap=10_500_000_000
    buy leg: filled=1_060_000 quoteSpent=100_906_806 stop=#slippage | victim BTC=1_060_000 ICPUSD left=49_899_093_194 | outcome filled=true
    [engine] unfunded maker cancelled
C third-party UNFUNDED dust ask @95.10
    gate: spent-within-buyCap=49_999_999_977 of 50_000_000_000 -> PASS (sell leg settles all source)
    engine refPrice(findBestMatch)=9_510_000_000 bandMax=9_985_500_000 buyCap=10_500_000_000
    buy leg: filled=0 quoteSpent=0 stop=#slippage | victim BTC=0 ICPUSD left=50_000_000_000 | outcome filled=false
D third-party EXPIRED funded ask @95.10
    gate: spent-within-buyCap=49_999_999_977 of 50_000_000_000 -> PASS (sell leg settles all source)
    engine refPrice(findBestMatch)=9_510_000_000 bandMax=9_985_500_000 buyCap=10_500_000_000
    buy leg: filled=0 quoteSpent=0 stop=#slippage | victim BTC=0 ICPUSD left=50_000_000_000 | outcome filled=false
    [engine] self-trade: own ask 4 cancelled
E victim's OWN ask @95.10
    gate: spent-within-buyCap=49_999_999_977 of 50_000_000_000 -> PASS (sell leg settles all source)
    engine refPrice(findBestMatch)=9_510_000_000 bandMax=9_985_500_000 buyCap=10_500_000_000
    buy leg: filled=0 quoteSpent=0 stop=#slippage | victim BTC=1_060_000 ICPUSD left=50_000_000_000 | outcome filled=false

Repro 2: repro 1's lines 1-110 (all before its run(...) calls), then this block; its 5-line comment restating the sequencing above is trimmed, as is moc's unused-run warning.

repro.mo (moc 1.9.0, commit 7bcdde6)
do {
  let s = OB.emptyStore(); let a = Accounts.emptyState();
  let atk = third;
  Accounts.setBalance(a, amm, base, 6 * Fixed.SCALE);
  Accounts.setBalance(a, amm, QUOTE, 100_000 * Fixed.SCALE);
  // AMM bid ladder: 99.80 / 99.65 / 99.50 x 2 BTC ; ask ladder 100.20 / 100.35 / 100.50 x 2 BTC
  ignore OB.createOrder(s, mkt, amm, #buy,  #limit,  9_980_000_000, 2 * Fixed.SCALE, 1);
  ignore OB.createOrder(s, mkt, amm, #buy,  #limit,  9_965_000_000, 2 * Fixed.SCALE, 1);
  ignore OB.createOrder(s, mkt, amm, #buy,  #limit,  9_950_000_000, 2 * Fixed.SCALE, 1);
  ignore OB.createOrder(s, mkt, amm, #sell, #limit, 10_020_000_000, 2 * Fixed.SCALE, 1);
  ignore OB.createOrder(s, mkt, amm, #sell, #limit, 10_035_000_000, 2 * Fixed.SCALE, 1);
  ignore OB.createOrder(s, mkt, amm, #sell, #limit, 10_050_000_000, 2 * Fixed.SCALE, 1);
  // attacker: 6.0106 BTC, staged limit SELL 6.0106 @ 95.10
  Accounts.setBalance(a, atk, base, 601_060_000);
  let ctx = mkCtx(a, s, 0);
  let (ord, trades, _, _, stopA) = MX.executeLimitOrderProtected(s, a, mkt, base, atk, #sell, #limit, 9_510_000_000, 601_060_000, 1, 2, ctx, null);
  Debug.print("B2 attacker limit sell @95.10: trades=" # debug_show trades.size() # " stop=" # debug_show stopA
    # " resting remainder=" # debug_show OB.remaining(ord) # " status=" # debug_show ord.status
    # " attackerICPUSD=" # debug_show Accounts.getBalance(a, atk, QUOTE));
  let bestBid = switch (OB.findBestMatch(s, mkt, #sell)) { case (?o) { debug_show o.price }; case null { "none" } };
  let bestAsk = switch (OB.findBestMatch(s, mkt, #buy)) { case (?o) { debug_show o.price # " owner=" # (if (Principal.equal(o.owner, atk)) "attacker" else "amm") }; case null { "none" } };
  Debug.print("    book after processDeferred: best bid=" # bestBid # " best ask=" # bestAsk);
  // victim FOK cross swap, buy leg: proceeds $500 (sell leg already settled at :4746)
  Accounts.setBalance(a, victim, QUOTE, grossUsd);
  let spent = gateSpent(s, a, buyCap, grossUsd, 0);
  let gatePass = not (grossUsd > spent + budgetCrumb(buyCap));
  let best = switch (OB.findBestMatch(s, mkt, #buy)) { case (?o) { o.price }; case null { 0 } };
  let buyQty = Fixed.div(Fixed.mulDiv(grossUsd, 10_000, 10_000 + TAKER_BPS, false), best, false);
  let r = MX.executeMarketOrderProtected(s, a, mkt, base, victim, #buy, buyQty, slip, false, 3, ctx, ?grossUsd, ?buyCap);
  Debug.print("    gate (buyCap 105.00 only): spent=" # debug_show spent # " -> " # (if gatePass "PASS" else "KILL"));
  Debug.print("    engine band = min(" # debug_show best # " x 1.05 = " # debug_show (Fixed.mul(best, Fixed.SCALE + slip, false)) # ", buyCap " # debug_show buyCap # ")");
  Debug.print("    buy leg: filled=" # debug_show r.totalFilled # " quoteSpent=" # debug_show r.quoteSpent # " stop=" # debug_show r.stopReason
    # " | victim BTC=" # debug_show Accounts.getBalance(a, victim, base) # " ICPUSD left=" # debug_show Accounts.getBalance(a, victim, QUOTE)
    # " | recorded filled=" # debug_show (r.totalFilled > 0));
};
B2 attacker limit sell @95.10: trades=3 stop=#bookExhausted resting remainder=1_060_000 status=#partiallyFilled attackerICPUSD=59_730_210_000
    book after processDeferred: best bid=none best ask=9_510_000_000 owner=attacker
    gate (buyCap 105.00 only): spent=49_999_999_999 -> PASS
    engine band = min(9_510_000_000 x 1.05 = 9_985_500_000, buyCap 10_500_000_000)
    buy leg: filled=1_060_000 quoteSpent=100_906_806 stop=#slippage | victim BTC=1_060_000 ICPUSD left=49_899_093_194 | recorded filled=true

Repro 3. Trimmed lines 1-24: imports; seller 2vxsx-fae, victim aaaaa-aa, amm, treas as in repro 1; ctx = repro 1's mkCtx with a flat 10 bps fee, nothing expired, no-op notices.

repro.mo (moc 1.9.0, commit 7bcdde6)
// mid 100; AMM asks 100.2 / 100.4 / 100.6 x 5 BTC each; slippage 1%; buyCap = mid*1.01 = 101
let cap : Nat = 10_100_000_000; let slip : Nat = 1_000_000;
func run(label_ : Text, lowAsk : ?Nat) {
  let s = OB.emptyStore(); let a = Accounts.emptyState();
  Accounts.setBalance(a, amm, base, 1_500_000_000);
  Accounts.setBalance(a, seller, base, 1_000_000);   // 0.01 BTC remainder, funded
  Accounts.setBalance(a, victim, QUOTE, 100_000_000_000); // $1000 proceeds
  switch (lowAsk) { case (?px) { ignore OB.createOrder(s, mkt, seller, #sell, #limit, px, 1_000_000, 1) }; case null {} };
  ignore OB.createOrder(s, mkt, amm, #sell, #limit, 10_020_000_000, 500_000_000, 1);
  ignore OB.createOrder(s, mkt, amm, #sell, #limit, 10_040_000_000, 500_000_000, 1);
  ignore OB.createOrder(s, mkt, amm, #sell, #limit, 10_060_000_000, 500_000_000, 1);
  let r = MX.executeMarketOrderProtected(s, a, mkt, base, victim, #buy, 900_000_000, slip, false, 2, ctx(a, s), ?100_000_000_000, ?cap);
  Debug.print(label_ # ": filled=" # debug_show r.totalFilled # " spent=" # debug_show r.quoteSpent # " stop=" # debug_show r.stopReason);
};
run("control (no low ask)", null);
run("resting 0.01 BTC ask @ 99.10 (0.991 mid)", ?9_910_000_000);
run("resting 0.01 BTC ask @ 99.25 (0.9925 mid)", ?9_925_000_000);
control (no low ask): filled=900_000_000 spent=90_350_260_000 stop=#filled
resting 0.01 BTC ask @ 99.10 (0.991 mid): filled=1_000_000 spent=99_199_100 stop=#slippage
resting 0.01 BTC ask @ 99.25 (0.9925 mid): filled=501_000_000 spent=50_249_449_250 stop=#slippage

Other routes (traced). (a) AMM bids absent (cash floor, _bidsGone): any ask rests down to mark/100; the vault-wide floor usually kills the swap at the sell leg anyway. (b) AMM half-spread above about slip / 2: an ordinary user ask in the spread suffices.

Impact

FOK: H-21's all-or-nothing promise breaks; the source is sold and proceeds stay in ICPUSD. Non-FOK: more is sold than converts, against :4673-4676. Nothing is stolen; the victim pays fees, spread and a swap back. Rated on the broken guarantee, not the play-money sum. The attacker cannot target a sealed swap and needs 6 BTC of inventory. Not established: how often (b) occurs live.

Relationship to existing reports

  • F14 / H-20 (internal scan): sell-side mirror; H-20's brief says the release "sizes with the same buyCap it executes under", untrue on the buy side, so the fix is incomplete. H-21 is the guarantee broken.
  • #48 (OhShii Labs): #48.5 (main.mo:3765-3771) made the direct FOK gate simulate at the executed price; the cross buy leg never got it.
  • #64 (@CanerKocak), #67.2 (OhShii Labs): same symptom, maker-funding root cause; here no maker is mis-scored.
  • #67.6 (OhShii Labs): nearest non-FOK. Its fix (the FOK gate's exclusions) does not close this: the FOK gate has them, and a funded third-party ask meets none. Its filled = true misreport is not claimed here.

Suggested fix

  1. Measure under the executed band (the #48.5 pattern): buyCeil = min(buyCap, bestAsk × (1 + slip)) for fokCrossGate (:4631), crossableDepth (:4717) and the buy leg (:4793).
  2. Stronger: MatchingEngine.mo:338 → case (#buy, ?cap) { (cap, bandMin) }. Only this buy leg passes a #buy cap (the rebalancer passes null, main.mo:5192); the H-20 pins (tests/MatchingEngine.test.mo:1205-1242) still hold.

Test: dust ask at 0.95 × mid; assert the FOK cross swap fully converts or sells nothing.


Part 9 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.

Ngôn ngữ chính
Shell
Star
19
Fork
7
Chỉ số merge pull request
Không có pull request nào được merge trong 30 ngày

Chuẩn bị môi trường

Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của dfinity/public-multidex

Tất cả issue của dfinity/public-multidex

Issue tương tự

Thêm issue về Shell/Bash

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.