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
まだ誰も着手していません。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 15/100
- issue の種類
- バグ
- 明瞭さ
- おおむね明確
- 活発さ
- 活発
- 領域
- backend, fintech-quant
調査の方向性
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
- 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.
- 主要言語
- Shell
- スター
- 19
- フォーク
- 7
- PR マージ指標
- 30日以内にマージされた PR はありません
環境構築
このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
dfinity/public-multidex のほかの issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 35/100
dfinity/public-multidex#96 ·
-
難易度 3/5 1〜2日 初心者へのやさしさ 45/100
dfinity/public-multidex#95 ·
-
難易度 3/5 1〜2日 初心者へのやさしさ 35/100
dfinity/public-multidex#94 ·
-
難易度 4/5 3〜5日 初心者へのやさしさ 15/100
dfinity/public-multidex#93 ·
-
難易度 4/5 3〜5日 初心者へのやさしさ 20/100
dfinity/public-multidex#92 ·
dfinity/public-multidex の issue をすべて見る
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
メンテナーはふだん 1 日以内に返信
-
S3: friction & debt
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
MedakaLang/medaka#5010 ·
メンテナーはふだん 1 日以内に返信
-
[BUG] apply-crs.sh reads wrong config.ini key ('namespace' instead of 'PH_NAMESPACE') and uses non-portable mktemp suffix対応中かも @muskan-creates352 が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
openMF/mifos-gazelle#214 ·
-
status: ready
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
melodic-software/claude-code-plugins#14389 ·
メンテナーはふだん 1 日以内に返信
-
area:ci good first issue priority:p2 type:test
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信