bug: USTR→USTC unwrap+2hop out of gas after #587 wrap fix #599

Closed
opened 2026-08-23 03:05:40 +00:00 by PlasticDigits · 18 comments
PlasticDigits commented 2026-08-23 03:05:40 +00:00 (Migrated from gitlab.com)

Summary

Retail report (2026-08-23, columbus-5, dex.cl8y.com /): USTR → USTC Swap quotes and signs, then fails with the humanized out-of-gas toast (“Transaction needed more gas than estimated. Try again — gas usage can vary slightly between blocks.”). Retrying does not help. The same bake succeeds for LUNC → USTR and USTC → USTR.

Screenshot chrome confirms the native unwrap path: Wrap limit 1.1M / 1.1M USTC available, Worst hop spread ≈ 0.00%, SWAP CTA, MAINNET / columbus-5. Quote succeeded; execute OOGed.

Do not reopen #587. That ticket shipped wrap+≥2hop combo overhead (WRAP_ROUTER_COMBO_OVERHEAD_GAS 400k) and Network-fee chrome. This is the remaining CW20 → native uusd hole: same 2-hop hub graph, unwrap_output, no wrap_deposit, and InstantWithdraw of taxed USTC. Bundle measurement, envelope raise, inventory/E2E, and docs into one issue.

Related: #587 (closed: wrap+≥2hop LUNC↔USTR), #343 (unwrap_output add-on), #353 (router single-hop floor), #475 (retail gas inventory), #590 (E7/E8 Playwright still chain-gated), #596 / H596-7 (native path is not Pattern C hybrid).

Current codebase

USTR is CW20 terra1vy3kc0…fsetxv. Native USTC is uusd; wrap CW20 is cUSTC terra1nap4dx…cr56ch. There is no factory pair USTR/cUSTC. Columbus-5 hub graph (indexer GET /api/v1/pairs, 2026-08-23):

Pair Legs
terra1cepr…w55f UST1 / cUSTC
terra16vxr…5hgqy UST1 / USTR
terra1su53…mm7h4 cLUNC / UST1

Client BFS therefore routes USTR → USTC as USTR → UST1 → cUSTC (2 hops) + router unwrap_output: true.

