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
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
- main.mo:4647-4824
releaseCrossSwap:buyCap:4698, FOK full size:4712, non-FOK sizing:4717, sell leg:4746-4748, buy leg:4792-4794, outcome:4810-4820 - main.mo:4616-4641
fokCrossGate; main.mo:3320-3349walkFillable; main.mo:3166-3192crossableDepth - MatchingEngine.mo:326-341 band anchor;
:390stops before the skips at:397-425 - OrderBook.mo:497-546
findBestMatch: no funding, expiry or owner filter
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
buyCapit 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 = truemisreport is not claimed here.
Suggested fix
- Measure under the executed band (the
#48.5pattern):buyCeil = min(buyCap, bestAsk × (1 + slip))forfokCrossGate(:4631),crossableDepth(:4717) and the buy leg (:4793). - Stronger:
MatchingEngine.mo:338→case (#buy, ?cap) { (cap, bandMin) }. Only this buy leg passes a#buycap (the rebalancer passesnull,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
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- 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.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của dfinity/public-multidex
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 35/100
dfinity/public-multidex#96 ·
-
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 45/100
dfinity/public-multidex#95 ·
-
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 35/100
dfinity/public-multidex#94 ·
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 15/100
dfinity/public-multidex#93 ·
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 20/100
dfinity/public-multidex#92 ·
Tất cả issue của dfinity/public-multidex
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
Maintainer thường phản hồi trong vòng 1 ngày
-
S3: friction & debt
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
MedakaLang/medaka#5010 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
[BUG] apply-crs.sh reads wrong config.ini key ('namespace' instead of 'PH_NAMESPACE') and uses non-portable mktemp suffixCó thể đã có người làm @muskan-creates352 đã nhận hôm nay. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 85/100
openMF/mifos-gazelle#214 ·
-
status: ready
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
melodic-software/claude-code-plugins#14389 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
area:ci good first issue priority:p2 type:test
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
Maintainer thường phản hồi trong vòng 1 ngày