fix(swap): CL8Y to KENA out of gas — auto gas 1.2×, per-hop fallback #1360

Closed
opened 2026-09-27 12:17:16 +00:00 by PlasticDigits · 1 comment

Summary

Retail Swap of CL8Y → KENA on columbus-5 (dex.cl8y.com, Keplr) broadcasts a two-hop pool-only router swap CL8Y → cLUNC → KENA with a static gas_wanted of 1,910,000. The transaction is included and fails code 11 (out of gas) at about 1,937,97x gas used. The same static envelope fails for both ~0.54 CL8Y and the on-screen suggestion 0.05 CL8Y. Each failed inclusion still charges about 54.10 LUNC.

A later pay-with-LUNC swap of the same KENA market succeeds because it is a different message shape: wrap plus one cLUNC → KENA hop (gas_wanted 1,800,000, gas_used 1,661,441).

The fix is not a new gas floor for this pair. Broadcast gas for a swap is auto gas: LCD simulate the exact transaction and set gas_wanted to 1.2× the simulated gas_used. The existing global per-hop gas setting is the fallback, and it is used only when that auto-gas attempt fails.

Current codebase

Swap does not LCD-simulate execute gas before it signs. estimateTerraClassicFeeForEntries in frontend-dapp/src/services/terraclassic/terraClassicFeeEstimate.ts multiplies a static limit from getGasLimitForTx (frontend-dapp/src/services/terraclassic/terraGas.ts) by effectiveGasPriceUluna() (28.325 uluna per gas). broadcastTerraExecuteContracts signs that Fee.

For router execute_swap_operations the global per-hop math is gasLimitForRouterExecuteSwapOperations:

  • one hop: ROUTER_SINGLE_HOP_GAS_LIMIT = 1,400,000
  • two or more hops: max(scaled hop math, hops × ROUTER_SWAP_OPS_MIN_GAS_PER_HOP) + SWAP_GAS_SAFETY_MARGIN
  • ROUTER_SWAP_OPS_MIN_GAS_PER_HOP is 950,000 and the safety margin is 10,000, so two hops is 1,910,000

SWAP_GAS_BUFFER (1.3) is the multiplier inside that static formula. It is not an LCD auto-gas adjustment. CLI scripts use terrad --gas-adjustment 1.3 the same way. This ticket’s auto-gas multiplier is 1.2, applied only to a successful simulate gas_used.

#1328 / ADR 0013 adds a pair-route exception: 3,000,000 when two pool-only hops run from configured CL8Y to configured UST1 (cl8yUst1PoolOnlyTwoHopGasLimit). Invariant G1328-5 leaves every other two-hop route, including CL8Y → cLUNC → KENA, on 1,910,000. That pair exception is the pattern this ticket stops extending. A read-only simulate harness already exists at frontend-dapp/scripts/measureIssue1328Columbus.mjs (POST /cosmos/tx/v1beta1/simulate). The dApp does not call it on the sign path.

The signed CL8Y failures are CW20 send of CL8Y (terra16wtml2q66g82fdkx66tap0qjkahqwp4lwq3ngtygacg5q0kzycgqvhpax3, 18 decimals) into the router (terra1e7s0h9ftxakwca5gxspyt4haeuaqxds6swr08ul3tsepq7el924sprrsrw) with execute_swap_operations, max_spread 0.05, and no hybrid / greedy field:

When (UTC) Hash CL8Y raw in gas_wanted gas_used code
2026-09-27T11:48:19Z 82911164…2A2E 539649831011571000 (~0.540 CL8Y) 1,910,000 1,937,976 11
2026-09-27T11:49:13Z A5BAFA88…8E31 same ~0.540 CL8Y 1,910,000 1,937,972 11
2026-09-27T11:49:30Z FE8D4C8A…C9D2 same ~0.540 CL8Y 1,910,000 1,937,972 11
2026-09-27T11:54:13Z 27AC18AC…049D 50000000000000000 (0.05 CL8Y) 1,910,000 1,937,775 11

Hop 1 ask and hop 2 offer are cLUNC (terra1437qslye72t7qmmahn4t5chz50r8a62g45phwkquwpyu2l62u6ksqssgdg). Hop 2 ask is KENA (terra1ps34wcgyjjp93hf2wvmt3t7v9ky9xm43yhzf6anq8kyp777egsdq0eua67, 6 decimals). Fee on each failure is 54,100,750 uluna (1,910,000 × 28.325). Four failures spent about 216.4 LUNC and moved no KENA.

ceil(1,937,976 × 1.2) is 2,325,572 gas, about 65.87 LUNC at 28.325. That clears the measured use. The static 1.91M figure is what did not.

The completed swap is 82729649…43BD at 2026-09-27T12:03:09Z, code 0: wrap_deposit of 200 LUNC, then one pool hop of 196 cLUNC → KENA. gas_used 1,661,441 under a 1,800,000 static envelope. That path already fits. It is a different shape from the failing two-hop CL8Y send.

