bug(frontend): one-sided zap Add reverts provide_liquidity when swap fill is below quote (Overflow Cannot Sub) #559

Closed
opened 2026-08-18 00:43:28 +00:00 by PlasticDigits · 16 comments
PlasticDigits commented 2026-08-18 00:43:28 +00:00 (Migrated from gitlab.com)

Summary

Retail one-sided Add (automatic zap) on production dex.cl8y.com reverts with CosmWasm Overflow: Cannot Sub during provide_liquidity. The swap leg is allowed to return any amount ≥ min_return (default 5% below the quote), but the following provide still TransferFroms the quoted ask amount. Any fill in (min_return, quote) succeeds the swap and then fails the provide.

Reported on columbus-5, pair UST1 / cUSTC (terra1ceprjsxp86ggftf5e38wwt34l83e5gq7penkdnv4wsatkwcs8v6qccw55f).

Evidence (mobile wallet, Pool → Add):

  • Amount line: 200 in + min swap 500571
  • Quote line: Zap. Est. LP ~0.000000000007259
  • Error: failed to execute message; message index: 3: Overflow: Cannot Sub with 525495 and 526916: execute wasm contract failed

500571 ≈ 526916 × 0.95 (Settings 5% slippage). 525495 is ~0.27% below the quote and above min_return, so this is not a swap slippage reject.

Related: #533 (one-sided zap), #342 (historical Cannot Sub on wrap amounts — different cause), #508 (UST1/cUSTC secondary AMM), #537 (I14 discount gating), #147 (allowance rollback is for sequential 3-tx provide, not this multi-msg).

Do not split zap-in provide, zap-out unwrap/swap, and the Add quote/pre-sign display into separate issues. Same multi-msg “next leg sized to quote” class.

Current codebase

#533 shipped retail /pool one-sided zap as frontend orchestration on existing messages (Z533-10: no pair/router Zap execute).

