fix(swap): CL8Y to KENA out of gas — auto gas 1.2×, per-hop fallback #1360
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:implement
agent:implement
agent:open_issues
agent:ready
agent:research
agent:security_audit
agent:verify
architecture
backend
blocker:hybrid
blocker:launch
blocker:limit-orders
blocker:v2
block:log_only
block:security
bug
ci
contracts
correctness
deploy
dev
devops
docs
documentation
duplicate
e2e
enhancement
epic
feature
frontend
functional-completion
gas
good first issue
governance
help wanted
high-risk
hooks
hybrid
indexer
infra
infrastructure
integrators
invalid
launch-blocker
limit-orders
localnet
localterra
low priority
missing-implementation
needs-design
ops
performance
priority
high
priority
medium
product
qa
QA
question
ready
ready
research
scripts
security
security-hardening
smartcontracts
tech-debt
testing
ux
UX
v2
verification
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-dex-terraclassic#1360
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
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 staticgas_wantedof 1,910,000. The transaction is included and failscode11 (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_wanted1,800,000,gas_used1,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_wantedto 1.2× the simulatedgas_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.
estimateTerraClassicFeeForEntriesinfrontend-dapp/src/services/terraclassic/terraClassicFeeEstimate.tsmultiplies a static limit fromgetGasLimitForTx(frontend-dapp/src/services/terraclassic/terraGas.ts) byeffectiveGasPriceUluna()(28.325 uluna per gas).broadcastTerraExecuteContractssigns thatFee.For router
execute_swap_operationsthe global per-hop math isgasLimitForRouterExecuteSwapOperations:ROUTER_SINGLE_HOP_GAS_LIMIT= 1,400,000max(scaled hop math, hops × ROUTER_SWAP_OPS_MIN_GAS_PER_HOP) + SWAP_GAS_SAFETY_MARGINROUTER_SWAP_OPS_MIN_GAS_PER_HOPis 950,000 and the safety margin is 10,000, so two hops is 1,910,000SWAP_GAS_BUFFER(1.3) is the multiplier inside that static formula. It is not an LCD auto-gas adjustment. CLI scripts useterrad --gas-adjustment 1.3the same way. This ticket’s auto-gas multiplier is 1.2, applied only to a successful simulategas_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 atfrontend-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
sendof CL8Y (terra16wtml2q66g82fdkx66tap0qjkahqwp4lwq3ngtygacg5q0kzycgqvhpax3, 18 decimals) into the router (terra1e7s0h9ftxakwca5gxspyt4haeuaqxds6swr08ul3tsepq7el924sprrsrw) withexecute_swap_operations,max_spread0.05, and nohybrid/greedyfield:gas_wantedgas_usedcode82911164…2A2EA5BAFA88…8E31FE8D4C8A…C9D227AC18AC…049DHop 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…43BDat 2026-09-27T12:03:09Z,code0:wrap_depositof 200 LUNC, then one pool hop of 196 cLUNC → KENA.gas_used1,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_impactkeys off route-level expected slippage above 5% (swapPayAcquireGuidance.ts). Worst-hop spread is a different number, printed outside collapsed Trade details.acquireGuidanceBlocksSubmitdoes not includehigh_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 beginningD47E825Eand ending632CA0, which is not one of the five included transactions in this table. Oncecode !== 0,humanizeTerraTxError.tsmapsout of gasto 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
Fee.gasisceil(simulated_gas_used × 1.2)from one LCDPOST /cosmos/tx/v1beta1/simulateof the same messages, contract addresses, funds, and signer that will be signed.gasLimitForRouterExecuteSwapOperations/getGasLimitForTxfor the non-pair path, andtotalGasLimitForExecuteMsgsfor multi-message txs such as wrap + hop) runs only after auto gas fails.gas_used, orgas_usedis 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 consensusmax_gasis lower. A result above the cap selects the per-hop fallback. It is not multiplied by 1.2 and signed.cl8yUst1PoolOnlyTwoHopGasLimitand 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-1328expectations move to: successful sim uses 1.2×; failed sim uses the global per-hop result (1,910,000 for two hops), including CL8Y → UST1.Fee.code11.uluna.Fee.amountisgas_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.preferNoSetFeestays on so the wallet does not replace this fee with its own simulate.max_spreadandminimum_receivestay as they are on the signed swap.recoveringorconfirming, hide Use {amount} instead. That control edits the pay field; it must not broadcast.out of gassentence that contains bothgasWantedandgasUsed, with used greater than wanted by more than 1,000, does not say “try again” or “vary slightly”. Other errors keep their current copy.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 beforebuildTerraClassicFeefrontend-dapp/src/services/terraclassic/terraClassicFeeEstimate.ts— one estimate helper for broadcast and the Network fee rowfrontend-dapp/src/services/terraclassic/terraGas.ts— global per-hop fallback; removecl8yUst1PoolOnlyTwoHopGasLimitfrom the fee decisionfrontend-dapp/src/utils/constants.ts—ROUTER_SWAP_OPS_MIN_GAS_PER_HOP,HYBRID_SWAP_GAS_LIMITas the sim cap, newAUTO_GAS_ADJUSTMENT = 1.2frontend-dapp/scripts/measureIssue1328Columbus.mjs— existing simulate request shape to mirror, not a pair-specific broadcast limitfrontend-dapp/src/pages/SwapPage.tsxandfrontend-dapp/src/components/trade/TradeMarketOrderPanel.tsx— Network fee, acquire banner, recovery phasefrontend-dapp/src/utils/swapPayAcquireGuidance.ts,SwapPayAcquireGuidanceBanner.tsx,swapRouteSlippage.tsfrontend-dapp/src/utils/humanizeTerraTxError.tsskills/AGENTS_TERRACLASSIC_GAS.md,docs/adr/0013-cl8y-ust1-gas-floor.md— record that pair floors are not the broadcast pathterraGas.issue1328.test.ts, broadcast tests,swapPayAcquireGuidance.test.ts, humanizer testsRecommended direction
Add a small helper, used by broadcast and by the fee hint:
POST /cosmos/tx/v1beta1/simulateonce, under a timeout.gas_usedis a positive integer andgas_used ≤ 15_000_000, setgas_wanted = ceil(gas_used × 1.2)and the uluna fee fromeffectiveGasPriceUluna().gas_wantedfrom the existing global per-hop / per-message helpers only.Do not
max()a successful 1.2× result withcl8yUst1PoolOnlyTwoHopGasLimitor with a KENA constant. Remove that predicate fromgasLimitForSwapOperationsMsg. LeaveROUTER_SWAP_OPS_MIN_GAS_PER_HOPat 950,000 in this change.For the banner, compare and display one percent. Hide the reduce control during
recoveringandconfirming. Point the deterministic out-of-gas humanizer at one sentence that the estimate was short, with no raw-log interpolation.Acceptance criteria
gas_used1,937,976 produces signedgas_wanted2,325,572 and fee denomuluna. The same multiplier applies to the 0.05 CL8Y and ~0.54 CL8Y messages. No KENA or CL8Y address check participates.gas_used,gas_used0, andgas_used147,000,000 each produce the global two-hop fallback 1,910,000 and do not signceil(147_000_000 × 1.2).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.broadcastTerraExecuteContractswill sign, after the simulate result or the fallback is known.code11 does not broadcast again.recoveringorconfirming, and its handler does not broadcast.out of gasforgasWanted: 1910000, gasUsed: 1937976does not contain “try again” or “vary slightly”.Test plan
gas_used1,937,976 → wanted 2,325,572. 1,937,775 →ceil(× 1.2). A 1-hopgas_usedof 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.gas_used"0",gas_used"-1",gas_used147000000. Each yieldsgasLimitForRouterExecuteSwapOperations(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.gas_used15,000,000 succeeds and wantsceil(15_000_000 × 1.2)= 18,000,000.gas_used15,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.)uluna.broadcastTxis 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.insufficient fees, deadline, code 32) stay as they are.make verify-issue-1328matches the new rule.Attack, hack, and abuse
1.2 × 147e6(~4,990 LUNC). Test the numeric boundary.gas_used1 is a positive integer under the cap, so the rule signsceil(1.2)= 2. That can still include as out of gas and burn a tiny fee. Treatgas_usedbelow 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.preferNoSetFeeis 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.Fee.gasunlessceil(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.max_spread0.05 and a positiveminimum_receive. The gas helper does not drop those fields.gas_used.tokenRegistry.tsand syntheticgas_usedvalues. They do not embed the sender from the hashes above.Verification
make verify-issue-1328passes under the updated expectations (no pair floor on the fee path).gas_used, andceil(gas_used × 1.2)is greater than thatgas_used. The script does not broadcast.gas_used < gas_wanted, Network fee matches the signed gas). This ticket does not block on that step.fix(swap): CL8Y to KENA via cLUNC runs out of gas at 1.91Mto fix(swap): CL8Y to KENA out of gas — auto gas 1.2×, per-hop fallbackImplementation is in #1361.
Acceptance from this issue:
gas_used1,937,976 signs 2,325,572uluna. No token-address check.0,-1, and 147,000,000 use the 1,910,000 two-hop fallback.gas_usedunder 100,000 also falls back.ceil(sim × 1.2), not 3,000,000. Failed sim is 1,910,000. Failed wrap+1hop is 1,800,000.code11.recoveringandconfirming.gasWanted: 1910000, gasUsed: 1937976does not say “try again” or “vary slightly”.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.