On screen at the same time: Worst hop spread ≈ 2.63% and This size moves the pool. Try a smaller amount. Use 0.05 instead. high_impact keys off route-level expected slippage above 5% (swapPayAcquireGuidance.ts). Worst-hop spread is a different number, printed outside collapsed Trade details. acquireGuidanceBlocksSubmit does not include high_impact, so Swap still signs. The 0.05 CL8Y row is that suggestion, and it used the same 1,910,000 gas. After sign, #359 recovery can show Broadcast status unknown until the tx is indexed. The recovery line on this report showed a hash beginning D47E825E and ending 632CA0, which is not one of the five included transactions in this table. Once code !== 0, humanizeTerraTxError.ts maps out of gas to Transaction needed more gas than estimated. Try again — gas usage can vary slightly between blocks. These four samples sit in a 201 gas band. A retry at 1,910,000 fails again and charges another ~54 LUNC.

Why this implementation is needed

The two-hop CL8Y send is about 28,000 gas over the static global two-hop result. Amount does not change that: 0.05 CL8Y and ~0.54 CL8Y both die at ~1,937,97x. A KENA-only or CL8Y-only floor would paper over one route and leave the next pair on the same static number. Auto gas at 1.2× follows the simulated cost of the transaction that will be signed. The global per-hop setting remains the offline/default number when simulate cannot be used.

#1330 stays the route-leg display ticket. This ticket does not build that UI.