Layer Behavior today
Quote SwapPage nativeRouteInfo branch calls simulateNativeSwap (wallet simulate_swap_operations + unwrap fee/tax net). Does not call indexer GET /route/solve. Hops are pool-only.
Submit Same page: isWrapOrUnwrap || nativeRouteInfo → executeNativeSwap. Comment on the hook: “Pool-only hops from client BFS findRoute — never copy hybrid / book_input (#587).” Single CW20 send → router execute_swap_operations with unwrap_output: true. No wrap_deposit.
Gas wanted getGasLimitForTx on that send: gasLimitForRouterExecuteSwapOperations(2) 1,910,000 + UNWRAP_GAS_LIMIT 800k = 2,710,000 (~76.76 LUNC at 28.325).
#587 combo WRAP_ROUTER_COMBO_OVERHEAD_GAS (400k) applies only when a wrap_deposit and router send (N≥2) share one multi-msg tx. USTR→USTC has no wrap msg, so combo overhead is 0. Wrap+2hop+unwrap is documented at 3,510,000; unwrap-only 2-hop stays 2,710,000.
UNWRAP calibration 800k was pinned from columbus-5 LCD sim of cLUNC → wrap-mapper { unwrap } (~562k used; failed tx wanted 550k). That is not router unwrap_output after two hub hops, and not treasury InstantWithdraw of uusd (Classic burn tax / extra bank sends).
Inventory / E2E gap Fixture send_2hop_unwrap is labeled USTR→LUNC (uluna unwrap). Playwright wrap-swap.spec.ts E8 is CW20 → LUNC, not → USTC. No combined envelope named for hub USTR→USTC.
Why LUNC→USTR / USTC→USTR work Those are wrap + hops (no InstantWithdraw). #587 raised that envelope. Reverse USTR→LUNC is uluna unwrap (E8 stand-in; chain AC still open on #590). Reverse USTR→USTC is the taxed-uusd unwrap and was never measured.
Best-price / #596 (not the execute path) Indexer GET /route/solve?token_in=USTR&token_out=cUSTC (2026-08-23) returns global_v4, 2 hops, 100% book_input on both USTR/UST1 and UST1/cUSTC (max_maker_fills: 8). Retail Swap does not submit those ops when the receive token is native uusd (H596-7). The OOG toast is gasUsed > gasWanted on the pool-only + unwrap envelope, not a hybrid book walk. Do not “fix” this by copying solver hybrid onto executeNativeSwap.
Error copy humanizeTerraTxError maps /out of gas/i to the screenshot string. Retrying the same 2.71M limit cannot succeed (#353 / #587).
Wrap-limit chrome WrapRateLimitStatus on native uusd is expected (#502 / #507). Fully available 1.1M/1.1M is not a rate-limit block; unwrap does not consume the wrap window.

Why this is needed

USTR → native USTC is a primary off-ramp (CW20 hub → taxed uusd). After #587, wrap-direction hub swaps work; this reverse still burns the failed-tx fee and leaves traders stuck. “Try again” is false — the constant is too low. #587’s unwrap fixture and E8 cover LUNC, so USTC InstantWithdraw + 2 hub hops never had a measured ceiling.

Constraints / guardrails

  1. Keep executeNativeSwap (BFS + unwrap_output). Do not tell users to hop USTR→UST1→cUSTC→unwrap by hand.
  2. Native path stays pool-only. Do not attach solver hybrid / book_input (would jump toward 15M per hop, #249 / #260 / H596-7). Indexer GET /route/solve for native uusd stays out of scope.
  3. Fee denom stays uluna. Do not add USTC / multi-denom fees.
  4. Do not LCD-sim as the sole envelope (AGENTS_TERRACLASSIC_GAS). If LCD sim is used for tuning, new constants must still exceed measured gasUsed with wasm/storage headroom.
  5. If a failed columbus-5 gasUsed is known, new defaults must exceed it with margin (Classic variance is not tight). Same rule as #353 / #587.
  6. Do not raise UNWRAP_GAS_LIMIT blindly for every unwrap if the miss is router+unwrap combo (same-msg overhead after N≥2 hops, analog of WRAP_ROUTER_COMBO_OVERHEAD_GAS). Prefer a named combo / router-unwrap add-on so direct cUSTC→USTC unwrap (800k) does not pay hub-multihop tax.
  7. Keep hop math in lockstep with packages/localnet-trading-swarm/src/gas.ts.
  8. Retail inventory (#475). New fixture(s) in RETAIL_COMBINED_ENVELOPE_FIXTURES for USTR→USTC (2hop + unwrap_output, no wrap). Do not fall through to SWAP_GAS_LIMIT 600k (SendHookGasDecodeError).
  9. Max / Network fee (#213 / #587). estimateNativeSwapUlunaFeesTotal / swap-network-fee / submit uluna gate must use the same envelope as broadcast (including the new unwrap combo).
  10. Wrap economics unchanged. minimum_receive on unwrap = post-fee pre-tax (R3). Display You Receive stays post–burn-tax (#512). Do not retune wrap-mapper bps here.
  11. Copy (#489). Do not add an essay. Keep Network fee (est.) ~X LUNC. Do not change the OOG toast to “try again” with a higher limit unless the limit actually rose.
  12. Do not enable mainnet extension fee-guard (#429 / SEC-E08). Diagnose wallet undershoot separately.
  13. Gem-bridge (#562). Production must not route USTR→USTC through RUBY/EMBER.
  14. Out of scope. Reopening #587. Hybrid-on-native. Indexer route/solve for uusd. Station-on-LocalTerra (#235). Changing wrap-limit chrome (informational only).

Relevant files

Path Role
frontend-dapp/src/utils/constants.ts UNWRAP_GAS_LIMIT, WRAP_ROUTER_COMBO_OVERHEAD_GAS, ROUTER_*
frontend-dapp/src/services/terraclassic/terraGas.ts gasLimitForSwapOperationsMsg (+unwrap), wrapRouterComboOverheadGas, totalGasLimitForExecuteMsgs
frontend-dapp/src/services/terraclassic/transactions.ts nativeSwapFeeExecuteMsgs / estimateNativeSwapUlunaFeesTotal
frontend-dapp/src/services/terraclassic/router.ts findRouteWithNativeSupport, executeNativeSwap, simulateNativeSwap
frontend-dapp/src/pages/SwapPage.tsx Native quote/submit; network-fee hints
frontend-dapp/src/services/terraclassic/terraGasRetailInventory.ts Add USTR→USTC combined fixture
frontend-dapp/src/services/terraclassic/__tests__/terraGas.retailShapes.test.ts Envelope assertions
frontend-dapp/src/services/terraclassic/__tests__/transactions.test.ts Native fee totals
frontend-dapp/src/services/terraclassic/__tests__/swapNetworkFee.test.ts Network-fee row
frontend-dapp/e2e/wrap-swap.spec.ts Add USTR→USTC (or LocalTerra 2hop+uusd unwrap stand-in)
packages/localnet-trading-swarm/src/gas.ts Lockstep
docs/frontend.md Gas table: unwrap+N-hop / USTC InstantWithdraw
skills/AGENTS_TERRACLASSIC_GAS.md Playbook row
AGENTS.md make verify-issue-<iid>
  1. Measure first. Prefer a failed columbus-5 hash (gasWanted / gasUsed) from the reporter. Else LCD-sim and/or LocalTerra combined single-msg: 2-hop router + unwrap_output into uusd (not wrap-alone + unwrap-alone). Also compare USTR→LUNC (uluna unwrap, same 2.71M) so we know if the miss is USTC-tax-specific or all unwrap+2hop.
  2. Fix the envelope, not the product shape. Keep BFS + unwrap_output. Raise whichever is short:
    • router+unwrap combo on N≥2 (analog of wrap combo; preferred if 1-hop unwrap and direct unwrap stay green), and/or
    • a router-unwrap_output add-on above mapper-only UNWRAP_GAS_LIMIT, and/or
    • hop floors only if hub pair gas_used alone exceeds 1.91M.
  3. Do not default to 15M hybrid. Target stays tens-of-LUNC at 28.325 (same class as #587 ~77–100 LUNC), not hundreds.
  4. Inventory + E2E. Named fixture send_2hop_unwrap_ustc. Playwright: USTR→USTC one submit; assert no OOG toast; gas_used < gas_wanted. Keep E7/E8 LUNC paths.
  5. Docs / verify. docs/frontend.md + AGENTS_TERRACLASSIC_GAS.md + make verify-issue-<iid>.

Acceptance criteria

  • AC1. Swap USTR → USTC on columbus-5 (and LocalTerra stand-in) succeeds in one signed tx. No OOG toast.
  • AC2. Broadcast gasWanted exceeds measured gasUsed with documented margin. Combined unwrap+2hop envelope is in inventory and matches totalGasLimitForExecuteMsgs.
  • AC3. LUNC → USTR and USTC → USTR stay successful (no regression of #587 wrap combo). Wrap+1hop stays 1.8M.
  • AC4. Direct cUSTC → USTC unwrap still uses mapper UNWRAP_GAS_LIMIT (800k) unless measurement proves it must rise; do not silently charge hub-multihop combo on a 1:1 unwrap.
  • AC5. USTR → LUNC still one tx; if it also OOGs at 2.71M, the same combo must cover uluna unwrap (document both).
  • AC6. executeNativeSwap hops remain pool-only (no hybrid / book_input from indexer or query string).
  • AC7. Network fee row still ~X LUNC; Max / submit uluna gate use the new envelope.
  • AC8. Malformed send.msg still throws SendHookGasDecodeError.
  • AC9. Production does not route this pair through soft-launch gems (#562).
  • AC10. make verify-issue-<iid> + docs/skill row. Playwright USTR→USTC (or documented stand-in) in wrap-swap.spec.ts.

Test plan (all paths)

Path Expect
USTR → USTC (2hop + unwrap uusd) One tx success; envelope ≥ measured used; Network fee LUNC
USTR → LUNC (2hop + unwrap uluna) Still success; if envelope changes, fee row tracks it
USTC → USTR / LUNC → USTR Wrap+2hop unchanged success (#587)
cUSTC → USTC / cLUNC → LUNC Direct unwrap; 800k unless retuned with evidence
LUNC → cLUNC / USTC → cUSTC Direct wrap; 400k
LUNC → EMBER (wrap+1hop) Stays 1.8M; no combo
CW20 ↔ CW20 (USTR → UST1) Still Pattern C / route-solve; not this envelope
Native Max on LUNC pay with unwrap-out Reserve uses new unwrap+N envelope when hints say so
Submit uluna gate Bank LUNC ≥ fee (CW20 pay) before sign
Quote simulateNativeSwap still pool-only; receive is post-unwrap-fee post-tax
Wrap-limit banner May still show USTC window; must not block unwrap when remaining = max
Indexer GET USTR→cUSTC May still return book-heavy hybrid; dApp must not submit it on native USTC

LocalTerra: prefer hub USTR/UST1/cUSTC if seeded; else shortest ≥2hop CW20 → native uusd unwrap and document the stand-in. Cloud Agent: make setup-cloud-localterra (not Postgres-only).

Test plan (attack, hack, abuse)

  1. Fee grief via inflated gas — do not default unwrap+multihop to 15M hybrid. Assert USTR→USTC fee stays in the tens-of-LUNC class at 28.325.
  2. Hybrid injection — executeNativeSwap must not accept attacker hybrid / book_input from query string, indexer router_operations, or typed Advanced book when toToken is uusd. Ops from client BFS only.
  3. Hop-count spoof — UI hopCount for Max/fee must equal submitted operations.length. A crafted shorter hint must not produce a smaller Fee.gas than execute.
  4. send.msg tamper / decode fail — refuse undersized 600k fallback (SendHookGasDecodeError).
  5. Wallet undershoot — mainnet extension may still cut gas_wanted (#429). Do not treat that as “raise to 15M.”
  6. Insufficient LUNC after quote — submit must not sign when bank uluna < Fee.amount. Prefer blocked CTA over OOG after sign.
  7. Unwrap vs wrap-limit — InstantWithdraw must not be gated on wrap-window remaining. Exhausted wrap window must not look like a successful unwrap path then fail mid-tx.
  8. Slippage / sandwich — raising gas must not disable max_spread / minimum_receive (R3 post-fee pre-tax). OOG fix is not a license to widen default 5% slippage.
  9. Gem-bridge — production rejects gem hops on economic quotes (#562).
  10. Tax / amount confusion — do not wrap the fee reserve into the unwrap; fee is separate Fee.amount in LUNC. Dust USTR→USTC still pays the full unwrap+2hop limit (document; no hidden min that looks like a pause).
  11. DoS via 4-hop BFS — cap remains 4; gas scales; Max/fee must cover the submitted hop count so a long route cannot empty the wallet then fail fee.
  12. XSS in fee hint — fee string is formatted number + LUNC; no raw LCD error.message interpolation.

Verification criteria

# Unit / inventory (no chain)
bash scripts/with-node.sh --cwd frontend-dapp -- npm run test:run -- \
  src/services/terraclassic/__tests__/transactions.test.ts \
  src/services/terraclassic/__tests__/terraGas.retailShapes.test.ts \
  src/services/terraclassic/__tests__/swapNetworkFee.test.ts \
  src/utils/__tests__/maxSpendableAmount.test.ts \
  src/utils/__tests__/humanizeTerraTxError.test.ts \
  src/pages/SwapPage.test.tsx
make verify-issue-475
make verify-issue-587
make verify-issue-<iid>    # added by implementation

# Chain (Cloud Agent: make setup-cloud-localterra first)
bash scripts/with-node.sh --cwd frontend-dapp -- npx playwright test \
  e2e/wrap-swap.spec.ts --project=e2e-tx

Manual on production /: USTR→USTC shows Route USTR → UST1 → cUSTC → USTC (or equivalent), Network fee ~N LUNC, one Swap, success, no OOG toast. LUNC→USTR and USTC→USTR still one-tx success.

Done when ACs 1–10 pass, verify script is green, and unwrap+2hop / USTC InstantWithdraw are in docs/frontend.md + AGENTS_TERRACLASSIC_GAS.md.

## Summary Retail report (2026-08-23, columbus-5, `dex.cl8y.com` `/`): **USTR → USTC** Swap quotes and signs, then fails with the humanized out-of-gas toast (*“Transaction needed more gas than estimated. Try again — gas usage can vary slightly between blocks.”*). Retrying does not help. The same bake **succeeds** for **LUNC → USTR** and **USTC → USTR**. Screenshot chrome confirms the native unwrap path: **Wrap limit 1.1M / 1.1M USTC available**, **Worst hop spread ≈ 0.00%**, **SWAP** CTA, **MAINNET** / columbus-5. Quote succeeded; execute OOGed. Do **not** reopen [#587](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/587). That ticket shipped wrap+≥2hop combo overhead (`WRAP_ROUTER_COMBO_OVERHEAD_GAS` 400k) and Network-fee chrome. This is the remaining **CW20 → native `uusd`** hole: same 2-hop hub graph, **`unwrap_output`**, no `wrap_deposit`, and InstantWithdraw of **taxed USTC**. Bundle measurement, envelope raise, inventory/E2E, and docs into **one** issue. Related: [#587](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/587) (closed: wrap+≥2hop LUNC↔USTR), [#343](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/343) (`unwrap_output` add-on), [#353](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/353) (router single-hop floor), [#475](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/475) (retail gas inventory), [#590](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/590) (E7/E8 Playwright still chain-gated), [#596](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/596) / **H596-7** (native path is not Pattern C hybrid). ## Current codebase USTR is CW20 `terra1vy3kc0…fsetxv`. Native USTC is `uusd`; wrap CW20 is cUSTC `terra1nap4dx…cr56ch`. There is **no** factory pair USTR/cUSTC. Columbus-5 hub graph (indexer `GET /api/v1/pairs`, 2026-08-23): | Pair | Legs | |------|------| | `terra1cepr…w55f` | UST1 / cUSTC | | `terra16vxr…5hgqy` | UST1 / USTR | | `terra1su53…mm7h4` | cLUNC / UST1 | Client BFS therefore routes **USTR → USTC** as **USTR → UST1 → cUSTC** (2 hops) + router `unwrap_output: true`. | Layer | Behavior today | |-------|----------------| | **Quote** | [`SwapPage`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/pages/SwapPage.tsx) `nativeRouteInfo` branch calls [`simulateNativeSwap`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/terraclassic/router.ts) (wallet `simulate_swap_operations` + unwrap fee/tax net). **Does not** call indexer `GET /route/solve`. Hops are **pool-only**. | | **Submit** | Same page: `isWrapOrUnwrap \|\| nativeRouteInfo` → [`executeNativeSwap`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/terraclassic/router.ts). Comment on the hook: *“Pool-only hops from client BFS `findRoute` — never copy hybrid / book_input (#587).”* Single CW20 `send` → router `execute_swap_operations` with `unwrap_output: true`. No `wrap_deposit`. | | **Gas wanted** | [`getGasLimitForTx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/terraclassic/terraGas.ts) on that send: [`gasLimitForRouterExecuteSwapOperations`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/terraclassic/terraGas.ts)(2) **1,910,000** + [`UNWRAP_GAS_LIMIT`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/constants.ts) **800k** = **2,710,000** (~76.76 LUNC at 28.325). | | **#587 combo** | [`WRAP_ROUTER_COMBO_OVERHEAD_GAS`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/constants.ts) (400k) applies only when a **`wrap_deposit` and router send (N≥2) share one multi-msg tx**. USTR→USTC has **no wrap msg**, so combo overhead is **0**. Wrap+2hop+unwrap is documented at **3,510,000**; unwrap-only 2-hop stays **2,710,000**. | | **UNWRAP calibration** | 800k was pinned from columbus-5 LCD sim of **cLUNC → wrap-mapper `{ unwrap }`** (~562k used; failed tx wanted 550k). That is **not** router `unwrap_output` after two hub hops, and **not** treasury `InstantWithdraw` of **`uusd`** (Classic burn tax / extra bank sends). | | **Inventory / E2E gap** | Fixture `send_2hop_unwrap` is labeled **USTR→LUNC** (uluna unwrap). Playwright [`wrap-swap.spec.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/e2e/wrap-swap.spec.ts) **E8** is CW20 → **LUNC**, not → **USTC**. No combined envelope named for hub USTR→USTC. | | **Why LUNC→USTR / USTC→USTR work** | Those are **wrap + hops** (no InstantWithdraw). #587 raised that envelope. Reverse **USTR→LUNC** is uluna unwrap (E8 stand-in; chain AC still open on #590). Reverse **USTR→USTC** is the taxed-`uusd` unwrap and was never measured. | | **Best-price / #596 (not the execute path)** | Indexer `GET /route/solve?token_in=USTR&token_out=cUSTC` (2026-08-23) returns **global_v4**, 2 hops, **100% `book_input`** on both USTR/UST1 and UST1/cUSTC (`max_maker_fills: 8`). Retail Swap **does not** submit those ops when the receive token is native `uusd` (**H596-7**). The OOG toast is `gasUsed > gasWanted` on the **pool-only + unwrap** envelope, not a hybrid book walk. Do not “fix” this by copying solver `hybrid` onto `executeNativeSwap`. | | **Error copy** | [`humanizeTerraTxError`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/humanizeTerraTxError.ts) maps `/out of gas/i` to the screenshot string. Retrying the **same** 2.71M limit cannot succeed (#353 / #587). | | **Wrap-limit chrome** | [`WrapRateLimitStatus`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/components/wrap/WrapRateLimitStatus.tsx) on native `uusd` is expected (#502 / #507). Fully available 1.1M/1.1M is **not** a rate-limit block; unwrap does not consume the wrap window. | ## Why this is needed USTR → native USTC is a primary off-ramp (CW20 hub → taxed `uusd`). After #587, wrap-direction hub swaps work; this reverse still burns the failed-tx fee and leaves traders stuck. “Try again” is false — the constant is too low. #587’s unwrap fixture and E8 cover **LUNC**, so USTC InstantWithdraw + 2 hub hops never had a measured ceiling. ## Constraints / guardrails 1. **Keep `executeNativeSwap` (BFS + `unwrap_output`).** Do not tell users to hop USTR→UST1→cUSTC→unwrap by hand. 2. **Native path stays pool-only.** Do **not** attach solver `hybrid` / `book_input` (would jump toward 15M per hop, #249 / #260 / **H596-7**). Indexer `GET /route/solve` for native `uusd` stays out of scope. 3. **Fee denom stays `uluna`.** Do not add USTC / multi-denom fees. 4. **Do not LCD-sim as the sole envelope** (`AGENTS_TERRACLASSIC_GAS`). If LCD sim is used for **tuning**, new constants must still exceed measured `gasUsed` with wasm/storage headroom. 5. **If a failed columbus-5 `gasUsed` is known**, new defaults must exceed it with margin (Classic variance is not tight). Same rule as #353 / #587. 6. **Do not raise `UNWRAP_GAS_LIMIT` blindly for every unwrap** if the miss is **router+unwrap combo** (same-msg overhead after N≥2 hops, analog of `WRAP_ROUTER_COMBO_OVERHEAD_GAS`). Prefer a named combo / router-unwrap add-on so direct cUSTC→USTC unwrap (800k) does not pay hub-multihop tax. 7. **Keep hop math in lockstep** with [`packages/localnet-trading-swarm/src/gas.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/packages/localnet-trading-swarm/src/gas.ts). 8. **Retail inventory (#475).** New fixture(s) in `RETAIL_COMBINED_ENVELOPE_FIXTURES` for USTR→USTC (2hop + `unwrap_output`, no wrap). Do not fall through to `SWAP_GAS_LIMIT` 600k (`SendHookGasDecodeError`). 9. **Max / Network fee (#213 / #587).** `estimateNativeSwapUlunaFeesTotal` / `swap-network-fee` / submit uluna gate must use the **same** envelope as broadcast (including the new unwrap combo). 10. **Wrap economics unchanged.** `minimum_receive` on unwrap = post-fee pre-tax (**R3**). Display You Receive stays post–burn-tax (#512). Do not retune wrap-mapper bps here. 11. **Copy (#489).** Do not add an essay. Keep **Network fee (est.) ~X LUNC**. Do not change the OOG toast to “try again” with a higher limit unless the limit actually rose. 12. **Do not enable mainnet extension fee-guard** (#429 / SEC-E08). Diagnose wallet undershoot separately. 13. **Gem-bridge (#562).** Production must not route USTR→USTC through RUBY/EMBER. 14. **Out of scope.** Reopening #587. Hybrid-on-native. Indexer `route/solve` for `uusd`. Station-on-LocalTerra (#235). Changing wrap-limit chrome (informational only). ## Relevant files | Path | Role | |------|------| | [`frontend-dapp/src/utils/constants.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/utils/constants.ts) | `UNWRAP_GAS_LIMIT`, `WRAP_ROUTER_COMBO_OVERHEAD_GAS`, `ROUTER_*` | | [`frontend-dapp/src/services/terraclassic/terraGas.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/terraclassic/terraGas.ts) | `gasLimitForSwapOperationsMsg` (+unwrap), `wrapRouterComboOverheadGas`, `totalGasLimitForExecuteMsgs` | | [`frontend-dapp/src/services/terraclassic/transactions.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/terraclassic/transactions.ts) | `nativeSwapFeeExecuteMsgs` / `estimateNativeSwapUlunaFeesTotal` | | [`frontend-dapp/src/services/terraclassic/router.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/terraclassic/router.ts) | `findRouteWithNativeSupport`, `executeNativeSwap`, `simulateNativeSwap` | | [`frontend-dapp/src/pages/SwapPage.tsx`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/pages/SwapPage.tsx) | Native quote/submit; network-fee hints | | [`frontend-dapp/src/services/terraclassic/terraGasRetailInventory.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/terraclassic/terraGasRetailInventory.ts) | Add USTR→USTC combined fixture | | [`frontend-dapp/src/services/terraclassic/__tests__/terraGas.retailShapes.test.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/terraclassic/__tests__/terraGas.retailShapes.test.ts) | Envelope assertions | | [`frontend-dapp/src/services/terraclassic/__tests__/transactions.test.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/terraclassic/__tests__/transactions.test.ts) | Native fee totals | | [`frontend-dapp/src/services/terraclassic/__tests__/swapNetworkFee.test.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/src/services/terraclassic/__tests__/swapNetworkFee.test.ts) | Network-fee row | | [`frontend-dapp/e2e/wrap-swap.spec.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/frontend-dapp/e2e/wrap-swap.spec.ts) | Add USTR→USTC (or LocalTerra 2hop+`uusd` unwrap stand-in) | | [`packages/localnet-trading-swarm/src/gas.ts`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/packages/localnet-trading-swarm/src/gas.ts) | Lockstep | | [`docs/frontend.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/docs/frontend.md) | Gas table: unwrap+N-hop / USTC InstantWithdraw | | [`skills/AGENTS_TERRACLASSIC_GAS.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/skills/AGENTS_TERRACLASSIC_GAS.md) | Playbook row | | [`AGENTS.md`](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/blob/main/AGENTS.md) | `make verify-issue-<iid>` | ## Recommended direction 1. **Measure first.** Prefer a failed columbus-5 hash (`gasWanted` / `gasUsed`) from the reporter. Else LCD-sim and/or LocalTerra **combined** single-msg: 2-hop router + `unwrap_output` into **`uusd`** (not wrap-alone + unwrap-alone). Also compare **USTR→LUNC** (uluna unwrap, same 2.71M) so we know if the miss is USTC-tax-specific or all unwrap+2hop. 2. **Fix the envelope, not the product shape.** Keep BFS + `unwrap_output`. Raise whichever is short: - **router+unwrap combo** on N≥2 (analog of wrap combo; preferred if 1-hop unwrap and direct unwrap stay green), and/or - a **router-`unwrap_output` add-on** above mapper-only `UNWRAP_GAS_LIMIT`, and/or - hop floors **only** if hub pair `gas_used` alone exceeds 1.91M. 3. **Do not default to 15M hybrid.** Target stays tens-of-LUNC at 28.325 (same class as #587 ~77–100 LUNC), not hundreds. 4. **Inventory + E2E.** Named fixture `send_2hop_unwrap_ustc`. Playwright: USTR→USTC one submit; assert no OOG toast; `gas_used < gas_wanted`. Keep E7/E8 LUNC paths. 5. **Docs / verify.** `docs/frontend.md` + `AGENTS_TERRACLASSIC_GAS.md` + `make verify-issue-<iid>`. ## Acceptance criteria - [ ] **AC1.** Swap **USTR → USTC** on columbus-5 (and LocalTerra stand-in) succeeds in **one** signed tx. No OOG toast. - [ ] **AC2.** Broadcast `gasWanted` **exceeds** measured `gasUsed` with documented margin. Combined unwrap+2hop envelope is in inventory and matches `totalGasLimitForExecuteMsgs`. - [ ] **AC3.** **LUNC → USTR** and **USTC → USTR** stay successful (no regression of #587 wrap combo). Wrap+1hop stays 1.8M. - [ ] **AC4.** Direct **cUSTC → USTC** unwrap still uses mapper `UNWRAP_GAS_LIMIT` (800k) unless measurement proves it must rise; do not silently charge hub-multihop combo on a 1:1 unwrap. - [ ] **AC5.** **USTR → LUNC** still one tx; if it also OOGs at 2.71M, the same combo must cover uluna unwrap (document both). - [ ] **AC6.** `executeNativeSwap` hops remain **pool-only** (no `hybrid` / `book_input` from indexer or query string). - [ ] **AC7.** Network fee row still **~X LUNC**; Max / submit uluna gate use the new envelope. - [ ] **AC8.** Malformed `send.msg` still **throws** `SendHookGasDecodeError`. - [ ] **AC9.** Production does not route this pair through soft-launch gems (#562). - [ ] **AC10.** `make verify-issue-<iid>` + docs/skill row. Playwright USTR→USTC (or documented stand-in) in `wrap-swap.spec.ts`. ## Test plan (all paths) | Path | Expect | |------|--------| | **USTR → USTC** (2hop + unwrap `uusd`) | One tx success; envelope ≥ measured used; Network fee LUNC | | **USTR → LUNC** (2hop + unwrap `uluna`) | Still success; if envelope changes, fee row tracks it | | **USTC → USTR** / **LUNC → USTR** | Wrap+2hop unchanged success (#587) | | **cUSTC → USTC** / **cLUNC → LUNC** | Direct unwrap; 800k unless retuned with evidence | | **LUNC → cLUNC** / **USTC → cUSTC** | Direct wrap; 400k | | **LUNC → EMBER** (wrap+1hop) | Stays 1.8M; no combo | | **CW20 ↔ CW20** (USTR → UST1) | Still Pattern C / route-solve; not this envelope | | **Native Max** on LUNC pay with unwrap-out | Reserve uses new unwrap+N envelope when hints say so | | **Submit uluna gate** | Bank LUNC ≥ fee (CW20 pay) before sign | | **Quote** | `simulateNativeSwap` still pool-only; receive is post-unwrap-fee post-tax | | **Wrap-limit banner** | May still show USTC window; must not block unwrap when remaining = max | | **Indexer GET USTR→cUSTC** | May still return book-heavy hybrid; dApp must **not** submit it on native USTC | LocalTerra: prefer hub USTR/UST1/cUSTC if seeded; else shortest ≥2hop CW20 → native `uusd` unwrap and document the stand-in. Cloud Agent: `make setup-cloud-localterra` (not Postgres-only). ## Test plan (attack, hack, abuse) 1. **Fee grief via inflated gas** — do not default unwrap+multihop to 15M hybrid. Assert USTR→USTC fee stays in the tens-of-LUNC class at 28.325. 2. **Hybrid injection** — `executeNativeSwap` must not accept attacker `hybrid` / `book_input` from query string, indexer `router_operations`, or typed Advanced book when `toToken` is `uusd`. Ops from client BFS only. 3. **Hop-count spoof** — UI `hopCount` for Max/fee must equal submitted `operations.length`. A crafted shorter hint must not produce a smaller `Fee.gas` than execute. 4. **`send.msg` tamper / decode fail** — refuse undersized 600k fallback (`SendHookGasDecodeError`). 5. **Wallet undershoot** — mainnet extension may still cut `gas_wanted` (#429). Do not treat that as “raise to 15M.” 6. **Insufficient LUNC after quote** — submit must not sign when bank uluna &lt; `Fee.amount`. Prefer blocked CTA over OOG after sign. 7. **Unwrap vs wrap-limit** — InstantWithdraw must not be gated on wrap-window remaining. Exhausted wrap window must not look like a successful unwrap path then fail mid-tx. 8. **Slippage / sandwich** — raising gas must not disable `max_spread` / `minimum_receive` (R3 post-fee pre-tax). OOG fix is not a license to widen default 5% slippage. 9. **Gem-bridge** — production rejects gem hops on economic quotes (#562). 10. **Tax / amount confusion** — do not wrap the fee reserve into the unwrap; fee is separate `Fee.amount` in LUNC. Dust USTR→USTC still pays the full unwrap+2hop limit (document; no hidden min that looks like a pause). 11. **DoS via 4-hop BFS** — cap remains 4; gas scales; Max/fee must cover the submitted hop count so a long route cannot empty the wallet then fail fee. 12. **XSS in fee hint** — fee string is formatted number + `LUNC`; no raw LCD `error.message` interpolation. ## Verification criteria ```bash # Unit / inventory (no chain) bash scripts/with-node.sh --cwd frontend-dapp -- npm run test:run -- \ src/services/terraclassic/__tests__/transactions.test.ts \ src/services/terraclassic/__tests__/terraGas.retailShapes.test.ts \ src/services/terraclassic/__tests__/swapNetworkFee.test.ts \ src/utils/__tests__/maxSpendableAmount.test.ts \ src/utils/__tests__/humanizeTerraTxError.test.ts \ src/pages/SwapPage.test.tsx make verify-issue-475 make verify-issue-587 make verify-issue-<iid> # added by implementation # Chain (Cloud Agent: make setup-cloud-localterra first) bash scripts/with-node.sh --cwd frontend-dapp -- npx playwright test \ e2e/wrap-swap.spec.ts --project=e2e-tx ``` Manual on production `/`: USTR→USTC shows Route `USTR → UST1 → cUSTC → USTC` (or equivalent), **Network fee ~N LUNC**, one Swap, success, no OOG toast. LUNC→USTR and USTC→USTR still one-tx success. Done when ACs 1–10 pass, verify script is green, and unwrap+2hop / USTC InstantWithdraw are in `docs/frontend.md` + `AGENTS_TERRACLASSIC_GAS.md`.
PlasticDigits commented 2026-08-23 03:05:41 +00:00 (Migrated from gitlab.com)

marked as related to #587

marked as related to #587
PlasticDigits commented 2026-08-23 03:05:42 +00:00 (Migrated from gitlab.com)

marked as related to #343

marked as related to #343
PlasticDigits commented 2026-08-23 03:05:43 +00:00 (Migrated from gitlab.com)

marked as related to #353

marked as related to #353
PlasticDigits commented 2026-08-23 03:05:44 +00:00 (Migrated from gitlab.com)

marked as related to #590

marked as related to #590
PlasticDigits commented 2026-08-23 03:05:44 +00:00 (Migrated from gitlab.com)

marked as related to #596

marked as related to #596
PlasticDigits commented 2026-08-23 03:05:52 +00:00 (Migrated from gitlab.com)

mentioned in issue #587

mentioned in issue #587
PlasticDigits commented 2026-08-23 03:05:53 +00:00 (Migrated from gitlab.com)

mentioned in issue #590

mentioned in issue #590
PlasticDigits commented 2026-08-23 03:21:25 +00:00 (Migrated from gitlab.com)

mentioned in commit 9c2035e21e

mentioned in commit 9c2035e21e8845fb2d7a7cbb8bb1363dc6dacd8d
PlasticDigits commented 2026-08-23 03:21:26 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1118

mentioned in merge request !1118
PlasticDigits commented 2026-08-23 03:29:58 +00:00 (Migrated from gitlab.com)

mentioned in commit 328b2b8d6d

mentioned in commit 328b2b8d6d365581d73fa3385749e7e8cff26f4c
PlasticDigits commented 2026-08-23 03:30:14 +00:00 (Migrated from gitlab.com)

mentioned in commit 4f245fc839

mentioned in commit 4f245fc839002f3ac287a4531b1ecc2e8b0aba7d
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-23 03:30:14 +00:00
PlasticDigits commented 2026-08-23 03:31:19 +00:00 (Migrated from gitlab.com)

mentioned in issue #595

mentioned in issue #595
PlasticDigits commented 2026-08-23 03:31:20 +00:00 (Migrated from gitlab.com)

Merge review (!1118 → main)

Merged as https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/merge_requests/400 (4f245fc8) after resolving conflicts with !1117 (#595). Conflict files were docs/Makefile/AGENTS only; gas inventory auto-merged and keeps both send_2hop_unwrap_ustc and wrap_plus_2hop_plus_invoice_send. GitLab CI failed with ci_quota_exceeded (not a test failure). Local sanity on the combined tree: 279 Vitest cases green.

Acceptance vs this MR

AC Result
AC2 inventory envelope 3,110,000; combo only on unwrap+N≥2 Met (unit + UNWRAP_ROUTER_COMBO_OVERHEAD_GAS 400k)
AC3 wrap+1hop stays 1.8M Met (unit)
AC4 direct cUSTC→USTC stays 800k Met (unit)
AC5 USTR→LUNC uses the same unwrap+2hop combo Met (unit / send_2hop_unwrap)
AC6 executeNativeSwap stays pool-only Met (code; no hybrid/book_input)
AC7 Network fee row uses the new envelope Met (unit)
AC8 malformed send.msg still throws SendHookGasDecodeError Met (existing #587 behavior kept)
AC9 production gem-bridge reject Unchanged policy (#562); not re-run on chain
AC1 columbus-5 USTR→USTC one-tx, no OOG Not verified — no measured gasUsed hash was attached; 400k is the wrap-combo analog
AC10 Playwright E9 on LocalTerra Spec added; not run (chain was down in the implementation workspace)

Unit/docs ACs are on main. Chain/manual ACs are still open — follow-up issue filed after this comment.

## Merge review (!1118 → `main`) Merged as https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/merge_requests/400 (`4f245fc8`) after resolving conflicts with !1117 (#595). Conflict files were docs/Makefile/AGENTS only; gas inventory auto-merged and keeps both `send_2hop_unwrap_ustc` and `wrap_plus_2hop_plus_invoice_send`. GitLab CI failed with `ci_quota_exceeded` (not a test failure). Local sanity on the combined tree: **279** Vitest cases green. ### Acceptance vs this MR | AC | Result | |----|--------| | AC2 inventory envelope 3,110,000; combo only on unwrap+N≥2 | Met (unit + `UNWRAP_ROUTER_COMBO_OVERHEAD_GAS` 400k) | | AC3 wrap+1hop stays 1.8M | Met (unit) | | AC4 direct cUSTC→USTC stays 800k | Met (unit) | | AC5 USTR→LUNC uses the same unwrap+2hop combo | Met (unit / `send_2hop_unwrap`) | | AC6 `executeNativeSwap` stays pool-only | Met (code; no hybrid/`book_input`) | | AC7 Network fee row uses the new envelope | Met (unit) | | AC8 malformed `send.msg` still throws `SendHookGasDecodeError` | Met (existing #587 behavior kept) | | AC9 production gem-bridge reject | Unchanged policy (#562); not re-run on chain | | AC1 columbus-5 USTR→USTC one-tx, no OOG | **Not verified** — no measured `gasUsed` hash was attached; 400k is the wrap-combo analog | | AC10 Playwright E9 on LocalTerra | Spec added; **not run** (chain was down in the implementation workspace) | Unit/docs ACs are on `main`. Chain/manual ACs are still open — follow-up issue filed after this comment.
PlasticDigits commented 2026-08-23 03:31:22 +00:00 (Migrated from gitlab.com)

mentioned in issue #600

mentioned in issue #600
PlasticDigits commented 2026-08-23 03:31:22 +00:00 (Migrated from gitlab.com)

marked as related to #600

marked as related to #600
PlasticDigits commented 2026-08-27 00:20:45 +00:00 (Migrated from gitlab.com)

mentioned in issue #679

mentioned in issue #679
PlasticDigits commented 2026-08-27 00:20:56 +00:00 (Migrated from gitlab.com)

mentioned in issue #681

mentioned in issue #681
PlasticDigits commented 2026-08-27 11:52:21 +00:00 (Migrated from gitlab.com)

mentioned in issue #690

mentioned in issue #690
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#599
No description provided.