bug: LUNC↔USTR wrap+multihop out of gas; disclose LUNC network fee on Swap #587
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#587
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 report (2026-08-21): swapping LUNC → USTR on Swap failed with the humanized out-of-gas toast (“Transaction needed more gas than estimated. Try again — gas usage can vary slightly between blocks.”). The trader reserved ~1000 LUNC for fees, held 0 USTC, and only succeeded by doing separate txs: LUNC → cLUNC → UST1 → USTR. They asked (1) where the gas fee comes from (LUNC vs USTC) and (2) to fix direct LUNC ↔ USTR (and reverse) so hop-by-hop is not required.
Bundle into one issue. Do not split “raise wrap+2hop gas” vs “show LUNC network fee” vs “E2E hub path” vs “FAQ/docs”. Those are the same user journey.
Related: #353 (closed: wrap + single-hop router underpriced as direct-pair), #343 (
unwrap_outputadd-on), #213 (Max reserve), #475 (retail gas inventory), #115 / #127 (fee envelope), #507 (wrap UX), #489 (short retail copy), #429 (mainnet extension fee-guard risk acceptance).Current codebase
A single-tx native LUNC → USTR path already exists. It is wrap + client-BFS router hops, not three user-signed txs. The report is that this path OOGs (or is unreliable) on the hub graph, so traders fall back to manual hops. Network fees are always bank
uluna(LUNC) — never USTC /uusd. ~1000 LUNC reserved is far above the fee amount; the toast is a gas limit miss (gasUsed > gasWanted), not “not enough LUNC to pay the fee.”buildTerraClassicFeesetsFee.amounttoulunaonly ateffectiveGasPriceUluna()(floor 28.325). USTC is never a gas denom in this dApp.findRouteWithNativeSupportsubstitutesuluna→cLUNC /uusd→cUSTC, then BFS (findRoute, max 4 hops). SetsneedsWrapInput/needsUnwrapOutput. Hub graph: cLUNC → UST1 → USTR = 2 hops.SwapPagenativeRouteInfobranch callssimulateNativeSwap(wrap-fee net then walletsimulate_swap_operations). Does not call indexerGET /route/solve(quoteCw20ViaRouteSolveis CW20↔CW20 only). Hops are pool-only (hybridomitted).swapMutation:isWrapOrUnwrap || nativeRouteInfo→executeNativeSwap. Native input: oneexecuteTerraContractMulti— msg1treasury.wrap_deposit(gross native), msg2 cLUNCsend→ routerexecute_swap_operations(post–wrap-fee amount). Native output:unwrap_output: true(+UNWRAP_GAS_LIMIT800k, #343).broadcastTerraExecuteContractsusesestimateTerraClassicFeeForEntries= sum ofgetGasLimitForTxper msg. Wrap =WRAP_GAS_LIMIT400k (~301k measured, #353). Router N-hop =gasLimitForRouterExecuteSwapOperations: 1-hop 1.4M; N≥2 floorROUTER_SWAP_OPS_MIN_GAS_PER_HOP950k + 10k margin (#353: 900k/hop sat ongasUsed1,810,064 vs wanted 1,810,000).wrap-swap.spec.tscovers LUNC→EMBER, LUNC→USTC, LUNC↔cLUNC — not LUNC↔USTR (hub 2-hop).terraGasRetailInventory.tshas wrap_deposit and send→2hop separately, not the combined multi-msg envelope.docs/frontend.mdwhile code is 950k.fee_bps, not network gas.TerraClassicTxFeeHintexists (“Network fee (est.): ~X LUNC”) but is not mounted on Swap (only its own Vitest). Max already subtractsestimateNativeSwapUlunaFeesTotal(hopCountfromnativeRouteInfo.operations.length). No Swap submit gate equivalent to #132/#147 when bank LUNC < fee (Max is the only reserve).humanizeTerraTxErrormaps/out of gas/ito the screenshot string. Retrying the same constant limit cannot succeed (#353).GET /route/solveis CW20 addresses. Nativeulunais out of scope for this issue (follow-up only if the wrap+BFS path still cannot quote after gas is fixed).Why this is needed
Native LUNC is the retail gas token and the primary on-ramp. Hub destination USTR is two CW20 hops after wrap. If the composed tx OOGs, users burn the failed-tx fee, conclude “direct swap is broken,” and burn three successful fees instead. The USTC question is a product bug: Swap never says the network fee is LUNC, and the AMM Fee row looks like it might be the gas line.
Constraints / guardrails
uluna. Do not add USTC /uusd/ multi-denom fees. Terra Classic dApp fee math is LUNC-only (MIN_GAS_PRICE_ULUNA28.325).AGENTS_TERRACLASSIC_GAS). Wallet extensions rewrite gas; dApp supplies explicitFee. If LCD sim is used for tuning, new constants must still exceed measuredgasUsedwith margin.gasUsedfrom a failed tx is known, new defaults must exceed it with wasm/storage headroom (Classic variance is not tight). Same rule as #353 / #115.packages/localnet-trading-swarm/src/gas.tswhen changingROUTER_*/SWAP_GAS_*/ wrap combo floors.terraGasRetailInventory.ts+getGasLimitForTx/estimateTerraClassicFeeForEntries. Do not fall through toBASE_GAS_LIMIT(200k) or legacySWAP_GAS_LIMIT(600k) ifsend.msgatobfails — a parse miss on wrap+2hop would grant 400k+600k = 1.0M and OOG deterministically.estimateNativeSwapUlunaFeesTotal/ Max on native LUNC pay must use the same envelope as broadcast (including unwrap add-on). No magic uluna constants in UI.~X LUNC(≤ ~5 word label). Do not showuluna/gas × priceon the primary Swap card (currentTerraClassicTxFeeHintparenthetical is too technical for default chrome — compact or Swap-specific). Optional internals only inside Trade details<details>.fee_bpsto “Network fee.” Two different things.book_input > 0to wrap+multihop in this issue — that would jump towardHYBRID_SWAP_GAS_LIMIT(15M) per hop (#249 / #260) and blow retail fees.wrap_depositcoins; CW20 send = post–fee_wrap_bpsmint (#512 / #516).minimum_receiveon unwrap = post-fee pre-tax (R3).route/solvefor nativeuluna/uusd. Changing wrap-mapper bps. Station-on-LocalTerra fee QA (#235). New LUNC/USTR factory pair (routing already has cLUNC–UST1–USTR).Relevant files
frontend-dapp/src/utils/constants.tsWRAP_GAS_LIMIT,UNWRAP_GAS_LIMIT,ROUTER_*,SWAP_GAS_*,MIN_GAS_PRICE_ULUNAfrontend-dapp/src/services/terraclassic/terraGas.tsgetGasLimitForTx,gasLimitForRouterExecuteSwapOperations,totalGasLimitForExecuteMsgsfrontend-dapp/src/services/terraclassic/terraClassicFeeEstimate.tsfrontend-dapp/src/services/terraclassic/terraBroadcast.tsFee.gasLimitfrom that estimatefrontend-dapp/src/services/terraclassic/transactions.tsestimateNativeSwapUlunaFeesTotalfrontend-dapp/src/services/terraclassic/router.tsfindRouteWithNativeSupport,executeNativeSwap,simulateNativeSwapfrontend-dapp/src/pages/SwapPage.tsxfrontend-dapp/src/components/common/TerraClassicTxFeeHint.tsxfrontend-dapp/src/utils/humanizeTerraTxError.tsfrontend-dapp/src/services/terraclassic/terraGasRetailInventory.tsfrontend-dapp/src/services/terraclassic/__tests__/transactions.test.tsfrontend-dapp/src/utils/__tests__/maxSpendableAmount.test.tsfrontend-dapp/e2e/wrap-swap.spec.tspackages/localnet-trading-swarm/src/gas.tsdocs/frontend.mdskills/AGENTS_TERRACLASSIC_GAS.mdNATIVE_TOKEN_WRAPPING.mdAGENTS.mdmake verify-issue-<iid>Recommended direction
gas_usedfor the combined two-msg tx — not wrap alone + hop alone. If LocalTerra seed lacks UST1/USTR, use the shortest ≥2 hop wrap+router path that exists and document the stand-in. Prefer a failed or successful columbus-5 hash if the reporter can share one (gasWanted/gasUsed).executeNativeSwap(wrap + router ops). Raise whatever is short:totalGasLimitForExecuteMsgs/ wrap+router entries, and/orROUTER_SWAP_OPS_MIN_GAS_PER_HOP/ wrap+N-hop floor so wrap+2hop (and wrap+2hop+unwrap) exceed measuredgasUsedwith the same margin discipline as #353.Do not “fix” by telling users to hop manually.
send→execute_swap_operations(2–4 ops, optionalunwrap_output) never prices the send leg as 600k/BASE. Ifatobfails, fail closed (throw / refuse broadcast) rather than under-gas.estimateTerraClassicFeeForEntries/estimateNativeSwapUlunaFeesTotalas submit (testid e.g.swap-network-fee). CW20-only swaps too (they also pay LUNC). Label answers “LUNC, not USTC.”uluna). USTC is not used for gas. Prefer the live Swap row over a new incident-FAQ section (user-incident-faq.mdis pause/blacklist, not gas school).e2e-tx(1 worker): LUNC→USTR (or documented stand-in) one submit, success, no OOG toast; reverse USTR→LUNC with unwrap. Keep existing wrap-swap cases.Acceptance criteria
unwrap_output+ unwrap gas add-on).gasWanted> measuredgasUsedwith documented margin (not sitting on the ceiling like #353’s 1,810,000).uluna/ LUNC only. 0 USTC does not block a LUNC-funded swap.uluna/gas×on the primary card.transactions.test.tscover wrap+2hop and wrap+2hop+unwrap combined limits.docs/frontend.md+AGENTS_TERRACLASSIC_GAS.md+make verify-issue-<iid>.Test plan (all paths)
Unit (Vitest)
buildTerraClassicFeeamount denom isulunafor wrap, wrap+2hop, unwrap_output, CW20 2hop.WRAP_GAS_LIMIT + ROUTER_SINGLE_HOP_GAS_LIMIT(1.8M today); no regression vs #353.gasLimitForRouterExecuteSwapOperations(2)(+ any new overhead); > 2,310,000 if constants rise; never 1.0M (400k+600k fallback).UNWRAP_GAS_LIMIT; wrap+2hop+unwrap > wrap+2hop.send.msgdoes not silently grant 600k for a router swap (throw or explicit high fallback — pick one and test).estimateNativeSwapUlunaFeesTotal—hopCount: 2,needsWrapInput: truematchesestimateTerraClassicFeeForEntriesof the executeNativeSwap msg pair;needsUnwrapOutput: truematches.RETAIL_GAS_SHAPE_FIXTURESor an adjacent combined-envelope table;make verify-issue-475still green.swap-network-feeshowsLUNCand notUSTC; AMMswap-fee-rowstill present.humanizeTerraTxError.test.ts).ROUTER_SWAP_OPS_MIN_GAS_PER_HOPchanges, swarmgas.tsmatches (or a drift test fails).E2E (
e2e-tx, 1 worker; LocalTerra)assertTxResultAlertsuccess; no OOG toast.swap-network-feecontainsLUNC).uusdbalance for LUNC→USTR.Manual / mainnet (if hash available)
gasWanted/gasUsedfor LUNC→USTR wrap+2hop. New limit must exceedgasUsed. Attach hash on the issue when implementing.Test plan (attack, hack, abuse)
executeNativeSwapmust not accept attackerhybrid/book_inputfrom query string or indexer on this path. Ops come from client BFSfindRouteonly.hopCountfor Max/fee hint must equaloperations.lengthactually submitted. A crafted shorter hint must not produce a smallerFee.gasthan execute.send.msgtamper / decode fail — refuse undersized fallback rather than broadcasting 600k against a real router payload.gas_wanted(#429). Do not treat that as “raise constants to 15M.” LocalTerra post-sign guard remains LocalTerra-only.Fee.amount(spend + fee). Prefer blocking CTA over OOG/insufficient-fee after sign.wrap_depositcoins are the swap amount; fee is a separateFee.amountfrom remaining bank LUNC. Do not wrap the fee reserve into the deposit (would OOG or fail bank).max_spread/minimum_receive. OOG fix is not a license to widen default slippage.LUNC; no rawerror.message/ denom interpolation from LCD.gasWantedwith amount). Fee is paid even if swap is dust — same as today; document, do not add a hidden min that looks like a pause.Verification criteria
Manual on
/: LUNC→USTR shows RouteLUNC → cLUNC → UST1 → USTR(or equivalent), Network fee ~N LUNC, 0 USTC in wallet, one Swap, success. Reverse USTR→LUNC one tx. Hop-by-hop must be unnecessary.Done when ACs 1–10 pass, verify script is green, and wrap+multihop + LUNC fee denom are in
docs/frontend.md+AGENTS_TERRACLASSIC_GAS.md.marked as related to #353
marked as related to #343
marked as related to #475
marked as related to #213
marked as related to #507
Follow-up from codebase / issue search
No duplicate open issue for wrap + multihop LUNC↔USTR or gas-fee denom UX. Closest closed sibling remains #353 (wrap + single-hop router only).
Indexer confirmation:
GET /route/solveis CW20terra1only — nativeuluna/uusdstay onexecuteNativeSwap(client BFS, pool-only). Still out of scope here; do not add “wrap first for best execution” copy (#489).Implementer notes not to miss:
gas_wanteduses realoperations.length. Max/reserve can briefly defaulthopCountto 1 while pairs are still loading (hints.hopCount ?? 1) — under-reserves ~13 LUNC vs a 2-hop wrap. Broadcast is unaffected; fix the hint once the route is known (and passneedsUnwrapOutputfor USTR→LUNC).payRaw + estimateNativeSwapUlunaFeesTotal(...)so a non-Max amount cannot sign then fail fee. Not a substitute for raising the wrap+2hop envelope.mentioned in merge request !1113
Implementation in !1113.
WRAP_ROUTER_COMBO_OVERHEAD_GAS) → wrap+2hop 2,710,000 (~76.76 LUNC). Wrap+1hop stays 1.8M.send.msgthrowsSendHookGasDecodeError(no 600k fallback).swap-network-fee). AMM Fee row unchanged.make verify-issue-587green (unit + docs, retested). Playwright E7/E8 added; needs full LocalTerra + indexer.mentioned in commit
004929d222Post-merge note (!1113 on
main)Merged: https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/merge_requests/395
Sanity:
make verify-issue-587green (207 Vitest + docs, retested). Wrap+2hop envelope is 2,710,000 gas (~76.76 LUNC) viaWRAP_ROUTER_COMBO_OVERHEAD_GAS400k. Malformedsend.msgthrowsSendHookGasDecodeError(no 600k fallback). Swap shows Network fee (est.) ~X LUNC; AMM Fee row unchanged.Still open vs AC:
e2e/wrap-swap.spec.tsE7/E8 (e2e-tx) need full LocalTerra + indexer — not inverify-issue-587. Confirmgas_used < gas_wantedfor LUNC→USTR and USTR→LUNC on hub pairs.hopCountdefaults to 2 until the route is known (conservative over-reserve vs wrap+1hop).sendwithout innermsgstill prices as 600k; only bad base64 is fail-closed.gas_wanted(#429) — out of scope here.Do not default wrap+multihop to 15M hybrid.
mentioned in issue #590
marked as related to #590
Follow-up tracker: #590 (Playwright e2e-tx E7/E8).
mentioned in issue #595
mentioned in issue #599
marked as related to #599
Follow-up (do not reopen): retail USTR → USTC still OOGs after the #587 wrap+≥2hop bake. LUNC→USTR and USTC→USTR succeed. New ticket #599 covers unwrap_output + 2 hub hops into taxed
uusd(no wrap combo). Native path stays pool-only (H596-7).mentioned in merge request !1118
mentioned in issue #600
mentioned in issue #679
marked as related to #679
mentioned in issue #681
marked as related to #681
mentioned in merge request !1182
mentioned in issue #690