diff --git a/apps/extension/src/languages/en.json b/apps/extension/src/languages/en.json index 4d70b929eb..035fe5abab 100644 --- a/apps/extension/src/languages/en.json +++ b/apps/extension/src/languages/en.json @@ -465,6 +465,8 @@ "page.ibc-swap.components.slippage-modal.label.slippage-custom": "Custom Slippage", "page.ibc-swap.components.swap-not-available-modal.title": "Swap Not Available", "page.ibc-swap.components.swap-not-available-modal.paragraph": "Swap is currently not available for this token.", + "page.ibc-swap.usdt-allowance-reset.description": "This swap requires 2 approvals.{br}Let's start by resetting your USDT spending limit to 0.", + "page.ibc-swap.usdt-allowance-reset.button": "Reset Allowance", "page.ibc-swap.button.terms-of-use.title": "Terms of Use", "page.ibc-swap.loading.first": "Finding the best route…", "page.ibc-swap.loading.second": "Checking a few more routes…", diff --git a/apps/extension/src/languages/ko.json b/apps/extension/src/languages/ko.json index 5a2ec710c3..6fb1fd4998 100644 --- a/apps/extension/src/languages/ko.json +++ b/apps/extension/src/languages/ko.json @@ -460,6 +460,8 @@ "page.ibc-swap.components.slippage-modal.label.slippage-custom": "커스텀 설정", "page.ibc-swap.components.swap-not-available-modal.title": "교환 불가능", "page.ibc-swap.components.swap-not-available-modal.paragraph": "현재 이 토큰에 대한 교환이 불가능합니다.", + "page.ibc-swap.usdt-allowance-reset.description": "이 스왑은 2번의 승인이 필요합니다.{br}먼저 USDT 지출 한도를 0으로 초기화합니다.", + "page.ibc-swap.usdt-allowance-reset.button": "지출 한도 초기화", "page.ibc-swap.button.terms-of-use.title": "이용약관", "page.ibc-swap.loading.first": "가장 좋은 경로를 찾고 있어요…", "page.ibc-swap.loading.second": "조금만 더 확인할게요…", diff --git a/apps/extension/src/languages/zh-cn.json b/apps/extension/src/languages/zh-cn.json index a97bb0c365..7f3008085e 100644 --- a/apps/extension/src/languages/zh-cn.json +++ b/apps/extension/src/languages/zh-cn.json @@ -426,6 +426,8 @@ "page.ibc-swap.components.slippage-modal.label.slippage-custom": "自定义滑价", "page.ibc-swap.components.swap-not-available-modal.title": "兑换不可用", "page.ibc-swap.components.swap-not-available-modal.paragraph": "当前无法兑换此代币。", + "page.ibc-swap.usdt-allowance-reset.description": "此兑换需要2次批准。{br}首先将您的USDT支出限额重置为0。", + "page.ibc-swap.usdt-allowance-reset.button": "重置支出限额", "page.ibc-swap.loading.first": "正在寻找最佳路线…", "page.ibc-swap.loading.second": "再帮您确认一下…", diff --git a/apps/extension/src/pages/ibc-swap/components/usdt-allowance-reset/index.tsx b/apps/extension/src/pages/ibc-swap/components/usdt-allowance-reset/index.tsx new file mode 100644 index 0000000000..6d257fc675 --- /dev/null +++ b/apps/extension/src/pages/ibc-swap/components/usdt-allowance-reset/index.tsx @@ -0,0 +1,308 @@ +import React, { FunctionComponent } from "react"; +import { useTheme } from "styled-components"; +import { ColorPalette } from "../../../../styles"; +import { Box } from "../../../../components/box"; +import { Body3 } from "../../../../components/typography"; +import { Gutter } from "../../../../components/gutter"; +import { FormattedMessage } from "react-intl"; +import { YAxis } from "../../../../components/axis"; + +const UsdtAllowanceGraphics: FunctionComponent = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const UsdtAllowanceResetInfo: FunctionComponent = () => { + const theme = useTheme(); + + return ( + + + + + + + + + }} + /> + + + ); +}; diff --git a/apps/extension/src/pages/ibc-swap/hooks/use-query-route-refresh.ts b/apps/extension/src/pages/ibc-swap/hooks/use-query-route-refresh.ts index 6b6e0397a6..9a26cc077c 100644 --- a/apps/extension/src/pages/ibc-swap/hooks/use-query-route-refresh.ts +++ b/apps/extension/src/pages/ibc-swap/hooks/use-query-route-refresh.ts @@ -5,8 +5,11 @@ import { useEffect, useRef } from "react"; export function useQueryRouteRefresh( queryRoute: ObservableQueryRouteInnerV2 | undefined, isSwapExecuting: boolean, - isButtonHolding: boolean + isButtonHolding: boolean, + isApprovalResetRequired?: boolean ) { + const isPaused = + isSwapExecuting || isButtonHolding || !!isApprovalResetRequired; const prevIsSwapLoadingRef = useRef(isSwapExecuting); const prevIsButtonHoldingRef = useRef(isButtonHolding); @@ -16,8 +19,7 @@ export function useQueryRouteRefresh( if ( queryRoute && !queryRoute.isFetching && - !isSwapExecuting && - !isButtonHolding && + !isPaused && queryRoute.response?.timestamp ) { const diff = Date.now() - queryRoute.response.timestamp; @@ -45,12 +47,7 @@ export function useQueryRouteRefresh( (prevIsButtonHolding && !currentIsButtonHolding)) ) { const timeoutId = setTimeout(() => { - if ( - queryRoute && - !queryRoute.isFetching && - !isSwapExecuting && - !isButtonHolding - ) { + if (queryRoute && !queryRoute.isFetching && !isPaused) { queryRoute.fetch(); } }, 3000); @@ -61,18 +58,19 @@ export function useQueryRouteRefresh( prevIsSwapLoadingRef.current = currentIsSwapLoading; prevIsButtonHoldingRef.current = currentIsButtonHolding; - }, [queryRoute, queryRoute?.isFetching, isSwapExecuting, isButtonHolding]); + }, [ + queryRoute, + queryRoute?.isFetching, + isSwapExecuting, + isButtonHolding, + isPaused, + ]); // QueryRouteRefreshInterval 마다 route query 자동 refresh useEffect(() => { - if ( - queryRoute && - !queryRoute.isFetching && - !isSwapExecuting && - !isButtonHolding - ) { + if (queryRoute && !queryRoute.isFetching && !isPaused) { const timeoutId = setTimeout(() => { - if (!queryRoute.isFetching && !isSwapExecuting && !isButtonHolding) { + if (!queryRoute.isFetching && !isPaused) { queryRoute.fetch(); } }, SwapAmountConfig.QueryRouteRefreshInterval); @@ -85,5 +83,5 @@ export function useQueryRouteRefresh( // queryRoute.isFetching는 현재 fetch중인지 아닌지를 알려주는 값이므로 deps에 꼭 넣어야한다. // queryRoute는 input이 같으면 reference가 같으므로 eslint에서 추천하는대로 queryRoute만 deps에 넣으면 // queryRoute.isFetching이 무시되기 때문에 수동으로 넣어줌 - }, [queryRoute, queryRoute?.isFetching, isSwapExecuting, isButtonHolding]); + }, [queryRoute, queryRoute?.isFetching, isPaused]); } diff --git a/apps/extension/src/pages/ibc-swap/hooks/use-usdt-approval-reset.ts b/apps/extension/src/pages/ibc-swap/hooks/use-usdt-approval-reset.ts new file mode 100644 index 0000000000..6e9a625f60 --- /dev/null +++ b/apps/extension/src/pages/ibc-swap/hooks/use-usdt-approval-reset.ts @@ -0,0 +1,236 @@ +import React from "react"; +import { AppCurrency } from "@keplr-wallet/types"; +import { + EthereumAccountStore, + EthereumQueries, + UnsignedEVMTransactionWithErc20Approvals, + isApproveResetRequired, +} from "@keplr-wallet/stores-eth"; +import { SwapAmountConfig } from "@keplr-wallet/hooks-internal"; +import { IFeeConfig, IGasSimulator, ISenderConfig } from "@keplr-wallet/hooks"; +import { Dec } from "@keplr-wallet/unit"; +import { isEVMFeeConfig } from "../../../hooks/fee"; +import { IBCSwapConfig } from "../../../stores/ui-config/ibc-swap"; + +export function useUsdtApprovalReset({ + amountConfig, + senderConfig, + inChainId, + inCurrency, + queriesStore, + ethereumAccountStore, + feeConfig, + gasSimulator, + ibcSwapConfig, + onSignComplete, + onResetSuccess, + onResetFailed, +}: { + amountConfig: SwapAmountConfig; + senderConfig: ISenderConfig; + inChainId: string; + inCurrency: AppCurrency; + queriesStore: { + get(chainId: string): EthereumQueries; + }; + ethereumAccountStore: EthereumAccountStore; + feeConfig: IFeeConfig; + gasSimulator: IGasSimulator; + ibcSwapConfig: IBCSwapConfig; + onSignComplete: () => void; + onResetSuccess: () => void; + onResetFailed: () => void; +}) { + const isApprovalResetPending = ibcSwapConfig.isApprovalResetPending; + const setIsApprovalResetPending = (pending: boolean) => + ibcSwapConfig.setIsApprovalResetPending(pending); + + // Detect USDT from swap inputs (not getTxsIfReady) to avoid flicker + const usdtContractAddress = inCurrency.coinMinimalDenom.startsWith("erc20:") + ? inCurrency.coinMinimalDenom.replace("erc20:", "") + : undefined; + const isInputUsdtRequiringReset = + usdtContractAddress != null && + isApproveResetRequired(inChainId, usdtContractAddress); + + // Cache the spender from tx data — survives getTxsIfReady() returning null during route transitions + const approvalSpenderRef = React.useRef(null); + const txs = amountConfig.getTxsIfReady(); + if (txs && txs.length > 0) { + const tx = txs[0]; + if ("requiredErc20Approvals" in tx) { + const approval = (tx as UnsignedEVMTransactionWithErc20Approvals) + .requiredErc20Approvals?.[0]; + // Clear stale spender when the new quote no longer needs an approval + // (e.g., user lowered amount below existing allowance). + approvalSpenderRef.current = approval?.spender ?? null; + } + } + + const sender = senderConfig.sender; + const allowanceQuery = + isInputUsdtRequiringReset && + sender && + usdtContractAddress && + approvalSpenderRef.current + ? queriesStore + .get(inChainId) + .ethereum.queryERC20Allowance.getAllowance( + usdtContractAddress, + sender, + approvalSpenderRef.current + ) + : null; + + const hasValidAmount = (() => { + try { + const amount = amountConfig.amount[0]?.toCoin().amount; + return amount != null && amount !== "0"; + } catch { + return false; + } + })(); + + const requiresApprovalReset = + hasValidAmount && + isInputUsdtRequiringReset && + (isApprovalResetPending || + (allowanceQuery != null && allowanceQuery.allowance > BigInt(0))); + + // P2 fallback: if pending flag is stuck after tx fulfill callback missed, + // clear it once on-chain allowance confirms the reset succeeded. + React.useEffect(() => { + if ( + isApprovalResetPending && + allowanceQuery != null && + !allowanceQuery.isFetching && + allowanceQuery.allowance === BigInt(0) + ) { + setIsApprovalResetPending(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + isApprovalResetPending, + allowanceQuery?.allowance, + allowanceQuery?.isFetching, + ]); + + const handleResetAllowance = async () => { + const txs = amountConfig.getTxsIfReady(); + if (!txs || txs.length === 0) return; + + const tx = txs[0]; + if (!("requiredErc20Approvals" in tx)) return; + + const approval = (tx as UnsignedEVMTransactionWithErc20Approvals) + .requiredErc20Approvals?.[0]; + if (!approval) return; + + const chainId = `eip155:${tx.chainId!}`; + const ethereumAccount = ethereumAccountStore.getAccount(chainId); + + const isInCurrencyErc20 = + ("type" in inCurrency && inCurrency.type === "erc20") || + inCurrency.coinMinimalDenom.startsWith("erc20:"); + if (!isInCurrencyErc20) return; + + const resetTx = ethereumAccount.makeErc20ApprovalTx( + { + ...inCurrency, + type: "erc20", + contractAddress: inCurrency.coinMinimalDenom.replace("erc20:", ""), + }, + approval.spender, + "0" + ); + + setIsApprovalResetPending(true); + + try { + // Simulate gas for the approve(0) tx + const gasResult = await ethereumAccount.simulateGas(sender, resetTx); + const gasLimit = Math.ceil( + gasResult.gasUsed * gasSimulator.gasAdjustment + ); + + // Build fee object + let feeObject: Record; + if (isEVMFeeConfig(feeConfig)) { + const eip1559Fees = feeConfig.getEIP1559TxFees(feeConfig.type); + if (eip1559Fees.maxFeePerGas && eip1559Fees.maxPriorityFeePerGas) { + feeObject = { + type: 2, + maxFeePerGas: `0x${BigInt( + eip1559Fees.maxFeePerGas.truncate().toString() + ).toString(16)}`, + maxPriorityFeePerGas: `0x${BigInt( + eip1559Fees.maxPriorityFeePerGas.truncate().toString() + ).toString(16)}`, + gasLimit: `0x${gasLimit.toString(16)}`, + }; + } else { + feeObject = { + gasPrice: `0x${BigInt( + (eip1559Fees.gasPrice ?? new Dec(0)).truncate().toString() + ).toString(16)}`, + gasLimit: `0x${gasLimit.toString(16)}`, + }; + } + } else { + feeObject = { + gasLimit: `0x${gasLimit.toString(16)}`, + }; + } + + await ethereumAccount.sendEthereumTx( + sender, + { ...resetTx, ...feeObject }, + { + onBroadcastFailed: (e) => { + setIsApprovalResetPending(false); + onSignComplete(); + // Don't surface failure toast for user-initiated cancellation. + if (e?.message && e.message !== "Request rejected") { + onResetFailed(); + } + }, + onBroadcasted: () => { + onSignComplete(); + }, + onFulfill: (txReceipt) => { + if (txReceipt.status === "0x1") { + if (allowanceQuery) { + allowanceQuery.fetch(); + } + onResetSuccess(); + } else { + onResetFailed(); + } + setIsApprovalResetPending(false); + }, + } + ); + } catch (e: any) { + setIsApprovalResetPending(false); + // sendEthereumTx rethrows after onBroadcastFailed, so failure toast + // (incl. cancellation suppression) is handled in onBroadcastFailed. + // Only the pre-broadcast path (gas sim, tx encoding) reaches here. + if (e?.message !== "Request rejected") { + onResetFailed(); + } + } + }; + + const refetchAllowance = () => { + if (allowanceQuery) { + allowanceQuery.fetch(); + } + }; + + return { + requiresApprovalReset, + isApprovalResetPending, + handleResetAllowance, + refetchAllowance, + }; +} diff --git a/apps/extension/src/pages/ibc-swap/index.tsx b/apps/extension/src/pages/ibc-swap/index.tsx index da0a02099a..abfcf42e40 100644 --- a/apps/extension/src/pages/ibc-swap/index.tsx +++ b/apps/extension/src/pages/ibc-swap/index.tsx @@ -26,6 +26,7 @@ import { } from "@keplr-wallet/hooks"; import { useNotification } from "../../hooks/notification"; import { useQueryRouteRefresh } from "./hooks/use-query-route-refresh"; +import { useUsdtApprovalReset } from "./hooks/use-usdt-approval-reset"; import { useSwapInitParams } from "./hooks/use-swap-init-params"; import { useSwapQueryParams } from "./hooks/use-swap-query-params"; import { FormattedMessage, useIntl } from "react-intl"; @@ -62,6 +63,7 @@ import { useEffectOnce } from "../../hooks/use-effect-once"; import { HoldButton } from "../../components/hold-button"; import { TextButtonProps } from "../../components/button-text"; import { UnsignedEVMTransactionWithErc20Approvals } from "@keplr-wallet/stores-eth"; +import { UsdtAllowanceResetInfo } from "./components/usdt-allowance-reset"; import { InsufficientFeeError } from "@keplr-wallet/hooks"; import { getSwapWarnings } from "./utils/swap-warnings"; import { @@ -384,9 +386,51 @@ export const IBCSwapPage: FunctionComponent = observer(() => { const [isButtonHolding, setIsButtonHolding] = useState(false); + const { + requiresApprovalReset, + isApprovalResetPending, + handleResetAllowance, + refetchAllowance, + } = useUsdtApprovalReset({ + amountConfig: swapConfigs.amountConfig, + senderConfig: swapConfigs.senderConfig, + inChainId, + inCurrency, + queriesStore, + ethereumAccountStore, + feeConfig: swapConfigs.feeConfig, + gasSimulator, + ibcSwapConfig: uiConfigStore.ibcSwapConfig, + onSignComplete: () => navigate(-1), + onResetSuccess: () => { + notification.show( + "success", + intl.formatMessage({ id: "notification.transaction-success" }), + "" + ); + // Refresh route to get updated tx data after allowance reset + const queryRoute = swapConfigs.amountConfig.getQueryRoute(); + if (queryRoute && !queryRoute.isFetching) { + queryRoute.fetch(); + } + }, + onResetFailed: () => { + notification.show( + "failed", + intl.formatMessage({ id: "error.transaction-failed" }), + "" + ); + }, + }); + const queryRoute = swapConfigs.amountConfig.getQueryRoute(); - useQueryRouteRefresh(queryRoute, isSwapExecuting, isButtonHolding); + useQueryRouteRefresh( + queryRoute, + isSwapExecuting, + isButtonHolding, + requiresApprovalReset + ); // ------ 기능상 의미는 없고 이 페이지에서 select asset page로의 전환시 UI flash를 막기 위해서 필요한 값들을 prefetch하는 용도 useEffect(() => { @@ -501,10 +545,15 @@ export const IBCSwapPage: FunctionComponent = observer(() => { const hideStepIndicatorForEvmPendingSimulation = inChainType === "evm" && evmOutcome == null; + const showApprovalResetUI = + requiresApprovalReset && + swapConfigs.amountConfig.uiProperties.error == null; + const holdToSwapEnabled = swapConfigs.amountConfig.isQuoteReady && !swapConfigs.amountConfig.requiresMultipleTxBundles && !isHardwareWallet && + !requiresApprovalReset && (inChainType === "evm" ? isEvmHoldToSwapEnabled : isCosmosHoldToSwapEnabled); @@ -1291,6 +1340,10 @@ export const IBCSwapPage: FunctionComponent = observer(() => { }); } + // Refresh allowance cache — approve(amount) may have changed it, + // so the next swap attempt evaluates reset UI against fresh on-chain state. + refetchAllowance(); + const params: Record< string, number | string | boolean | number[] | string[] | undefined @@ -1399,6 +1452,10 @@ export const IBCSwapPage: FunctionComponent = observer(() => { }); } + // Refresh allowance cache — approve(amount) may have succeeded before + // swap failure, leaving non-zero allowance that the next attempt must detect. + refetchAllowance(); + // in case of error, navigate back to the previous page if any signing was attempted if (signatureNavigationCount > 0) { navigate(-1); @@ -1584,7 +1641,9 @@ export const IBCSwapPage: FunctionComponent = observer(() => { }} /> - {inChainType === "evm" ? ( + {showApprovalResetUI ? ( + + ) : inChainType === "evm" ? ( { swapConfigs.amountConfig.isFetchingOutAmount || gasSimulator.isSimulating || isSwapExecuting || - hideStepIndicatorForEvmPendingSimulation + hideStepIndicatorForEvmPendingSimulation || + showApprovalResetUI } > { - {holdToSwapEnabled ? ( + {showApprovalResetUI ? ( +