Step What the code does today
Quote quoteOneSidedAdd runs local zapInSplit on LCD getPool (React Query staleTime: 15_000). It does not call pair simulateHybridSwap. swapMinReturn = applySlippagePercentFloor(swapOut, slippage%). provideOffer / provideAsk are split.provideIn / split.provideOut from the quoted net, not the floor.
Fee Split uses effectivePoolFeeBps(fee_bps, advertisedDiscountBps). I14 gates discount on pair DISCOUNT_REGISTRY (#537). Local math can still disagree with execute (stale reserves, rounding, hook skim, mempool).
Tx (no wrap, pair-leg — this report) buildZapInMessages: [0] CW20 send + swap (min_return) → [1] increase_allowance offer → [2] increase_allowance ask → [3] provide_liquidity. Matches message index: 3.
Provide amounts provide_liquidity.assets[].amount = quoted provideAsk. Pair then CW20 TransferFrom that exact amount (pair/src/contract.rs execute_provide_liquidity). CW20 checked_sub on a balance of 525495 vs amount 526916 is the observed Overflow: Cannot Sub with 525495 and 526916.
Slippage Swap min_return is the 5% floor. Provide slippage_tolerance is set (Z533-7). That does not shrink the TransferFrom amounts.
Preflight Retail execute uses static gas envelopes (#475 / terraGasRetailInventory.ts). No LCD simulate-before-broadcast. A structurally failing zap is still signed and pays gas. Cosmos multi-msg is atomic (swap+allowances revert with provide); user loses gas only, not a half-zap.
Zap-out (same class) OneSidedWithdrawCard sets swapAmount to quoted split.swapIn and unwrap.amount to quoted split.totalWantedCw20 (wanted + quoted swap out), while the swap leg only guarantees swapMinReturn and withdraw only guarantees min_assets. A worse-than-quote fill can Cannot Sub on the swap send or unwrap send.
Tests oneSidedLiquidityTx.test.ts A18 fixture already uses provideAsk === swapMinReturn. quoteOneSidedAdd never asserts provideAsk <= swapMinReturn. No unit covers “fill in (min_return, quote) must still provide”.
Display Pre-sign shows human ${amount} in next to raw min swap ${swapMinReturn} (500571 instead of 0.500571 at 6 decimals). Est. LP uses LP CW20 18 decimals (Z533-10), so a small mint looks like ~0.000000000007259.

Pair mint/burn math is fine. This is not a wrap-tax #342 miss: the UI showed Zap. not Wrap + zap., and the failing msg is provide.

Why this is needed

  1. Retail Add is broken on mainnet for the advertised flow. Users can pass the quote, sign, pay gas, and still revert. Support screenshot is UST1/cUSTC, but any factory pair zap-in has the same bug.
  2. min_return is lying. Settings 5% is applied to the swap, then ignored when sizing the provide. The only fills that work are those ≥ the optimistic quote (a ~0% band), not the 5% the UI sold.
  3. Silent extra-balance spend if the tx ever did not revert. If the wallet already holds the ask token, TransferFrom(quoted) can succeed by dipping pre-existing ask balance to cover a shortfall. That violates Z533-4 (no silent donate / no unsolicited other-side spend). Atomic revert currently hides this; a future “retry provide” or split-tx would expose it. Size to floors so it cannot happen.
  4. Zap-out will fail the same way on unwrap/swap once users hit a worse-than-quote fill (especially native unwrap of quoted totalWantedCw20).
  5. Quote chrome is unreadable (min swap 500571 raw; dust-looking 18-dec LP) so users cannot tell a thin/skewed pool from a solver bug.

Constraints / guardrails

  1. Keep frontend multi-msg orchestration. Do not add a pair/router Zap execute unless LocalTerra rehearsal proves conservative floors still cannot work (Z533-10). Default fix is quote/tx sizing, not a contract upgrade.
  2. Do not change pair mint/burn, fee treasury, wrap-mapper bps, or factory whitelist.
  3. Z533-4: provide must stay on-ratio to post-swap reserves. Leftover stays in the wallet. Do not donate excess to make TransferFrom succeed.
  4. Z533-7: every leg keeps a floor (min_return / slippage_tolerance / min_assets). Do not set slippage_tolerance: null. Default 5% from Settings (#497).
  5. Do not spend pre-existing ask (or wanted) wallet balances to paper over a worse swap. Provide/unwrap amounts must be satisfiable from this tx’s swap/withdraw floors alone.
  6. I14 / #537: zap fee math stays behind useFeeDiscountRegistryStatus(pairAddr). Unwired pair → discountBps = 0. Do not apply a wallet get_discount to the split when the pair registry is unset.
  7. Pool-only zap swap (poolOnlyHybridParams, A13). No book leg.
  8. Same quote snapshot on submit (#356 / Z533-6). Stale pay-raw still disables CTA.
  9. Unwrap only the zap-out amount (Z533-8) — and that amount must be the floor chain, not the optimistic quote.
  10. #489 copy. Do not add architecture essays. Blocking errors stay one sentence. Pre-sign amounts must be human units, not raw uints.
  11. Keep pause, blacklist, wrap-mapper pause / treasury match, IL, clickwrap, NFA, Expert Mode impact gate, gas/Max envelopes, pre-sign (#462).
  12. Empty pool stays disabled (Z533-5). Advanced two-sided unchanged.
  13. Do not introduce LCD simulate-before-broadcast as the sole fix (conflicts with #475 static gas). Optional simulate is belt-and-suspenders only; floors must make the tx valid even if the pool moves inside the slippage window after quote.
  14. LocalTerra for on-chain paths. Do not skip with a false “no LocalTerra”.

Relevant files

File Role
frontend-dapp/src/utils/oneSidedLiquidityQuote.ts Sets provideAsk from quoted split.provideOut; swapMinReturn unused for provide
frontend-dapp/src/utils/oneSidedLiquidity.ts zapInSplit / trimProvideToRatio / zapOutSplit
frontend-dapp/src/utils/oneSidedLiquidityTx.ts Multi-msg order; provide/unwrap amounts passed through
frontend-dapp/src/components/pool/OneSidedAddCard.tsx Submit snapshot; pre-sign min swap ${swapMinReturn} raw; Est. LP 18-dec
frontend-dapp/src/components/pool/OneSidedWithdrawCard.tsx Zap-out swapAmount + unwrap of quoted totalWantedCw20
frontend-dapp/src/utils/provideLiquidityEstimate.ts LP mint estimate (post-swap reserves)
frontend-dapp/src/utils/rawAmountMath.ts applySlippagePercentFloor
frontend-dapp/src/hooks/useFeeDiscountRegistryStatus.ts I14 discountBps
smartcontracts/contracts/pair/src/contract.rs execute_provide_liquidity TransferFrom (do not change mint math)
smartcontracts/contracts/pair/src/hybrid_reverse.rs pool_net_output_for_input (solver should keep matching)
frontend-dapp/src/utils/__tests__/oneSidedLiquidityQuote.test.ts Missing floor-sized provide coverage
frontend-dapp/src/utils/__tests__/oneSidedLiquidityTx.test.ts A18 order; fixture already provideAsk === min_return
frontend-dapp/e2e/pool-one-sided-533-tx.spec.ts LocalTerra P4–P8
docs/frontend.md § One-sided pool Z533 invariants to extend
skills/AGENTS_FRONTEND_POOL_ONE_SIDED.md Agent playbook
deployments/mainnet-ust1-wrap/REGISTRY.md Mainnet UST1/cUSTC pair addr

Size every subsequent multi-msg leg to the previous leg’s floor, then trim to the conservative post-swap ratio.

Zap-in

  1. Keep solving zapInSplit on current pool + effective fee (quoted split for Est. LP / impact).
  2. Build execution amounts from the worst fill the swap is allowed to produce:
    • swapMinReturn stays floor(swapOut × (1 − slippage%)).
    • provideAsk = min(split.provideOut, swapMinReturn) (typically the floor).
    • Re-run trimProvideToRatio with swapOut = provideAsk and post-swap reserves consistent with that worse output (offer reserve still + swapIn; ask reserve higher if less ask left the pool). provideOffer shrinks; extra offer stays in the wallet.
  3. Allowances must match those conservative provide amounts (not the quote).
  4. Optional hardening: LCD simulateHybridSwap (pool-only) to refresh swapOut before flooring — not a substitute for (2).
  5. Do not require or spend ask-token balance the user held before the tx.

Zap-out

  1. swapAmount ≤ withdraw min_assets of the sold leg (not the 0% pro-rata quote).
  2. Unwrap send ≤ min(withdrawn wanted, min_assets[wanted]) + swapMinReturn (never quoted totalWantedCw20 if that exceeds the floor chain).
  3. Leftover of the sold or wanted token stays in the wallet.

Quote / pre-sign UX (same cards)

  1. Pre-sign min swap must be human (formatTokenAmount(swapMinReturn, askDecimals)), not raw.
  2. Est. LP stays 18-dec, but if conservative mint is dust / zero, disable with Amount too small (existing dust copy) rather than submitting a tx that can only mint ~1e-14 LP.
  3. Keep Zap. / Wrap + zap. / Route + zap. prefixes.

Out of scope

  • New Zap execute on pair/router.
  • Changing provide_liquidity to take max assets and refund (would be a contract change and donation foot-gun).
  • LCD simulate-before-broadcast for all retail txs (#475).

Acceptance criteria

  • AC1. Zap-in provideAsk ≤ swapMinReturn and is satisfiable if the swap returns exactly min_return and the user had zero pre-existing ask balance.
  • AC2. Zap-in provideOffer is ratio-trimmed to the conservative post-swap reserves (Z533-4). Leftover offer/ask stays in the wallet; no donate.
  • AC3. A swap fill in (min_return, quote) (e.g. quote 526916, fill 525495, min 500571) must not produce Overflow: Cannot Sub on provide. Tx either succeeds or fails at swap min_return / provide slippage_tolerance with those errors — never CW20 underflow.
  • AC4. Zap-out unwrap amount and other-side swapAmount are sized to withdraw/swap floors, not optimistic quotes. Same Cannot Sub ban.
  • AC5. Wrap + zap and route-in + zap use the same conservative provide sizing after the last hop’s min_return.
  • AC6. Unwired fee-discount pair (discountBps = 0) still floors provide; a wallet with a large get_discount must not inflate provideAsk above on-chain output.
  • AC7. Pre-sign shows human min-swap (and human Est. LP). Raw uints like 500571 must not appear next to a human 200 in.
  • AC8. Dust conservative LP → one-sentence disable (Amount too small); CTA blocked. Empty pool still Empty pool. Use Advanced.
  • AC9. A18 message order unchanged: wrap? → route? → swap → allowances ×2 → provide. slippage_tolerance set. Pool-only hybrid (book_input = 0).
  • AC10. Expert Mode / pause / blacklist / wrap treasury mismatch / gas Max gates unchanged.
  • AC11. Docs + skill: new invariant that execution amounts follow floors, quotes may be optimistic. make verify-issue-533 still passes; this issue gets make verify-issue-559.
  • AC12. LocalTerra tx: one-sided add on a seeded pair succeeds when an on-chain fill is worse than the client quote but ≥ min_return (fee mismatch or reserve nudge). UST1/cUSTC-shaped 6-dec/6-dec pair included.

Test plan (all paths)

Use invariant ids T-Z1–T-Z14 in unit tests.

ID Path Expect
T-Z1 Pair-leg zap-in, 5% slippage, 30 bps fee provideAsk <= swapMinReturn; provideOffer + leftoverIn = amountIn - swapIn; ratio trim vs conservative post-swap reserves
T-Z2 Same as T-Z1 with fill quote - 1 but >= min_return (fixture balances) Built msgs: ask TransferFrom amount ≤ fill; no Cannot Sub
T-Z3 0% slippage provideAsk == swapOut (floors collapse to quote); still ratio-trimmed
T-Z4 Wrap + zap (W8 wrap fee only) Solver input is post-fee_wrap_bps; provide floors after wrap net; no burn tax on wrap
T-Z5 Route-in + zap Route minimum_receive floors the zap amountIn; zap provide uses zap min_return
T-Z6 Zap from asset1 (not asset0) Provide asset infos still match offer/ask contracts; LP estimate uses matching reserve amounts
T-Z7 Skewed pool (UST1-heavy / dust ask) Conservative LP dust → unavailable; no broadcast
T-Z8 Empty / one-sided reserves Unchanged Empty pool. Use Advanced.
T-Z9 Fee 30 bps quote vs 0 bps quote (discount) Execution provideAsk from the on-chain effective fee (unwired → 0 discount). Inflated 0-fee quote must not raise provideAsk
T-Z10 Zap-out to pair CW20 swapAmount <= min_assets[sold]; leftover sold token OK
T-Z11 Zap-out to native (unwrap) Unwrap send ≤ floor(wanted withdrawn) + swapMinReturn
T-Z12 Pre-sign copy Human min-swap; 6-dec token does not show raw 500571
T-Z13 Advanced two-sided Unchanged; no zap builder
T-Z14 Playwright smoke P1–P3 + LocalTerra P4–P8 Existing #533 paths still pass; add P9: add succeeds after a worse-than-quote pool-only fill (or documented LCD sim of conservative msgs)

Unit files: oneSidedLiquidity.test.ts, oneSidedLiquidityQuote.test.ts, oneSidedLiquidityTx.test.ts, OneSidedAddCard / copy if needed.

Playwright: 5 workers for UI smoke; 1 worker e2e-tx for chain (pool-one-sided-533-tx.spec.ts + new case). PLAYWRIGHT_SKIP_CHAIN=1 must not be used to skip P9.

Test plan (attack, hack, abuse)

ID Vector Expect
A-Z1 Sandwich / reserve nudge inside 5% of quote Swap either fills ≥ min_return and provide succeeds at conservative amounts, or swap reverts min_return / max_spread. Never Cannot Sub on provide.
A-Z2 Pre-existing ask balance covering quote − fill Provide must not pull that extra balance. Post-tx ask increase ≈ fill − provideAsk (leftover), not − (quote − fill) from old inventory.
A-Z3 Fee-discount mismatch (UI 0 bps, chain 30 bps, or reverse) Unwired pair cannot advertise discount (I14). Provide sized as if full fee_bps when registry unset. No inflated TransferFrom.
A-Z4 Stale 15s pool cache Snapshot/pay-raw guard remains; conservative floors still cover typical 15s drift inside slippage.
A-Z5 Donation via oversize provideOffer trimProvideToRatio + slippage_tolerance; excess offer not transferred.
A-Z6 Zap-out unwrap oversize (quoted wanted+swapOut > actual) Must not unwrap other wallet cLUNC/cUSTC (Z533-8 + this floor). Cannot Sub banned; leftover CW20 stays.
A-Z7 Gas grief Failed zap costs gas only (atomic). Dust gate (AC8) prevents repeating dust provides. Do not add unbounded retries.
A-Z8 0 slippage + expert impact 0% still exact; high impact still Expert Mode blocked. Attacker cannot use zap to bypass Swap impact gate.
A-Z9 Pause / blacklist / mapper pause CTA stays blocked; no msgs built.
A-Z10 Foreign / non-factory pair Retail picker factory-only (Z533-6). No zap against indexer-only clones.
A-Z11 Allowance leftover On success, spent allowances match provide. On failure, tx atomic → no leftover allowance. Do not widen to sequential 3-tx (would reintroduce #147 rollback).
A-Z12 Integer overflow / dust min_return 0 still RetailZapFloorError / dust disable. No wrap of unsigned sub in JS (bigint only).

Verification criteria

Close when all of the following are true:

  1. make verify-issue-559 (new script) passes: unit T-Z1–T-Z12, grep that quoteOneSidedAdd provide amounts are floor-trimmed, docs/skill invariant listed, make verify-issue-533 still green.
  2. make test-frontend green for the touched files.
  3. LocalTerra: make verify-issue-539 / sg docker -c 'CI=1 make test-e2e' path including P9 (worse-than-quote fill still adds LP, or swap fails cleanly). Cloud Agent: provision via make setup-cloud-localterra — do not report skip.
  4. Manual / replay of the report shape: UST1/cUSTC-like 6-dec/6-dec pair, Settings 5%, one-sided Add. Must not show Overflow: Cannot Sub with <fill> and <quote> at message index 3.
  5. Pre-sign on /pool Add shows human min-swap; Est. LP is either a readable 18-dec amount or a dust disable.
  6. No pair/router Zap execute landed unless a linked issue records LocalTerra proof that multi-msg floors cannot work.

Severity: production retail LP add on columbus-5. Priority: high.

## Summary Retail one-sided **Add** (automatic zap) on production `dex.cl8y.com` reverts with CosmWasm `Overflow: Cannot Sub` during `provide_liquidity`. The swap leg is allowed to return any amount ≥ `min_return` (default 5% below the quote), but the following provide still `TransferFrom`s the **quoted** ask amount. Any fill in `(min_return, quote)` succeeds the swap and then fails the provide. Reported on **columbus-5**, pair **UST1 / cUSTC** (`terra1ceprjsxp86ggftf5e38wwt34l83e5gq7penkdnv4wsatkwcs8v6qccw55f`). **Evidence (mobile wallet, Pool → Add):** - Amount line: `200 in + min swap 500571` - Quote line: `Zap. Est. LP ~0.000000000007259` - Error: `failed to execute message; message index: 3: Overflow: Cannot Sub with 525495 and 526916: execute wasm contract failed` `500571 ≈ 526916 × 0.95` (Settings 5% slippage). `525495` is ~0.27% below the quote and **above** `min_return`, so this is not a swap slippage reject. Related: [#533](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/533) (one-sided zap), [#342](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/342) (historical `Cannot Sub` on wrap amounts — **different** cause), [#508](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/508) (UST1/cUSTC secondary AMM), [#537](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/537) (I14 discount gating), [#147](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/147) (allowance rollback is for sequential 3-tx provide, not this multi-msg). Do **not** split zap-in provide, zap-out unwrap/swap, and the Add quote/pre-sign display into separate issues. Same multi-msg “next leg sized to quote” class. ## Current codebase [#533](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/533) shipped retail `/pool` one-sided zap as **frontend orchestration** on existing messages (Z533-10: no pair/router `Zap` execute). | Step | What the code does today | |------|--------------------------| | **Quote** | `quoteOneSidedAdd` runs local `zapInSplit` on LCD `getPool` (React Query `staleTime: 15_000`). It does **not** call pair `simulateHybridSwap`. `swapMinReturn = applySlippagePercentFloor(swapOut, slippage%)`. `provideOffer` / `provideAsk` are `split.provideIn` / `split.provideOut` from the **quoted** net, not the floor. | | **Fee** | Split uses `effectivePoolFeeBps(fee_bps, advertisedDiscountBps)`. I14 gates discount on pair `DISCOUNT_REGISTRY` (#537). Local math can still disagree with execute (stale reserves, rounding, hook skim, mempool). | | **Tx (no wrap, pair-leg — this report)** | `buildZapInMessages`: `[0] CW20 send + swap (min_return)` → `[1] increase_allowance offer` → `[2] increase_allowance ask` → **`[3] provide_liquidity`**. Matches **message index: 3**. | | **Provide amounts** | `provide_liquidity.assets[].amount` = quoted `provideAsk`. Pair then CW20 `TransferFrom` that exact amount (`pair/src/contract.rs` `execute_provide_liquidity`). CW20 `checked_sub` on a balance of `525495` vs amount `526916` is the observed `Overflow: Cannot Sub with 525495 and 526916`. | | **Slippage** | Swap `min_return` is the 5% floor. Provide `slippage_tolerance` is set (Z533-7). That does **not** shrink the TransferFrom amounts. | | **Preflight** | Retail execute uses static gas envelopes ([#475](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/475) / `terraGasRetailInventory.ts`). **No LCD simulate-before-broadcast.** A structurally failing zap is still signed and pays gas. Cosmos multi-msg is atomic (swap+allowances revert with provide); user loses **gas only**, not a half-zap. | | **Zap-out (same class)** | `OneSidedWithdrawCard` sets `swapAmount` to quoted `split.swapIn` and `unwrap.amount` to quoted `split.totalWantedCw20` (wanted + quoted swap out), while the swap leg only guarantees `swapMinReturn` and withdraw only guarantees `min_assets`. A worse-than-quote fill can `Cannot Sub` on the swap send or unwrap send. | | **Tests** | `oneSidedLiquidityTx.test.ts` A18 fixture already uses `provideAsk === swapMinReturn`. `quoteOneSidedAdd` never asserts `provideAsk <= swapMinReturn`. No unit covers “fill in `(min_return, quote)` must still provide”. | | **Display** | Pre-sign shows human `${amount} in` next to **raw** `min swap ${swapMinReturn}` (`500571` instead of `0.500571` at 6 decimals). Est. LP uses LP CW20 **18** decimals (Z533-10), so a small mint looks like `~0.000000000007259`. | Pair mint/burn math is fine. This is not a wrap-tax #342 miss: the UI showed `Zap.` not `Wrap + zap.`, and the failing msg is provide. ## Why this is needed 1. **Retail Add is broken on mainnet for the advertised flow.** Users can pass the quote, sign, pay gas, and still revert. Support screenshot is UST1/cUSTC, but any factory pair zap-in has the same bug. 2. **`min_return` is lying.** Settings 5% is applied to the swap, then ignored when sizing the provide. The only fills that work are those ≥ the optimistic quote (a ~0% band), not the 5% the UI sold. 3. **Silent extra-balance spend if the tx ever did not revert.** If the wallet already holds the ask token, `TransferFrom(quoted)` can succeed by dipping **pre-existing** ask balance to cover a shortfall. That violates Z533-4 (no silent donate / no unsolicited other-side spend). Atomic revert currently hides this; a future “retry provide” or split-tx would expose it. Size to floors so it cannot happen. 4. **Zap-out will fail the same way** on unwrap/swap once users hit a worse-than-quote fill (especially native unwrap of quoted `totalWantedCw20`). 5. **Quote chrome is unreadable** (`min swap 500571` raw; dust-looking 18-dec LP) so users cannot tell a thin/skewed pool from a solver bug. ## Constraints / guardrails 1. **Keep frontend multi-msg orchestration.** Do not add a pair/router `Zap` execute unless LocalTerra rehearsal proves conservative floors still cannot work (Z533-10). Default fix is quote/tx sizing, not a contract upgrade. 2. **Do not change** pair mint/burn, fee treasury, wrap-mapper bps, or factory whitelist. 3. **Z533-4:** provide must stay on-ratio to **post-swap** reserves. Leftover stays in the wallet. Do not donate excess to make TransferFrom succeed. 4. **Z533-7:** every leg keeps a floor (`min_return` / `slippage_tolerance` / `min_assets`). Do not set `slippage_tolerance: null`. Default 5% from Settings (#497). 5. **Do not spend pre-existing ask (or wanted) wallet balances** to paper over a worse swap. Provide/unwrap amounts must be satisfiable from **this tx’s** swap/withdraw floors alone. 6. **I14 / #537:** zap fee math stays behind `useFeeDiscountRegistryStatus(pairAddr)`. Unwired pair → `discountBps = 0`. Do not apply a wallet `get_discount` to the split when the pair registry is unset. 7. **Pool-only zap swap** (`poolOnlyHybridParams`, A13). No book leg. 8. **Same quote snapshot on submit** (#356 / Z533-6). Stale pay-raw still disables CTA. 9. **Unwrap only the zap-out amount** (Z533-8) — and that amount must be the **floor** chain, not the optimistic quote. 10. **#489 copy.** Do not add architecture essays. Blocking errors stay one sentence. Pre-sign amounts must be **human** units, not raw uints. 11. **Keep** pause, blacklist, wrap-mapper pause / treasury match, IL, clickwrap, NFA, Expert Mode impact gate, gas/Max envelopes, pre-sign (#462). 12. **Empty pool** stays disabled (Z533-5). Advanced two-sided unchanged. 13. **Do not introduce LCD simulate-before-broadcast as the sole fix** (conflicts with #475 static gas). Optional simulate is belt-and-suspenders only; floors must make the tx valid even if the pool moves inside the slippage window after quote. 14. **LocalTerra** for on-chain paths. Do not skip with a false “no LocalTerra”. ## Relevant files | File | Role | |------|------| | [`frontend-dapp/src/utils/oneSidedLiquidityQuote.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/oneSidedLiquidityQuote.ts) | Sets `provideAsk` from quoted `split.provideOut`; `swapMinReturn` unused for provide | | [`frontend-dapp/src/utils/oneSidedLiquidity.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/oneSidedLiquidity.ts) | `zapInSplit` / `trimProvideToRatio` / `zapOutSplit` | | [`frontend-dapp/src/utils/oneSidedLiquidityTx.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/oneSidedLiquidityTx.ts) | Multi-msg order; provide/unwrap amounts passed through | | [`frontend-dapp/src/components/pool/OneSidedAddCard.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/pool/OneSidedAddCard.tsx) | Submit snapshot; pre-sign `min swap ${swapMinReturn}` raw; Est. LP 18-dec | | [`frontend-dapp/src/components/pool/OneSidedWithdrawCard.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/pool/OneSidedWithdrawCard.tsx) | Zap-out `swapAmount` + unwrap of quoted `totalWantedCw20` | | [`frontend-dapp/src/utils/provideLiquidityEstimate.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/provideLiquidityEstimate.ts) | LP mint estimate (post-swap reserves) | | [`frontend-dapp/src/utils/rawAmountMath.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/rawAmountMath.ts) | `applySlippagePercentFloor` | | [`frontend-dapp/src/hooks/useFeeDiscountRegistryStatus.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/hooks/useFeeDiscountRegistryStatus.ts) | I14 `discountBps` | | [`smartcontracts/contracts/pair/src/contract.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/smartcontracts/contracts/pair/src/contract.rs) | `execute_provide_liquidity` TransferFrom (do not change mint math) | | [`smartcontracts/contracts/pair/src/hybrid_reverse.rs`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/smartcontracts/contracts/pair/src/hybrid_reverse.rs) | `pool_net_output_for_input` (solver should keep matching) | | [`frontend-dapp/src/utils/__tests__/oneSidedLiquidityQuote.test.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/__tests__/oneSidedLiquidityQuote.test.ts) | Missing floor-sized provide coverage | | [`frontend-dapp/src/utils/__tests__/oneSidedLiquidityTx.test.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/__tests__/oneSidedLiquidityTx.test.ts) | A18 order; fixture already `provideAsk === min_return` | | [`frontend-dapp/e2e/pool-one-sided-533-tx.spec.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/e2e/pool-one-sided-533-tx.spec.ts) | LocalTerra P4–P8 | | [`docs/frontend.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/frontend.md) § One-sided pool | Z533 invariants to extend | | [`skills/AGENTS_FRONTEND_POOL_ONE_SIDED.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/skills/AGENTS_FRONTEND_POOL_ONE_SIDED.md) | Agent playbook | | [`deployments/mainnet-ust1-wrap/REGISTRY.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/deployments/mainnet-ust1-wrap/REGISTRY.md) | Mainnet UST1/cUSTC pair addr | ## Recommended direction **Size every subsequent multi-msg leg to the previous leg’s floor, then trim to the conservative post-swap ratio.** ### Zap-in 1. Keep solving `zapInSplit` on current pool + effective fee (quoted split for Est. LP / impact). 2. Build **execution** amounts from the worst fill the swap is allowed to produce: - `swapMinReturn` stays `floor(swapOut × (1 − slippage%))`. - `provideAsk = min(split.provideOut, swapMinReturn)` (typically the floor). - Re-run `trimProvideToRatio` with `swapOut = provideAsk` and post-swap reserves consistent with that worse output (offer reserve still `+ swapIn`; ask reserve higher if less ask left the pool). `provideOffer` shrinks; extra offer stays in the wallet. 3. Allowances must match those conservative provide amounts (not the quote). 4. Optional hardening: LCD `simulateHybridSwap` (pool-only) to refresh `swapOut` before flooring — **not** a substitute for (2). 5. Do not require or spend ask-token balance the user held before the tx. ### Zap-out 1. `swapAmount` ≤ withdraw `min_assets` of the sold leg (not the 0% pro-rata quote). 2. Unwrap send ≤ `min(withdrawn wanted, min_assets[wanted]) + swapMinReturn` (never quoted `totalWantedCw20` if that exceeds the floor chain). 3. Leftover of the sold or wanted token stays in the wallet. ### Quote / pre-sign UX (same cards) 1. Pre-sign `min swap` must be **human** (`formatTokenAmount(swapMinReturn, askDecimals)`), not raw. 2. Est. LP stays 18-dec, but if conservative mint is dust / zero, disable with `Amount too small` (existing dust copy) rather than submitting a tx that can only mint `~1e-14` LP. 3. Keep `Zap.` / `Wrap + zap.` / `Route + zap.` prefixes. ### Out of scope - New `Zap` execute on pair/router. - Changing `provide_liquidity` to take max assets and refund (would be a contract change and donation foot-gun). - LCD simulate-before-broadcast for all retail txs (#475). ## Acceptance criteria - [ ] **AC1.** Zap-in `provideAsk` ≤ `swapMinReturn` and is satisfiable if the swap returns exactly `min_return` and the user had **zero** pre-existing ask balance. - [ ] **AC2.** Zap-in `provideOffer` is ratio-trimmed to the conservative post-swap reserves (Z533-4). Leftover offer/ask stays in the wallet; no donate. - [ ] **AC3.** A swap fill in `(min_return, quote)` (e.g. quote `526916`, fill `525495`, min `500571`) must **not** produce `Overflow: Cannot Sub` on provide. Tx either succeeds or fails at swap `min_return` / provide `slippage_tolerance` with those errors — never CW20 underflow. - [ ] **AC4.** Zap-out unwrap amount and other-side `swapAmount` are sized to withdraw/swap **floors**, not optimistic quotes. Same `Cannot Sub` ban. - [ ] **AC5.** Wrap + zap and route-in + zap use the same conservative provide sizing after the last hop’s `min_return`. - [ ] **AC6.** Unwired fee-discount pair (`discountBps = 0`) still floors provide; a wallet with a large `get_discount` must not inflate `provideAsk` above on-chain output. - [ ] **AC7.** Pre-sign shows human min-swap (and human Est. LP). Raw uints like `500571` must not appear next to a human `200 in`. - [ ] **AC8.** Dust conservative LP → one-sentence disable (`Amount too small`); CTA blocked. Empty pool still `Empty pool. Use Advanced.` - [ ] **AC9.** A18 message order unchanged: wrap? → route? → swap → allowances ×2 → provide. `slippage_tolerance` set. Pool-only hybrid (`book_input = 0`). - [ ] **AC10.** Expert Mode / pause / blacklist / wrap treasury mismatch / gas Max gates unchanged. - [ ] **AC11.** Docs + skill: new invariant that **execution amounts follow floors, quotes may be optimistic**. `make verify-issue-533` still passes; this issue gets `make verify-issue-559`. - [ ] **AC12.** LocalTerra tx: one-sided add on a seeded pair succeeds when an on-chain fill is worse than the client quote but ≥ `min_return` (fee mismatch or reserve nudge). UST1/cUSTC-shaped 6-dec/6-dec pair included. ## Test plan (all paths) Use invariant ids **T-Z1–T-Z14** in unit tests. | ID | Path | Expect | |----|------|--------| | **T-Z1** | Pair-leg zap-in, 5% slippage, 30 bps fee | `provideAsk <= swapMinReturn`; `provideOffer + leftoverIn = amountIn - swapIn`; ratio trim vs conservative post-swap reserves | | **T-Z2** | Same as T-Z1 with fill `quote - 1` but `>= min_return` (fixture balances) | Built msgs: ask `TransferFrom` amount ≤ fill; no `Cannot Sub` | | **T-Z3** | 0% slippage | `provideAsk == swapOut` (floors collapse to quote); still ratio-trimmed | | **T-Z4** | Wrap + zap (W8 wrap fee only) | Solver input is post-`fee_wrap_bps`; provide floors after wrap net; no burn tax on wrap | | **T-Z5** | Route-in + zap | Route `minimum_receive` floors the zap `amountIn`; zap provide uses zap `min_return` | | **T-Z6** | Zap from asset1 (not asset0) | Provide asset infos still match offer/ask contracts; LP estimate uses matching reserve amounts | | **T-Z7** | Skewed pool (UST1-heavy / dust ask) | Conservative LP dust → unavailable; no broadcast | | **T-Z8** | Empty / one-sided reserves | Unchanged `Empty pool. Use Advanced.` | | **T-Z9** | Fee 30 bps quote vs 0 bps quote (discount) | Execution provideAsk from the **on-chain** effective fee (unwired → 0 discount). Inflated 0-fee quote must not raise provideAsk | | **T-Z10** | Zap-out to pair CW20 | `swapAmount <= min_assets[sold]`; leftover sold token OK | | **T-Z11** | Zap-out to native (unwrap) | Unwrap send ≤ floor(wanted withdrawn) + `swapMinReturn` | | **T-Z12** | Pre-sign copy | Human min-swap; 6-dec token does not show raw `500571` | | **T-Z13** | Advanced two-sided | Unchanged; no zap builder | | **T-Z14** | Playwright smoke P1–P3 + LocalTerra P4–P8 | Existing #533 paths still pass; add P9: add succeeds after a worse-than-quote pool-only fill (or documented LCD sim of conservative msgs) | Unit files: `oneSidedLiquidity.test.ts`, `oneSidedLiquidityQuote.test.ts`, `oneSidedLiquidityTx.test.ts`, OneSidedAddCard / copy if needed. Playwright: 5 workers for UI smoke; 1 worker `e2e-tx` for chain (`pool-one-sided-533-tx.spec.ts` + new case). `PLAYWRIGHT_SKIP_CHAIN=1` must not be used to skip P9. ## Test plan (attack, hack, abuse) | ID | Vector | Expect | |----|--------|--------| | **A-Z1** | **Sandwich / reserve nudge** inside 5% of quote | Swap either fills ≥ `min_return` and provide succeeds at conservative amounts, or swap reverts `min_return` / `max_spread`. Never `Cannot Sub` on provide. | | **A-Z2** | **Pre-existing ask balance** covering quote − fill | Provide must **not** pull that extra balance. Post-tx ask increase ≈ fill − provideAsk (leftover), not `− (quote − fill)` from old inventory. | | **A-Z3** | **Fee-discount mismatch** (UI 0 bps, chain 30 bps, or reverse) | Unwired pair cannot advertise discount (I14). Provide sized as if full `fee_bps` when registry unset. No inflated TransferFrom. | | **A-Z4** | **Stale 15s pool cache** | Snapshot/pay-raw guard remains; conservative floors still cover typical 15s drift inside slippage. | | **A-Z5** | **Donation via oversize provideOffer** | `trimProvideToRatio` + slippage_tolerance; excess offer not transferred. | | **A-Z6** | **Zap-out unwrap oversize** (quoted wanted+swapOut > actual) | Must not unwrap other wallet cLUNC/cUSTC (Z533-8 + this floor). `Cannot Sub` banned; leftover CW20 stays. | | **A-Z7** | **Gas grief** | Failed zap costs gas only (atomic). Dust gate (AC8) prevents repeating dust provides. Do not add unbounded retries. | | **A-Z8** | **0 slippage + expert impact** | 0% still exact; high impact still Expert Mode blocked. Attacker cannot use zap to bypass Swap impact gate. | | **A-Z9** | **Pause / blacklist / mapper pause** | CTA stays blocked; no msgs built. | | **A-Z10** | **Foreign / non-factory pair** | Retail picker factory-only (Z533-6). No zap against indexer-only clones. | | **A-Z11** | **Allowance leftover** | On success, spent allowances match provide. On failure, tx atomic → no leftover allowance. Do not widen to sequential 3-tx (would reintroduce #147 rollback). | | **A-Z12** | **Integer overflow / dust** | `min_return` 0 still `RetailZapFloorError` / dust disable. No wrap of unsigned sub in JS (`bigint` only). | ## Verification criteria Close when all of the following are true: 1. `make verify-issue-559` (new script) passes: unit T-Z1–T-Z12, grep that `quoteOneSidedAdd` provide amounts are floor-trimmed, docs/skill invariant listed, `make verify-issue-533` still green. 2. `make test-frontend` green for the touched files. 3. LocalTerra: `make verify-issue-539` / `sg docker -c 'CI=1 make test-e2e'` path including **P9** (worse-than-quote fill still adds LP, or swap fails cleanly). Cloud Agent: provision via `make setup-cloud-localterra` — do not report skip. 4. Manual / replay of the report shape: UST1/cUSTC-like 6-dec/6-dec pair, Settings 5%, one-sided Add. Must not show `Overflow: Cannot Sub with <fill> and <quote>` at message index 3. 5. Pre-sign on `/pool` Add shows human min-swap; Est. LP is either a readable 18-dec amount or a dust disable. 6. No pair/router `Zap` execute landed unless a linked issue records LocalTerra proof that multi-msg floors cannot work. **Severity:** production retail LP add on columbus-5. **Priority:** high.
PlasticDigits commented 2026-08-18 00:43:30 +00:00 (Migrated from gitlab.com)

marked as related to #533

marked as related to #533
PlasticDigits commented 2026-08-18 00:43:45 +00:00 (Migrated from gitlab.com)

changed the description

changed the description
PlasticDigits commented 2026-08-18 00:44:25 +00:00 (Migrated from gitlab.com)

Follow-up from code review of the same report: quote–execute coupling is confirmed. Additional related issues to keep in scope as hardening, not a second ticket:

  • #356 — Swap has useSubmitAlignedSimQuote; one-sided Add only guards snapshot.payRaw !== rawAmount. Optional LCD hybrid_simulation / simulateSwap(..., { trader }) on the zap swap leg belongs under AC5 / recommended direction item 4, and must not replace floor-sized provideAsk (AC1–AC3).
  • #471 — wallet hybrid_simulation vs indexer drift; same class.
  • #245 — trader on hybrid_simulation so fee-discount matches execute.

Zap-out floors (AC4), human min-swap (AC7), and dust LP disable (AC8) stay in this issue. Do not split a P1 “quote hardening” work item unless floors-only still fails LocalTerra P9.

Follow-up from code review of the same report: quote–execute coupling is confirmed. Additional related issues to keep in scope as **hardening**, not a second ticket: - [#356](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/356) — Swap has `useSubmitAlignedSimQuote`; one-sided Add only guards `snapshot.payRaw !== rawAmount`. Optional LCD `hybrid_simulation` / `simulateSwap(..., { trader })` on the zap swap leg belongs under AC5 / recommended direction item 4, and must not replace floor-sized `provideAsk` (AC1–AC3). - [#471](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/471) — wallet `hybrid_simulation` vs indexer drift; same class. - [#245](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/245) — `trader` on `hybrid_simulation` so fee-discount matches execute. Zap-out floors (AC4), human min-swap (AC7), and dust LP disable (AC8) stay in this issue. Do not split a P1 “quote hardening” work item unless floors-only still fails LocalTerra P9.
PlasticDigits commented 2026-08-18 02:37:51 +00:00 (Migrated from gitlab.com)

mentioned in commit cb834f3069

mentioned in commit cb834f306929fd204dd0789191aba9fbdca7053a
PlasticDigits commented 2026-08-18 02:38:12 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1084

mentioned in merge request !1084
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-18 03:09:50 +00:00
PlasticDigits commented 2026-08-18 03:09:52 +00:00 (Migrated from gitlab.com)

mentioned in commit da133bd380

mentioned in commit da133bd38010537fb71eb98536da7d0283f25a9c
PlasticDigits commented 2026-08-18 03:11:26 +00:00 (Migrated from gitlab.com)

Merged in !1084 (Makefile conflict only vs !1080–!1083).

Shipped vs #559 AC: zap-in provideAsk follows swapMinReturn then conservative reserve trim; zap-out uses min_assets + swapMinReturn; leftover stays in wallet; pre-sign min-swap is human units; Z559-1–Z559-4 + make verify-issue-559.

Still open: production manual on UST1/cUSTC (Settings 5%) must not show Cannot Sub at message index 3. Coolify/CI make test-frontend was not waited (quota-exceeded). Local frontend unit tests for zap + portfolio/volume/hub helpers passed after the merge stack.

Tracked in the post-merge follow-up after !1080–!1084.

Merged in !1084 (Makefile conflict only vs !1080–!1083). **Shipped vs #559 AC:** zap-in `provideAsk` follows `swapMinReturn` then conservative reserve trim; zap-out uses `min_assets` + `swapMinReturn`; leftover stays in wallet; pre-sign min-swap is human units; Z559-1–Z559-4 + `make verify-issue-559`. **Still open:** production manual on UST1/cUSTC (Settings 5%) must not show `Cannot Sub` at message index 3. Coolify/CI `make test-frontend` was not waited (quota-exceeded). Local frontend unit tests for zap + portfolio/volume/hub helpers passed after the merge stack. Tracked in the post-merge follow-up after !1080–!1084.
PlasticDigits commented 2026-08-18 03:12:09 +00:00 (Migrated from gitlab.com)

mentioned in issue #560

mentioned in issue #560
PlasticDigits commented 2026-08-18 03:12:10 +00:00 (Migrated from gitlab.com)

marked as related to #560

marked as related to #560
PlasticDigits commented 2026-08-18 03:12:55 +00:00 (Migrated from gitlab.com)

Post-merge follow-up: #560

Post-merge follow-up: #560
PlasticDigits commented 2026-08-18 03:14:37 +00:00 (Migrated from gitlab.com)

Post-merge review extra: LocalTerra P9 names “fill can be below quote” but only asserts human min-swap copy + a happy-path add. Worse-than-quote is covered by unit fixtures (525495/526916/500571), not an on-chain reserve nudge. Optional follow-up: e2e that forces fill ∈ (min_return, quote). Production UST1/cUSTC manual QA remains the AC that matters; tracked on #560.

Post-merge review extra: LocalTerra **P9** names “fill can be below quote” but only asserts human min-swap copy + a happy-path add. Worse-than-quote is covered by unit fixtures (`525495/526916/500571`), not an on-chain reserve nudge. Optional follow-up: e2e that forces fill ∈ `(min_return, quote)`. Production UST1/cUSTC manual QA remains the AC that matters; tracked on #560.
PlasticDigits commented 2026-08-19 01:06:07 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1090

mentioned in merge request !1090
PlasticDigits commented 2026-08-24 05:54:15 +00:00 (Migrated from gitlab.com)

mentioned in issue #622

mentioned in issue #622
PlasticDigits commented 2026-08-24 06:55:15 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1135

mentioned in merge request !1135
PlasticDigits commented 2026-08-26 04:11:19 +00:00 (Migrated from gitlab.com)

mentioned in issue #660

mentioned in issue #660
PlasticDigits commented 2026-08-26 04:11:22 +00:00 (Migrated from gitlab.com)

marked as related to #660

marked as related to #660
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#559
No description provided.