Constraints and guardrails

  • Primary swap Fee.gas is ceil(simulated_gas_used × 1.2) from one LCD POST /cosmos/tx/v1beta1/simulate of the same messages, contract addresses, funds, and signer that will be signed.
  • The global per-hop function (gasLimitForRouterExecuteSwapOperations / getGasLimitForTx for the non-pair path, and totalGasLimitForExecuteMsgs for multi-message txs such as wrap + hop) runs only after auto gas fails.
  • Auto gas has failed when the simulate call times out, the HTTP status is not success, the body does not yield a positive integer gas_used, or gas_used is above a single global cap. The cap exists so a ~147,000,000 simulate (the Station mobile auto-gas case in #679) is a failure. Use the existing hybrid ceiling 15,000,000 (HYBRID_SWAP_GAS_LIMIT) as that cap unless chain consensus max_gas is lower. A result above the cap selects the per-hop fallback. It is not multiplied by 1.2 and signed.
  • A successful simulate is not raised or lowered to a pair floor. cl8yUst1PoolOnlyTwoHopGasLimit and any new CL8Y → KENA constant are not inputs to the signed fee. Delete the UST1 pair predicate from the fee decision in the same change so a later route cannot grow another exception. make verify-issue-1328 expectations move to: successful sim uses 1.2×; failed sim uses the global per-hop result (1,910,000 for two hops), including CL8Y → UST1.
  • The gas limit placed inside the simulate request is only there so the query itself can finish. It is not copied into the signed Fee.
  • One simulate attempt per submit, with a bounded timeout. On failure, sign once with the per-hop fallback. No second broadcast and no automatic retry after an included code 11.
  • Fee denom stays uluna. Fee.amount is gas_wanted × effectiveGasPriceUluna(). Swap Network fee (est.) and the Trade market hint show the number that will be signed: the 1.2× figure after a successful sim, otherwise the per-hop fallback.
  • Keplr / Station preferNoSetFee stays on so the wallet does not replace this fee with its own simulate.
  • max_spread and minimum_receive stay as they are on the signed swap.
  • While phase is recovering or confirming, hide Use {amount} instead. That control edits the pay field; it must not broadcast.
  • The high-impact sentence and the percent outside collapsed Trade details are the same figure. A lone 2.63% worst-hop line is not shown next to This size moves the pool when the banner’s input was route slippage above 5%.
  • The out of gas sentence that contains both gasWanted and gasUsed, with used greater than wanted by more than 1,000, does not say “try again” or “vary slightly”. Other errors keep their current copy.
  • No trader bech32, chat text, or person identifier in fixtures or docs.

Known residual, accepted with this design: if simulate fails, two-hop CL8Y → KENA and CL8Y → UST1 still sign 1,910,000, which these measurements exceed. Closing that residual with a pair floor is out of scope. A later change may raise the single global per-hop constant for every pair.

Relevant files

  • frontend-dapp/src/services/terraclassic/terraBroadcast.ts — sign path; call simulate before buildTerraClassicFee
  • frontend-dapp/src/services/terraclassic/terraClassicFeeEstimate.ts — one estimate helper for broadcast and the Network fee row
  • frontend-dapp/src/services/terraclassic/terraGas.ts — global per-hop fallback; remove cl8yUst1PoolOnlyTwoHopGasLimit from the fee decision
  • frontend-dapp/src/utils/constants.ts — ROUTER_SWAP_OPS_MIN_GAS_PER_HOP, HYBRID_SWAP_GAS_LIMIT as the sim cap, new AUTO_GAS_ADJUSTMENT = 1.2
  • frontend-dapp/scripts/measureIssue1328Columbus.mjs — existing simulate request shape to mirror, not a pair-specific broadcast limit
  • frontend-dapp/src/pages/SwapPage.tsx and frontend-dapp/src/components/trade/TradeMarketOrderPanel.tsx — Network fee, acquire banner, recovery phase
  • frontend-dapp/src/utils/swapPayAcquireGuidance.ts, SwapPayAcquireGuidanceBanner.tsx, swapRouteSlippage.ts
  • frontend-dapp/src/utils/humanizeTerraTxError.ts
  • skills/AGENTS_TERRACLASSIC_GAS.md, docs/adr/0013-cl8y-ust1-gas-floor.md — record that pair floors are not the broadcast path
  • Tests: terraGas.issue1328.test.ts, broadcast tests, swapPayAcquireGuidance.test.ts, humanizer tests

Add a small helper, used by broadcast and by the fee hint:

  1. Build the unsigned tx the wallet will sign.
  2. POST /cosmos/tx/v1beta1/simulate once, under a timeout.
  3. If gas_used is a positive integer and gas_used ≤ 15_000_000, set gas_wanted = ceil(gas_used × 1.2) and the uluna fee from effectiveGasPriceUluna().
  4. Otherwise set gas_wanted from the existing global per-hop / per-message helpers only.

Do not max() a successful 1.2× result with cl8yUst1PoolOnlyTwoHopGasLimit or with a KENA constant. Remove that predicate from gasLimitForSwapOperationsMsg. Leave ROUTER_SWAP_OPS_MIN_GAS_PER_HOP at 950,000 in this change.

For the banner, compare and display one percent. Hide the reduce control during recovering and confirming. Point the deterministic out-of-gas humanizer at one sentence that the estimate was short, with no raw-log interpolation.

Acceptance criteria

  1. A successful simulate of the CL8Y → cLUNC → KENA send with gas_used 1,937,976 produces signed gas_wanted 2,325,572 and fee denom uluna. The same multiplier applies to the 0.05 CL8Y and ~0.54 CL8Y messages. No KENA or CL8Y address check participates.
  2. Simulate timeout, non-success HTTP, missing gas_used, gas_used 0, and gas_used 147,000,000 each produce the global two-hop fallback 1,910,000 and do not sign ceil(147_000_000 × 1.2).
  3. CL8Y → UST1 with a successful sim uses ceil(sim × 1.2), not 3,000,000. The same route with a failed sim uses 1,910,000. Wrap + one cLUNC → KENA hop with a failed sim stays on the existing wrap+1hop total 1,800,000. A failed sim never selects a pair-specific function.
  4. Swap and Trade market Network fee equal the gas that broadcastTerraExecuteContracts will sign, after the simulate result or the fallback is known.
  5. Submit performs one simulate. A failed sim signs one fallback tx. An included code 11 does not broadcast again.
  6. Worst-hop 2.63 and route slippage above 5 do not show This size moves the pool beside a lone 2.63% line. Use {amount} instead is absent while phase is recovering or confirming, and its handler does not broadcast.
  7. Humanized out of gas for gasWanted: 1910000, gasUsed: 1937976 does not contain “try again” or “vary slightly”.
  8. No trader bech32 or chat transcript in new fixtures or docs.

Test plan

  • Auto gas: fixture gas_used 1,937,976 → wanted 2,325,572. 1,937,775 → ceil(× 1.2). A 1-hop gas_used of 1,661,441 → ceil(× 1.2) = 1,993,730, and that result is what a successful sim signs even though the static wrap+1hop envelope is 1,800,000.
  • Failure paths: timeout, HTTP 500, empty body, gas_used "0", gas_used "-1", gas_used 147000000. Each yields gasLimitForRouterExecuteSwapOperations(2) = 1,910,000 for a two-hop router send, and the wrap+send multi-message helper for a wrap+1hop tx. No code path reads the KENA or UST1 address to pick the number.
  • Cap boundary: gas_used 15,000,000 succeeds and wants ceil(15_000_000 × 1.2) = 18,000,000. gas_used 15,000,001 fails closed to the per-hop fallback. (If implementers cap the wanted value at 15,000,000 instead, lock that choice in the test and still reject 147,000,000.)
  • Fee row: after a resolved sim, Swap and Trade market render the 1.2× uluna amount. After a forced sim failure, they render the per-hop uluna amount. Denom is uluna.
  • Single submit: a stub simulate that rejects is called once; broadcastTx is called once with the fallback fee. A stub that returns 1,937,976 is called once and the signed fee gas is 2,325,572.
  • Banner and copy: same cases as acceptance criteria 6 and 7. Unrelated humanizer strings (insufficient fees, deadline, code 32) stay as they are.
  • 1328 regression: update the focused test so the UST1 pair predicate is absent from fee selection. make verify-issue-1328 matches the new rule.

Attack, hack, and abuse

  • Fee drain from a short static limit: the four code-11 txs are the observed case (~216 LUNC, no KENA moved). Auto gas at 1.2× of ~1,937,976 signs ~2,325,572 instead. Tests lock that arithmetic. There is no automatic rebroadcast after code 11.
  • Hostile or broken simulate: a node or proxy that returns ~147,000,000 gas (the #679 Station figure) must hit the cap and fall back to the global per-hop fee (~54 LUNC for two hops), not 1.2 × 147e6 (~4,990 LUNC). Test the numeric boundary.
  • Tiny simulate: gas_used 1 is a positive integer under the cap, so the rule signs ceil(1.2) = 2. That can still include as out of gas and burn a tiny fee. Treat gas_used below 100,000 as a failed sim so a truncated or dummy response uses the per-hop fallback (~54 LUNC) instead of a 2-gas fee that still fails. 100,000 is far under every measured retail swap in this repo and is not a pair floor.
  • Wallet overwrite: tests or review confirm preferNoSetFee is still set for Keplr and Station extension, so the extension does not run a second simulate and replace the 1.2× fee. Station WalletConnect atomic post remains the documented residual from G-AUTO-8; this ticket does not switch that client to LCD-sim-as-fee.
  • Simulate-request gas leaked into the signature: the high gas limit used so the query can execute must not equal signed Fee.gas unless ceil(gas_used × 1.2) happens to match it. A test spies on the signed fee and asserts it is the 1.2× value, not the query ceiling.
  • Double submit: the reduce control and the out-of-gas copy cannot call broadcast. One click, one simulate, one sign.
  • Slippage bypass: fixtures keep max_spread 0.05 and a positive minimum_receive. The gas helper does not drop those fields.
  • Pair-floor smuggling: a quote that names KENA, UST1, or a new meme still has no address-keyed gas constant. A unit test builds two identical hop counts with different CW20 addresses and expects the same fallback number, and the same 1.2× number for the same gas_used.
  • Copy injection: the new out-of-gas sentence is a constant. It does not interpolate the raw log, addresses, or amounts.
  • Privacy: fixtures use token addresses already in tokenRegistry.ts and synthetic gas_used values. They do not embed the sender from the hashes above.

Verification

  • Vitest covers the 1.2× arithmetic, the failure matrix, the 15,000,000 cap, the fee-row equality, the single-submit rule, the banner, and the humanizer.
  • make verify-issue-1328 passes under the updated expectations (no pair floor on the fee path).
  • A read-only Columbus-5 simulate of the two-hop CL8Y → cLUNC → KENA message records gas_used, and ceil(gas_used × 1.2) is greater than that gas_used. The script does not broadcast.
  • A signed Keplr swap remains a later wallet check (gas_used < gas_wanted, Network fee matches the signed gas). This ticket does not block on that step.
## Summary Retail Swap of **CL8Y → KENA** on columbus-5 (`dex.cl8y.com`, Keplr) broadcasts a two-hop pool-only router swap **CL8Y → cLUNC → KENA** with a static `gas_wanted` of **1,910,000**. The transaction is included and fails `code` **11** (`out of gas`) at about **1,937,97x** gas used. The same static envelope fails for both ~0.54 CL8Y and the on-screen suggestion **0.05 CL8Y**. Each failed inclusion still charges about **54.10 LUNC**. A later pay-with-**LUNC** swap of the same KENA market succeeds because it is a different message shape: wrap plus **one** cLUNC → KENA hop (`gas_wanted` **1,800,000**, `gas_used` **1,661,441**). The fix is **not** a new gas floor for this pair. Broadcast gas for a swap is auto gas: LCD simulate the exact transaction and set `gas_wanted` to **1.2×** the simulated `gas_used`. The existing **global per-hop** gas setting is the fallback, and it is used only when that auto-gas attempt fails. ## Current codebase Swap does not LCD-simulate execute gas before it signs. `estimateTerraClassicFeeForEntries` in `frontend-dapp/src/services/terraclassic/terraClassicFeeEstimate.ts` multiplies a static limit from `getGasLimitForTx` (`frontend-dapp/src/services/terraclassic/terraGas.ts`) by `effectiveGasPriceUluna()` (28.325 uluna per gas). `broadcastTerraExecuteContracts` signs that `Fee`. For router `execute_swap_operations` the global per-hop math is `gasLimitForRouterExecuteSwapOperations`: - one hop: `ROUTER_SINGLE_HOP_GAS_LIMIT` = **1,400,000** - two or more hops: `max(scaled hop math, hops × ROUTER_SWAP_OPS_MIN_GAS_PER_HOP) + SWAP_GAS_SAFETY_MARGIN` - `ROUTER_SWAP_OPS_MIN_GAS_PER_HOP` is **950,000** and the safety margin is **10,000**, so two hops is **1,910,000** `SWAP_GAS_BUFFER` (**1.3**) is the multiplier inside that static formula. It is not an LCD auto-gas adjustment. CLI scripts use `terrad --gas-adjustment 1.3` the same way. This ticket’s auto-gas multiplier is **1.2**, applied only to a successful simulate `gas_used`. [#1328](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1328) / [ADR 0013](docs/adr/0013-cl8y-ust1-gas-floor.md) adds a **pair-route** exception: **3,000,000** when two pool-only hops run from configured CL8Y to configured UST1 (`cl8yUst1PoolOnlyTwoHopGasLimit`). Invariant **G1328-5** leaves every other two-hop route, including CL8Y → cLUNC → KENA, on **1,910,000**. That pair exception is the pattern this ticket stops extending. A read-only simulate harness already exists at `frontend-dapp/scripts/measureIssue1328Columbus.mjs` (`POST /cosmos/tx/v1beta1/simulate`). The dApp does not call it on the sign path. The signed CL8Y failures are CW20 `send` of CL8Y (`terra16wtml2q66g82fdkx66tap0qjkahqwp4lwq3ngtygacg5q0kzycgqvhpax3`, 18 decimals) into the router (`terra1e7s0h9ftxakwca5gxspyt4haeuaqxds6swr08ul3tsepq7el924sprrsrw`) with `execute_swap_operations`, `max_spread` **0.05**, and no `hybrid` / `greedy` field: | When (UTC) | Hash | CL8Y raw in | `gas_wanted` | `gas_used` | `code` | | --- | --- | --- | --- | --- | --- | | 2026-09-27T11:48:19Z | [`82911164…2A2E`](https://finder.terraclassic.community/columbus-5/tx/82911164FB9398201AE4AFCA2962F45B7452E1FD265D53421DF19CD1BD8D2A2E) | 539649831011571000 (~0.540 CL8Y) | 1,910,000 | 1,937,976 | 11 | | 2026-09-27T11:49:13Z | [`A5BAFA88…8E31`](https://finder.terraclassic.community/columbus-5/tx/A5BAFA887FE9A2A822DC80B89D7340157F26B66CF55E2926FFDA24549F0D8E31) | same ~0.540 CL8Y | 1,910,000 | 1,937,972 | 11 | | 2026-09-27T11:49:30Z | [`FE8D4C8A…C9D2`](https://finder.terraclassic.community/columbus-5/tx/FE8D4C8ACE0D1180A5001C4E6A0965FE4CB49FA9B7EF55B7198C1EC9890AC9D2) | same ~0.540 CL8Y | 1,910,000 | 1,937,972 | 11 | | 2026-09-27T11:54:13Z | [`27AC18AC…049D`](https://finder.terraclassic.community/columbus-5/tx/27AC18AC9B62DC5227703AF39ACA1C822600A2CB971E8B18307FA5EC9C17049D) | 50000000000000000 (**0.05 CL8Y**) | 1,910,000 | 1,937,775 | 11 | Hop 1 ask and hop 2 offer are cLUNC (`terra1437qslye72t7qmmahn4t5chz50r8a62g45phwkquwpyu2l62u6ksqssgdg`). Hop 2 ask is KENA (`terra1ps34wcgyjjp93hf2wvmt3t7v9ky9xm43yhzf6anq8kyp777egsdq0eua67`, 6 decimals). Fee on each failure is **54,100,750 uluna** (1,910,000 × 28.325). Four failures spent about **216.4 LUNC** and moved no KENA. `ceil(1,937,976 × 1.2)` is **2,325,572** gas, about **65.87 LUNC** at 28.325. That clears the measured use. The static 1.91M figure is what did not. The completed swap is [`82729649…43BD`](https://finder.terraclassic.community/columbus-5/tx/82729649C6D95593A5273D66F90CD2DF6E6CD45D1565BD36FF920257D59B43BD) at 2026-09-27T12:03:09Z, `code` 0: `wrap_deposit` of **200 LUNC**, then one pool hop of **196 cLUNC → KENA**. `gas_used` **1,661,441** under a **1,800,000** static envelope. That path already fits. It is a different shape from the failing two-hop CL8Y send. On screen at the same time: **Worst hop spread ≈ 2.63%** and **This size moves the pool. Try a smaller amount. Use 0.05 instead.** `high_impact` keys off route-level expected slippage above 5% (`swapPayAcquireGuidance.ts`). Worst-hop spread is a different number, printed outside collapsed Trade details. `acquireGuidanceBlocksSubmit` does not include `high_impact`, so Swap still signs. The 0.05 CL8Y row is that suggestion, and it used the same 1,910,000 gas. After sign, [#359](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/359) recovery can show **Broadcast status unknown** until the tx is indexed. The recovery line on this report showed a hash beginning `D47E825E` and ending `632CA0`, which is not one of the five included transactions in this table. Once `code !== 0`, `humanizeTerraTxError.ts` maps `out of gas` to **Transaction needed more gas than estimated. Try again — gas usage can vary slightly between blocks.** These four samples sit in a **201** gas band. A retry at 1,910,000 fails again and charges another ~54 LUNC. ## Why this implementation is needed The two-hop CL8Y send is about **28,000** gas over the static global two-hop result. Amount does not change that: 0.05 CL8Y and ~0.54 CL8Y both die at ~1,937,97x. A KENA-only or CL8Y-only floor would paper over one route and leave the next pair on the same static number. Auto gas at 1.2× follows the simulated cost of the transaction that will be signed. The global per-hop setting remains the offline/default number when simulate cannot be used. [#1330](https://git.cl8y.com/code/cl8y-dex-terraclassic/issues/1330) stays the route-leg display ticket. This ticket does not build that UI. ## Constraints and guardrails - Primary swap `Fee.gas` is `ceil(simulated_gas_used × 1.2)` from one LCD `POST /cosmos/tx/v1beta1/simulate` of the same messages, contract addresses, funds, and signer that will be signed. - The global per-hop function (`gasLimitForRouterExecuteSwapOperations` / `getGasLimitForTx` for the non-pair path, and `totalGasLimitForExecuteMsgs` for multi-message txs such as wrap + hop) runs only after auto gas fails. - Auto gas has failed when the simulate call times out, the HTTP status is not success, the body does not yield a positive integer `gas_used`, or `gas_used` is above a single global cap. The cap exists so a ~147,000,000 simulate (the Station mobile auto-gas case in [#679](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/679)) is a failure. Use the existing hybrid ceiling **15,000,000** (`HYBRID_SWAP_GAS_LIMIT`) as that cap unless chain consensus `max_gas` is lower. A result above the cap selects the per-hop fallback. It is not multiplied by 1.2 and signed. - A successful simulate is not raised or lowered to a pair floor. `cl8yUst1PoolOnlyTwoHopGasLimit` and any new CL8Y → KENA constant are not inputs to the signed fee. Delete the UST1 pair predicate from the fee decision in the same change so a later route cannot grow another exception. `make verify-issue-1328` expectations move to: successful sim uses 1.2×; failed sim uses the global per-hop result (**1,910,000** for two hops), including CL8Y → UST1. - The gas limit placed inside the simulate request is only there so the query itself can finish. It is not copied into the signed `Fee`. - One simulate attempt per submit, with a bounded timeout. On failure, sign once with the per-hop fallback. No second broadcast and no automatic retry after an included `code` 11. - Fee denom stays `uluna`. `Fee.amount` is `gas_wanted × effectiveGasPriceUluna()`. Swap **Network fee (est.)** and the Trade market hint show the number that will be signed: the 1.2× figure after a successful sim, otherwise the per-hop fallback. - Keplr / Station `preferNoSetFee` stays on so the wallet does not replace this fee with its own simulate. - `max_spread` and `minimum_receive` stay as they are on the signed swap. - While phase is `recovering` or `confirming`, hide **Use {amount} instead**. That control edits the pay field; it must not broadcast. - The high-impact sentence and the percent outside collapsed Trade details are the same figure. A lone **2.63%** worst-hop line is not shown next to **This size moves the pool** when the banner’s input was route slippage above 5%. - The `out of gas` sentence that contains both `gasWanted` and `gasUsed`, with used greater than wanted by more than **1,000**, does not say “try again” or “vary slightly”. Other errors keep their current copy. - No trader bech32, chat text, or person identifier in fixtures or docs. Known residual, accepted with this design: if simulate fails, two-hop CL8Y → KENA and CL8Y → UST1 still sign **1,910,000**, which these measurements exceed. Closing that residual with a pair floor is out of scope. A later change may raise the single global per-hop constant for every pair. ## Relevant files - `frontend-dapp/src/services/terraclassic/terraBroadcast.ts` — sign path; call simulate before `buildTerraClassicFee` - `frontend-dapp/src/services/terraclassic/terraClassicFeeEstimate.ts` — one estimate helper for broadcast and the Network fee row - `frontend-dapp/src/services/terraclassic/terraGas.ts` — global per-hop fallback; remove `cl8yUst1PoolOnlyTwoHopGasLimit` from the fee decision - `frontend-dapp/src/utils/constants.ts` — `ROUTER_SWAP_OPS_MIN_GAS_PER_HOP`, `HYBRID_SWAP_GAS_LIMIT` as the sim cap, new `AUTO_GAS_ADJUSTMENT = 1.2` - `frontend-dapp/scripts/measureIssue1328Columbus.mjs` — existing simulate request shape to mirror, not a pair-specific broadcast limit - `frontend-dapp/src/pages/SwapPage.tsx` and `frontend-dapp/src/components/trade/TradeMarketOrderPanel.tsx` — Network fee, acquire banner, recovery phase - `frontend-dapp/src/utils/swapPayAcquireGuidance.ts`, `SwapPayAcquireGuidanceBanner.tsx`, `swapRouteSlippage.ts` - `frontend-dapp/src/utils/humanizeTerraTxError.ts` - `skills/AGENTS_TERRACLASSIC_GAS.md`, `docs/adr/0013-cl8y-ust1-gas-floor.md` — record that pair floors are not the broadcast path - Tests: `terraGas.issue1328.test.ts`, broadcast tests, `swapPayAcquireGuidance.test.ts`, humanizer tests ## Recommended direction Add a small helper, used by broadcast and by the fee hint: 1. Build the unsigned tx the wallet will sign. 2. `POST /cosmos/tx/v1beta1/simulate` once, under a timeout. 3. If `gas_used` is a positive integer and `gas_used ≤ 15_000_000`, set `gas_wanted = ceil(gas_used × 1.2)` and the uluna fee from `effectiveGasPriceUluna()`. 4. Otherwise set `gas_wanted` from the existing global per-hop / per-message helpers only. Do not `max()` a successful 1.2× result with `cl8yUst1PoolOnlyTwoHopGasLimit` or with a KENA constant. Remove that predicate from `gasLimitForSwapOperationsMsg`. Leave `ROUTER_SWAP_OPS_MIN_GAS_PER_HOP` at **950,000** in this change. For the banner, compare and display one percent. Hide the reduce control during `recovering` and `confirming`. Point the deterministic out-of-gas humanizer at one sentence that the estimate was short, with no raw-log interpolation. ## Acceptance criteria 1. A successful simulate of the CL8Y → cLUNC → KENA send with `gas_used` **1,937,976** produces signed `gas_wanted` **2,325,572** and fee denom `uluna`. The same multiplier applies to the 0.05 CL8Y and ~0.54 CL8Y messages. No KENA or CL8Y address check participates. 2. Simulate timeout, non-success HTTP, missing `gas_used`, `gas_used` 0, and `gas_used` **147,000,000** each produce the global two-hop fallback **1,910,000** and do not sign `ceil(147_000_000 × 1.2)`. 3. CL8Y → UST1 with a successful sim uses `ceil(sim × 1.2)`, not **3,000,000**. The same route with a failed sim uses **1,910,000**. Wrap + one cLUNC → KENA hop with a failed sim stays on the existing wrap+1hop total **1,800,000**. A failed sim never selects a pair-specific function. 4. Swap and Trade market Network fee equal the gas that `broadcastTerraExecuteContracts` will sign, after the simulate result or the fallback is known. 5. Submit performs one simulate. A failed sim signs one fallback tx. An included `code` 11 does not broadcast again. 6. Worst-hop **2.63** and route slippage **above 5** do not show **This size moves the pool** beside a lone 2.63% line. **Use {amount} instead** is absent while phase is `recovering` or `confirming`, and its handler does not broadcast. 7. Humanized `out of gas` for `gasWanted: 1910000, gasUsed: 1937976` does not contain “try again” or “vary slightly”. 8. No trader bech32 or chat transcript in new fixtures or docs. ## Test plan - **Auto gas:** fixture `gas_used` 1,937,976 → wanted 2,325,572. 1,937,775 → `ceil(× 1.2)`. A 1-hop `gas_used` of 1,661,441 → `ceil(× 1.2)` = **1,993,730**, and that result is what a successful sim signs even though the static wrap+1hop envelope is 1,800,000. - **Failure paths:** timeout, HTTP 500, empty body, `gas_used` `"0"`, `gas_used` `"-1"`, `gas_used` `147000000`. Each yields `gasLimitForRouterExecuteSwapOperations(2)` = 1,910,000 for a two-hop router send, and the wrap+send multi-message helper for a wrap+1hop tx. No code path reads the KENA or UST1 address to pick the number. - **Cap boundary:** `gas_used` 15,000,000 succeeds and wants `ceil(15_000_000 × 1.2)` = **18,000,000**. `gas_used` 15,000,001 fails closed to the per-hop fallback. (If implementers cap the *wanted* value at 15,000,000 instead, lock that choice in the test and still reject 147,000,000.) - **Fee row:** after a resolved sim, Swap and Trade market render the 1.2× uluna amount. After a forced sim failure, they render the per-hop uluna amount. Denom is `uluna`. - **Single submit:** a stub simulate that rejects is called once; `broadcastTx` is called once with the fallback fee. A stub that returns 1,937,976 is called once and the signed fee gas is 2,325,572. - **Banner and copy:** same cases as acceptance criteria 6 and 7. Unrelated humanizer strings (`insufficient fees`, deadline, code 32) stay as they are. - **1328 regression:** update the focused test so the UST1 pair predicate is absent from fee selection. `make verify-issue-1328` matches the new rule. ## Attack, hack, and abuse - **Fee drain from a short static limit:** the four code-11 txs are the observed case (~216 LUNC, no KENA moved). Auto gas at 1.2× of ~1,937,976 signs ~2,325,572 instead. Tests lock that arithmetic. There is no automatic rebroadcast after code 11. - **Hostile or broken simulate:** a node or proxy that returns ~147,000,000 gas (the [#679](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/679) Station figure) must hit the cap and fall back to the global per-hop fee (~54 LUNC for two hops), not `1.2 × 147e6` (~4,990 LUNC). Test the numeric boundary. - **Tiny simulate:** `gas_used` 1 is a positive integer under the cap, so the rule signs `ceil(1.2)` = **2**. That can still include as out of gas and burn a tiny fee. Treat `gas_used` below **100,000** as a failed sim so a truncated or dummy response uses the per-hop fallback (~54 LUNC) instead of a 2-gas fee that still fails. 100,000 is far under every measured retail swap in this repo and is not a pair floor. - **Wallet overwrite:** tests or review confirm `preferNoSetFee` is still set for Keplr and Station extension, so the extension does not run a second simulate and replace the 1.2× fee. Station WalletConnect atomic post remains the documented residual from **G-AUTO-8**; this ticket does not switch that client to LCD-sim-as-fee. - **Simulate-request gas leaked into the signature:** the high gas limit used so the query can execute must not equal signed `Fee.gas` unless `ceil(gas_used × 1.2)` happens to match it. A test spies on the signed fee and asserts it is the 1.2× value, not the query ceiling. - **Double submit:** the reduce control and the out-of-gas copy cannot call broadcast. One click, one simulate, one sign. - **Slippage bypass:** fixtures keep `max_spread` 0.05 and a positive `minimum_receive`. The gas helper does not drop those fields. - **Pair-floor smuggling:** a quote that names KENA, UST1, or a new meme still has no address-keyed gas constant. A unit test builds two identical hop counts with different CW20 addresses and expects the same fallback number, and the same 1.2× number for the same `gas_used`. - **Copy injection:** the new out-of-gas sentence is a constant. It does not interpolate the raw log, addresses, or amounts. - **Privacy:** fixtures use token addresses already in `tokenRegistry.ts` and synthetic `gas_used` values. They do not embed the sender from the hashes above. ## Verification - Vitest covers the 1.2× arithmetic, the failure matrix, the 15,000,000 cap, the fee-row equality, the single-submit rule, the banner, and the humanizer. - `make verify-issue-1328` passes under the updated expectations (no pair floor on the fee path). - A read-only Columbus-5 simulate of the two-hop CL8Y → cLUNC → KENA message records `gas_used`, and `ceil(gas_used × 1.2)` is greater than that `gas_used`. The script does not broadcast. - A signed Keplr swap remains a later wallet check (`gas_used < gas_wanted`, Network fee matches the signed gas). This ticket does not block on that step.
PlasticDigits changed title from fix(swap): CL8Y to KENA via cLUNC runs out of gas at 1.91M to fix(swap): CL8Y to KENA out of gas — auto gas 1.2×, per-hop fallback 2026-09-27 12:23:49 +00:00
Author
Owner

Implementation is in #1361.

Acceptance from this issue:

  • 1. Simulated gas_used 1,937,976 signs 2,325,572 uluna. No token-address check.
  • 2. Timeout, non-success HTTP, empty body, 0, -1, and 147,000,000 use the 1,910,000 two-hop fallback. gas_used under 100,000 also falls back.
  • 3. CL8Y → UST1 successful sim is ceil(sim × 1.2), not 3,000,000. Failed sim is 1,910,000. Failed wrap+1hop is 1,800,000.
  • 4. Swap and Trade market Network fee use that same resolver.
  • 5. One simulate and one broadcast per submit. No second broadcast after included code 11.
  • 6. A route-slippage figure above 5% is not shown beside a different 2.63% worst-hop line. Use {amount} instead is hidden during recovering and confirming.
  • 7. The out-of-gas sentence for gasWanted: 1910000, gasUsed: 1937976 does not say “try again” or “vary slightly”.
  • 8. Fixtures and docs do not include a trader address.

Not done in this change: the optional Columbus-5 read-only simulate (VERIFY1360_SIMULATE_ADDRESS) and a signed Keplr swap. The issue already treats the wallet check as later work.

Implementation is in #1361. Acceptance from this issue: - [x] 1. Simulated `gas_used` 1,937,976 signs 2,325,572 `uluna`. No token-address check. - [x] 2. Timeout, non-success HTTP, empty body, `0`, `-1`, and 147,000,000 use the 1,910,000 two-hop fallback. `gas_used` under 100,000 also falls back. - [x] 3. CL8Y → UST1 successful sim is `ceil(sim × 1.2)`, not 3,000,000. Failed sim is 1,910,000. Failed wrap+1hop is 1,800,000. - [x] 4. Swap and Trade market Network fee use that same resolver. - [x] 5. One simulate and one broadcast per submit. No second broadcast after included `code` 11. - [x] 6. A route-slippage figure above 5% is not shown beside a different 2.63% worst-hop line. **Use {amount} instead** is hidden during `recovering` and `confirming`. - [x] 7. The out-of-gas sentence for `gasWanted: 1910000, gasUsed: 1937976` does not say “try again” or “vary slightly”. - [x] 8. Fixtures and docs do not include a trader address. Not done in this change: the optional Columbus-5 read-only simulate (`VERIFY1360_SIMULATE_ADDRESS`) and a signed Keplr swap. The issue already treats the wallet check as later work.
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#1360
No description provided.