Skip to content
2 changes: 2 additions & 0 deletions apps/extension/src/languages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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…",
Expand Down
2 changes: 2 additions & 0 deletions apps/extension/src/languages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "조금만 더 확인할게요…",
Expand Down
2 changes: 2 additions & 0 deletions apps/extension/src/languages/zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "再帮您确认一下…",

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -16,8 +19,7 @@ export function useQueryRouteRefresh(
if (
queryRoute &&
!queryRoute.isFetching &&
!isSwapExecuting &&
!isButtonHolding &&
!isPaused &&
queryRoute.response?.timestamp
) {
const diff = Date.now() - queryRoute.response.timestamp;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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]);
}
227 changes: 227 additions & 0 deletions apps/extension/src/pages/ibc-swap/hooks/use-usdt-approval-reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
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<string | null>(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];
if (approval) {
approvalSpenderRef.current = approval.spender;
}
Comment thread
piatoss3612 marked this conversation as resolved.
Outdated
}
}

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)));
Comment thread
piatoss3612 marked this conversation as resolved.
Comment thread
piatoss3612 marked this conversation as resolved.

// 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<string, unknown>;
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: () => {
setIsApprovalResetPending(false);
onSignComplete();
},
onBroadcasted: () => {
onSignComplete();
},
Comment thread
piatoss3612 marked this conversation as resolved.
onFulfill: (txReceipt) => {
Comment thread
piatoss3612 marked this conversation as resolved.
if (txReceipt.status === "0x1") {
if (allowanceQuery) {
allowanceQuery.fetch();
}
onResetSuccess();
} else {
onResetFailed();
}
setIsApprovalResetPending(false);
},
}
);
} catch {
setIsApprovalResetPending(false);
Comment thread
piatoss3612 marked this conversation as resolved.
Outdated
onResetFailed();
Comment thread
piatoss3612 marked this conversation as resolved.
Outdated
}
};

const refetchAllowance = () => {
if (allowanceQuery) {
allowanceQuery.fetch();
}
};

return {
requiresApprovalReset,
isApprovalResetPending,
handleResetAllowance,
refetchAllowance,
};
}
Loading
Loading