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

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

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

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
15/100
issue の種類
バグ
明瞭さ
おおむね明確
活発さ
活発

調査の方向性

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.

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

説明

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.

主要言語
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 を短くまとめたダイジェスト。