fix(pair): named min-size for dust limit rungs (Overflow Cannot Sub on tiny batch/ladder) #1219
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:implement
agent:implement
agent:open_issues
agent:ready
agent:research
agent:security_audit
agent:verify
architecture
backend
blocker:hybrid
blocker:launch
blocker:limit-orders
blocker:v2
block:log_only
block:security
bug
ci
contracts
correctness
deploy
dev
devops
docs
documentation
duplicate
e2e
enhancement
epic
feature
frontend
functional-completion
gas
good first issue
governance
help wanted
high-risk
hooks
hybrid
indexer
infra
infrastructure
integrators
invalid
launch-blocker
limit-orders
localnet
localterra
low priority
missing-implementation
needs-design
ops
performance
priority
high
priority
medium
product
qa
QA
question
ready
ready
research
scripts
security
security-hardening
smartcontracts
tech-debt
testing
ux
UX
v2
verification
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-dex-terraclassic#1219
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
PlaceLimitOrderBatch/PlaceLimitOrderLadder(ladder expands into the same batch execute) can fail a whole CW20sendwith CosmWasmOverflow: Cannot Sub with 1 and 3: execute wasm contract failedwhen a rung is at single-digit raw units, or when a descending ladder subtracts
end - starton Decimal atomics1and3. Retail expected a named minimum size (or a successful place). One dust rung currently reverts healthy rungs.This issue bundles (do not split):
LIMIT_ORDER_DUST_FLUSH_THRESHOLD= 10 raw escrow units). Same gate for batch, ladder, and retailn=1.ladder_pricesmust step fromstarttowardendwhenend < startwithoutchecked_suboverflow. Boundary-rung hints already assume this (ladder_boundary_rung_index)./tradeticket +/limitsladder must not sign a dust rung; show the same minimum in retail copy.Not implemented on current
main. Not a duplicate of closed #467 (price band), #264 (match-time park), or open #1225 (in-bandMIN_LIMIT_PRICEasks that skip forever). Leave #1225 open for match/eviction of legacy unfillable heads.Given / When / Then
Given a factory CW20/CW20 pair at default
fee_bps(30 → maker 15 bps)When
PlaceLimitOrderBatchincludes any rung with grossamountin1…9(or post-fee remaining< 10)Then the tx reverts with a named
ContractError(notOverflow: Cannot Sub …); no book row; CW20 send atomically undone; other rungs in that tx are not partially insertedGiven
PlaceLimitOrderLadderwithstart_price > end_priceand both prices in the human band (including 18-vs-6 raw atomics1and3)When
count ≥ 2andtotal_amountis large enough for every rung after the min-size gateThen rungs expand, place, and sum to
total_amount— no OverflowGiven the official dApp ladder / single place
When the user types a total that would assign
< 10raw to any rungThen Place is disabled with a named minimum; no
increase_allowanceCurrent codebase
Retail place is always CW20
Send→ pairReceive→Cw20HookMsg::PlaceLimitOrderBatch(one item) orPlaceLimitOrderLadder. Ladder callsexpand_limit_ladderthen the sameexecute_place_limit_orders_batch(smartcontracts/contracts/pair/src/limit_placement.rs). Failed txs still look like “the batch” to the wallet.Amount gate is zero-only
validate_placement_itemrejectsamount == 0(ZeroAmount) and the L20 human price band. It does not require a minimum residual. Maker fee isfloor(amount × maker_bps / 10_000)withmaker_bps = effective_fee_bps / 2(15 bps at default 30). Foramount < 667, fee is 0.remaining_for_book = amount.checked_sub(maker_fee)therefore succeeds foramount = 1.LimitOrderMakerFeeExceedsAmount(“Limit order amount too small after maker fee”) only fires whenmaker_fee >= amount(impossible at ≤100% bps foramount > 0once fee is the floor product). The display string is misleading. There is no test that placesamount ∈ {1,9,10}.Dust exists only at match time
LIMIT_ORDER_DUST_FLUSH_THRESHOLD = 10(L16 / #264) parks0 < remaining < 10after a fill. Placement can restremaining = 1…9forever. DefaultCleanLimitBookthresholds are 0 (#263), so keepers do not evict them. Tier 9limit_discount_bps = 10000makes placement free (#514 / I13); internal audit already flaggedamount = 1spam.Why
Cannot Sub with 1 and 3CosmWasm prints
Overflow: Cannot Sub with {a} and {b}forUint128a.checked_sub(b)(Decimal uses the atomics). Two in-tree paths match the report:ladder_pricesdoesend.checked_sub(start)and maps the error to"ladder price range overflow". On wasm without that map (or if a caller hits a raw?on Decimal atomics), 18-vs-6 pricesDecimal::raw(1)andDecimal::raw(3)pass the human band (1e-6/3e-6>MIN_LIMIT_PRICE) and overflow asCannot Sub with 1 and 3.ladder_boundary_rung_indexalready treatsstart > endas valid. Unit tests only expand ascending prices.checked_subis guarded today; do not assume it stays that way if fee math changes to ceil / min-fee. Fail closed with a named min before any sub.Validation is all-or-nothing (docs/
limit-orders.md). A single dust / overflow rung reverts the entiresend(reporter’s “other rungs die with it”). Book-walk skip (LimitInsertStepsExceeded) is unrelated.dApp
LimitOrderLadderPanel+expandLimitLaddersplittotal / countwith remainder on the last rung.total=1,count=3→[0,0,1]→ on-chainZeroAmount, not Overflow. JS handles descending prices; on-chain ladder does not. Escrow gates compare total human vs wallet (limitOrderEscrowBalanceGate.ts); they do not require per-rung raw ≥ 10.TradeOrderTickettoRawAmountcan submit1on an 18-dec token. Swarm usesMIN_SWAP_OR_ESCROW_AMOUNT = 500_000only as a bot heuristic.Related tickets (not this work)
amount=1dust priceDecimal::raw(1)is already rejected on equal-decCannot Sub— different contractsbelief_pricedust, not limitsSendto a listed pair is inbound 1:1 (T592-1)Why the new implementation is needed
1and3does not tell the maker the minimum size. Integrators and the dApp cannot branch on it.end < start(equal-dec human3 → 1overflows 1e18-scale; 18-vs-6 raw3 → 1is the literal1and3string).amount=1at gas-only cost.Pair wasm + a thin dApp gate. No indexer schema. No factory migrate of existing rows (legacy dust stays until cancel / #1225 / clean).
Constraints / guardrails
LimitInsertStepsExceeded(partial place + refund). Invalid size is all-or-nothing, same as price band / empty batch.LIMIT_ORDER_DUST_FLUSH_THRESHOLD. Do not invent a second constant unless docs say why. Grossamountmust be> maker_feeand remaining≥ 10.maker_fee >= amount→ existing variant; add a new variant (or fix the existing one’s semantics) for remaining< 10.ladder_pricesmust use an absolute step (start ± i × |end−start|/(count−1)), last rung exactlyend. Do not require the UI to sort start < end. Keep L20 on every rung. Do not weakenMIN_LIMIT_PRICE.remaining.checked_mul_floor(price) < 1, reject here too or document that #1225 owns it. Do not implement match-time park /CleanLimitBookchanges in this MR.UpdateLimitOrderPricedoes not changeremaining— out of scope for size. Pause still blocks place. Blacklist / F6 code-id gates stay.Send. Do not mix #1228.shell-panel*/ lecture banner (C653 / #489). Disable Place + one short line (e.g. “Minimum per order is 10 units”). Do not broadcast then toast Overflow.readyuntil tests exist.Relevant files
smartcontracts/contracts/pair/src/limit_placement.rsvalidate_placement_item; maker fee; batch loopsmartcontracts/contracts/pair/src/error.rsLimitOrderMakerFeeExceedsAmountcopy)smartcontracts/packages/dex-common/src/limit_placement.rsladder_pricesdescending; optional shared min-size helper; testssmartcontracts/packages/dex-common/src/pair.rsLIMIT_ORDER_DUST_FLUSH_THRESHOLD— document as place + flush floorsmartcontracts/tests/src/limit_order_tests.rsfrontend-dapp/src/utils/limitOrderLadder.tsfrontend-dapp/src/components/trade/LimitOrderLadderPanel.tsxfrontend-dapp/src/components/trade/TradeOrderTicket.tsxfrontend-dapp/src/utils/limitOrderEscrowBalanceGate.tsdocs/limit-orders.md/docs/contracts-security-audit.mdskills/AGENTS_LIMIT_ORDER_BATCH_LADDER.mdscripts/qa/verify-issue-1219.shmake verify-issue-1219Recommended direction
min_limit_place_remaining()index-common=LIMIT_ORDER_DUST_FLUSH_THRESHOLD. After computingmaker_fee, ifamount ≤ maker_feekeep/rename fee-exceeds; else ifamount - fee < minreturnLimitOrderAmountTooSmall { min, actual }(include both in the string). Call fromvalidate_placement_itemor immediately after fee (fee depends on bps). Ladderexpand_limit_ladder: if any equal-split rung would be< min(including zeros fromtotal < count),StdErrorwith the same min before batch execute.ladder_prices:span = |end − start|;step = span / (count−1); rungi=starttowardend; last =endexactly. Table-test equal-dec3 → 1,1 → 3, and 18-vs-6Decimal::raw(3) → Decimal::raw(1)(must not Overflow).expandLimitLadderthrows if anyamountRaw < 10n; ticket submit if escrow raw< 10. Copy: Minimum size is 10 units (not “Overflow”).limit-orders.mdplace section; L16 note “placement also rejects remaining< 10”. Playbook invariant.make verify-issue-1219greps the named error + descending test + frontend min.Acceptance criteria
n=1withamount ∈ {1,9}(valid in-band price) reverts named min-size; message includes the minimum10; noOverflow: Cannot Sub.amountsuch that post-fee remaining≥ 10still places;remaining == amount - floor(amount × maker_bps / 10_000).ORDERS/ pending escrow.total_amountthat would assign< 10to any equal-split rung (incl.total < count) reverts named / ladder-invalid before inserts.start > endwith in-band prices (equal-dec and 18-vs-6 raw3 → 1) placescountrungs; prices monotonic towardend; amounts sum tototal_amount.limit_discount_bps = 10000, fee 0) cannot placeamount < 10.make verify-issue-467/#529/#264/#1227behavior unchanged.#1225not closed by this MR.AGENTS_LIMIT_ORDER_BATCH_LADDER.md+make verify-issue-1219.Test plan (functional paths)
amount=1, price 1.0, 6/6amount=9amount=10, fee 0 or 15 bps (remaining ≥ 10)amount=667@ 15 bps (fee 1)1000,1,1000count=3,total=1count=3,total=30equal-dec 0.95→1.05Decimal::one()*3, endDecimal::one(), count 3Decimal::raw(3)→Decimal::raw(1)Cannot Sub with 1 and 3; L20 still enforced per runglimit-orders-tx5-rung (LocalTerra)Contracts:
cl8y-dex-testslimit_order_tests+dex-commonlimit_placementunit. Frontend:limitOrderLadder+ ticket min-raw Vitest. 5 Playwright workers when chain E2E is in the MR.Test plan (attack, hack, and abuse)
amount=1×max_batch_rungstotal=29,count=3→ 9,9,11 or 9,9,11 depending on split)< 10, reject allCannot Submatch_asksLimitInsertStepsExceededremaining=0sendamount=1)UpdateLimitOrderPriceon a legacyremaining=1rowNo public mainnet attack tx. In-tree multitest only.
Verification criteria
make verify-issue-1219(named error + descending ladder unit + frontend min-raw + docs greps).cd smartcontracts && cargo test -p cl8y-dex-tests --test limit_order_tests -- --test-threads=1paths T1–T9, A1–A6.cd frontend-dapp &&VitestlimitOrderLadder+ ticket/ladder Place disabled on dust./limitsladder total that splits under 10 → no Keplr popup; raise total → place. Descending start/end on UST1/USTR (6/18) and a 18/6 pair if present.Out of scope
CleanLimitBookdefaults (#1225, #264).max_batch_rungsor gas envelopes.Original report
Reporter (LeonardoLUNC). Preserved during issue repair.
Placing a limit-order batch on a cw20/cw20 pair fails on-chain with code 5:
Overflow: Cannot Sub with 1 and 3: execute wasm contract failedwhen one or more rungs in the batch carry an amount at the very bottom of the token's precision (single-digit base units). Larger batches on the same pair and the same code path succeed normally.
Expected: either the rung is accepted, or the contract returns a clear validation error naming a minimum order size.
Actual: an Overflow panic that fails the whole batch, so one dust rung rejects the other rungs alongside it.
Suggestion: validate a minimum order amount per rung up front and return a descriptive error, and/or use checked/saturating subtraction on that path.
Approved for issue repair -> implement
place_limit_order_batch fails with "Overflow: Cannot Sub with 1 and 3" on very small order amountsto fix(pair): named min-size for dust limit rungs (Overflow Cannot Sub on tiny batch/ladder)Issue repair — Definition of Ready written from current
main(pairlimit_placement+dex-commonladder expand + dApp ladder/ticket).Not implemented. Placement still allows
amount=1…9. Descendingladder_pricesstill doesend.checked_sub(start).Not a duplicate. Do not close #467, #264, or #1225. Bundle stays: named min remaining (=10), descending ladder math, dApp preflight. Original reporter text is appended on the issue body.
Summary
PlaceLimitOrderBatch/PlaceLimitOrderLadder(ladder expands into the same batch execute) can fail a whole CW20sendwith CosmWasmOverflow: Cannot Sub with 1 and 3: execute wasm contract failedwhen a rung is at single-digit raw units, or when a descending ladder subtracts
end - starton Decimal atomics1and3. Retail expected a named minimum size (or a successful place). One dust rung currently reverts healthy rungs.This issue bundles (do not split):
LIMIT_ORDER_DUST_FLUSH_THRESHOLD= 10 raw escrow units). Same gate for batch, ladder, and retailn=1.ladder_pricesmust step fromstarttowardendwhenend < startwithoutchecked_suboverflow. Boundary-rung hints already assume this (ladder_boundary_rung_index)./tradeticket +/limitsladder must not sign a dust rung; show the same minimum in retail copy.Not implemented on current
main. Not a duplicate of closed #467 (price band), #264 (match-time park), or open #1225 (in-bandMIN_LIMIT_PRICEasks that skip forever). Leave #1225 open for match/eviction of legacy unfillable heads.Given / When / Then
Given a factory CW20/CW20 pair at default
fee_bps(30 → maker 15 bps)When
PlaceLimitOrderBatchincludes any rung with grossamountin1…9(or post-fee remaining< 10)Then the tx reverts with a named
ContractError(notOverflow: Cannot Sub …); no book row; CW20 send atomically undone; other rungs in that tx are not partially insertedGiven
PlaceLimitOrderLadderwithstart_price > end_priceand both prices in the human band (including 18-vs-6 raw atomics1and3)When
count ≥ 2andtotal_amountis large enough for every rung after the min-size gateThen rungs expand, place, and sum to
total_amount— no OverflowGiven the official dApp ladder / single place
When the user types a total that would assign
< 10raw to any rungThen Place is disabled with a named minimum; no
increase_allowanceCurrent codebase
Retail place is always CW20
Send→ pairReceive→Cw20HookMsg::PlaceLimitOrderBatch(one item) orPlaceLimitOrderLadder. Ladder callsexpand_limit_ladderthen the sameexecute_place_limit_orders_batch(smartcontracts/contracts/pair/src/limit_placement.rs). Failed txs still look like “the batch” to the wallet.Amount gate is zero-only
validate_placement_itemrejectsamount == 0(ZeroAmount) and the L20 human price band. It does not require a minimum residual. Maker fee isfloor(amount × maker_bps / 10_000)withmaker_bps = effective_fee_bps / 2(15 bps at default 30). Foramount < 667, fee is 0.remaining_for_book = amount.checked_sub(maker_fee)therefore succeeds foramount = 1.LimitOrderMakerFeeExceedsAmount(“Limit order amount too small after maker fee”) only fires whenmaker_fee >= amount(impossible at ≤100% bps foramount > 0once fee is the floor product). The display string is misleading. There is no test that placesamount ∈ {1,9,10}.Dust exists only at match time
LIMIT_ORDER_DUST_FLUSH_THRESHOLD = 10(L16 / #264) parks0 < remaining < 10after a fill. Placement can restremaining = 1…9forever. DefaultCleanLimitBookthresholds are 0 (#263), so keepers do not evict them. Tier 9limit_discount_bps = 10000makes placement free (#514 / I13); internal audit already flaggedamount = 1spam.Why
Cannot Sub with 1 and 3CosmWasm prints
Overflow: Cannot Sub with {a} and {b}forUint128a.checked_sub(b)(Decimal uses the atomics). Two in-tree paths match the report:ladder_pricesdoesend.checked_sub(start)and maps the error to"ladder price range overflow". On wasm without that map (or if a caller hits a raw?on Decimal atomics), 18-vs-6 pricesDecimal::raw(1)andDecimal::raw(3)pass the human band (1e-6/3e-6>MIN_LIMIT_PRICE) and overflow asCannot Sub with 1 and 3.ladder_boundary_rung_indexalready treatsstart > endas valid. Unit tests only expand ascending prices.checked_subis guarded today; do not assume it stays that way if fee math changes to ceil / min-fee. Fail closed with a named min before any sub.Validation is all-or-nothing (docs/
limit-orders.md). A single dust / overflow rung reverts the entiresend(reporter’s “other rungs die with it”). Book-walk skip (LimitInsertStepsExceeded) is unrelated.dApp
LimitOrderLadderPanel+expandLimitLaddersplittotal / countwith remainder on the last rung.total=1,count=3→[0,0,1]→ on-chainZeroAmount, not Overflow. JS handles descending prices; on-chain ladder does not. Escrow gates compare total human vs wallet (limitOrderEscrowBalanceGate.ts); they do not require per-rung raw ≥ 10.TradeOrderTickettoRawAmountcan submit1on an 18-dec token. Swarm usesMIN_SWAP_OR_ESCROW_AMOUNT = 500_000only as a bot heuristic.Related tickets (not this work)
amount=1dust priceDecimal::raw(1)is already rejected on equal-decCannot Sub— different contractsbelief_pricedust, not limitsSendto a listed pair is inbound 1:1 (T592-1)Why the new implementation is needed
1and3does not tell the maker the minimum size. Integrators and the dApp cannot branch on it.end < start(equal-dec human3 → 1overflows 1e18-scale; 18-vs-6 raw3 → 1is the literal1and3string).amount=1at gas-only cost.Pair wasm + a thin dApp gate. No indexer schema. No factory migrate of existing rows (legacy dust stays until cancel / #1225 / clean).
Constraints / guardrails
LimitInsertStepsExceeded(partial place + refund). Invalid size is all-or-nothing, same as price band / empty batch.LIMIT_ORDER_DUST_FLUSH_THRESHOLD. Do not invent a second constant unless docs say why. Grossamountmust be> maker_feeand remaining≥ 10.maker_fee >= amount→ existing variant; add a new variant (or fix the existing one’s semantics) for remaining< 10.ladder_pricesmust use an absolute step (start ± i × |end−start|/(count−1)), last rung exactlyend. Do not require the UI to sort start < end. Keep L20 on every rung. Do not weakenMIN_LIMIT_PRICE.remaining.checked_mul_floor(price) < 1, reject here too or document that #1225 owns it. Do not implement match-time park /CleanLimitBookchanges in this MR.UpdateLimitOrderPricedoes not changeremaining— out of scope for size. Pause still blocks place. Blacklist / F6 code-id gates stay.Send. Do not mix #1228.shell-panel*/ lecture banner (C653 / #489). Disable Place + one short line (e.g. “Minimum per order is 10 units”). Do not broadcast then toast Overflow.readyuntil tests exist.Relevant files
smartcontracts/contracts/pair/src/limit_placement.rsvalidate_placement_item; maker fee; batch loopsmartcontracts/contracts/pair/src/error.rsLimitOrderMakerFeeExceedsAmountcopy)smartcontracts/packages/dex-common/src/limit_placement.rsladder_pricesdescending; optional shared min-size helper; testssmartcontracts/packages/dex-common/src/pair.rsLIMIT_ORDER_DUST_FLUSH_THRESHOLD— document as place + flush floorsmartcontracts/tests/src/limit_order_tests.rsfrontend-dapp/src/utils/limitOrderLadder.tsfrontend-dapp/src/components/trade/LimitOrderLadderPanel.tsxfrontend-dapp/src/components/trade/TradeOrderTicket.tsxfrontend-dapp/src/utils/limitOrderEscrowBalanceGate.tsdocs/limit-orders.md/docs/contracts-security-audit.mdskills/AGENTS_LIMIT_ORDER_BATCH_LADDER.mdscripts/qa/verify-issue-1219.shmake verify-issue-1219Recommended direction
min_limit_place_remaining()index-common=LIMIT_ORDER_DUST_FLUSH_THRESHOLD. After computingmaker_fee, ifamount ≤ maker_feekeep/rename fee-exceeds; else ifamount - fee < minreturnLimitOrderAmountTooSmall { min, actual }(include both in the string). Call fromvalidate_placement_itemor immediately after fee (fee depends on bps). Ladderexpand_limit_ladder: if any equal-split rung would be< min(including zeros fromtotal < count),StdErrorwith the same min before batch execute.ladder_prices:span = |end − start|;step = span / (count−1); rungi=starttowardend; last =endexactly. Table-test equal-dec3 → 1,1 → 3, and 18-vs-6Decimal::raw(3) → Decimal::raw(1)(must not Overflow).expandLimitLadderthrows if anyamountRaw < 10n; ticket submit if escrow raw< 10. Copy: Minimum size is 10 units (not “Overflow”).limit-orders.mdplace section; L16 note “placement also rejects remaining< 10”. Playbook invariant.make verify-issue-1219greps the named error + descending test + frontend min.Acceptance criteria
n=1withamount ∈ {1,9}(valid in-band price) reverts named min-size; message includes the minimum10; noOverflow: Cannot Sub.amountsuch that post-fee remaining≥ 10still places;remaining == amount - floor(amount × maker_bps / 10_000).ORDERS/ pending escrow.total_amountthat would assign< 10to any equal-split rung (incl.total < count) reverts named / ladder-invalid before inserts.start > endwith in-band prices (equal-dec and 18-vs-6 raw3 → 1) placescountrungs; prices monotonic towardend; amounts sum tototal_amount.limit_discount_bps = 10000, fee 0) cannot placeamount < 10.make verify-issue-467/#529/#264/#1227behavior unchanged.#1225not closed by this MR.AGENTS_LIMIT_ORDER_BATCH_LADDER.md+make verify-issue-1219.Test plan (functional paths)
amount=1, price 1.0, 6/6amount=9amount=10, fee 0 or 15 bps (remaining ≥ 10)amount=667@ 15 bps (fee 1)1000,1,1000count=3,total=1count=3,total=30equal-dec 0.95→1.05Decimal::one()*3, endDecimal::one(), count 3Decimal::raw(3)→Decimal::raw(1)Cannot Sub with 1 and 3; L20 still enforced per runglimit-orders-tx5-rung (LocalTerra)Contracts:
cl8y-dex-testslimit_order_tests+dex-commonlimit_placementunit. Frontend:limitOrderLadder+ ticket min-raw Vitest. 5 Playwright workers when chain E2E is in the MR.Test plan (attack, hack, and abuse)
amount=1×max_batch_rungstotal=29,count=3→ 9,9,11 or 9,9,11 depending on split)< 10, reject allCannot Submatch_asksLimitInsertStepsExceededremaining=0sendamount=1)UpdateLimitOrderPriceon a legacyremaining=1rowNo public mainnet attack tx. In-tree multitest only.
Verification criteria
make verify-issue-1219(named error + descending ladder unit + frontend min-raw + docs greps).cd smartcontracts && cargo test -p cl8y-dex-tests --test limit_order_tests -- --test-threads=1paths T1–T9, A1–A6.cd frontend-dapp &&VitestlimitOrderLadder+ ticket/ladder Place disabled on dust./limitsladder total that splits under 10 → no Keplr popup; raise total → place. Descending start/end on UST1/USTR (6/18) and a 18/6 pair if present.Out of scope
CleanLimitBookdefaults (#1225, #264).max_batch_rungsor gas envelopes.Merged as PR #1296. Named
LimitOrderAmountTooSmallplus descending absolute-span ladders. L23 F6 reprice gate from #1234 kept.Leftover: columbus-5 pair wasm migrate; Playwright T13 5-rung LocalTerra. Tracked on #1300. #1225 stays open (unfillable in-band asks).