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 ? (
+
+ ) : holdToSwapEnabled ? (
= new Map();
+ // USDT approve(0) reset pending state — persists across page navigation
+ @observable
+ protected _isApprovalResetPending: boolean = false;
+
// multi tx swap signature progress state
@observable
protected _totalSignatureCount: number | undefined = undefined;
@@ -329,4 +333,13 @@ export class IBCSwapConfig {
this._completedSignatureCount = undefined;
this._showSignatureProgress = false;
}
+
+ get isApprovalResetPending(): boolean {
+ return this._isApprovalResetPending;
+ }
+
+ @action
+ setIsApprovalResetPending(pending: boolean) {
+ this._isApprovalResetPending = pending;
+ }
}
diff --git a/packages/stores-eth/src/constants.ts b/packages/stores-eth/src/constants.ts
index 888847996b..d9aabf9320 100644
--- a/packages/stores-eth/src/constants.ts
+++ b/packages/stores-eth/src/constants.ts
@@ -270,3 +270,16 @@ export const erc20ContractInterface: Interface = new Interface([
type: "function",
},
]);
+
+export const USDT_APPROVE_RESET_REQUIRED: Record = {
+ "eip155:1": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
+};
+
+export function isApproveResetRequired(
+ chainId: string,
+ tokenAddress: string
+): boolean {
+ const addr = USDT_APPROVE_RESET_REQUIRED[chainId];
+ if (!addr) return false;
+ return addr.toLowerCase() === tokenAddress.toLowerCase();
+}
diff --git a/packages/stores-eth/src/queries/erc20-allowance.ts b/packages/stores-eth/src/queries/erc20-allowance.ts
new file mode 100644
index 0000000000..53e9f86e15
--- /dev/null
+++ b/packages/stores-eth/src/queries/erc20-allowance.ts
@@ -0,0 +1,80 @@
+import { ChainGetter, QuerySharedContext } from "@keplr-wallet/stores";
+import { computed, makeObservable } from "mobx";
+import bigInteger from "big-integer";
+import { erc20ContractInterface } from "../constants";
+import { ObservableEvmChainJsonRpcQuery } from "./evm-chain-json-rpc";
+
+export class ObservableQueryERC20Allowance extends ObservableEvmChainJsonRpcQuery {
+ constructor(
+ sharedContext: QuerySharedContext,
+ chainId: string,
+ chainGetter: ChainGetter,
+ protected readonly contractAddress: string,
+ protected readonly owner: string,
+ protected readonly spender: string
+ ) {
+ super(sharedContext, chainId, chainGetter, "eth_call", [
+ {
+ to: contractAddress,
+ data: erc20ContractInterface.encodeFunctionData("allowance", [
+ owner,
+ spender,
+ ]),
+ },
+ "latest",
+ ]);
+
+ makeObservable(this);
+ }
+
+ @computed
+ get allowance(): bigint {
+ if (!this.response || !this.response.data) {
+ return BigInt(0);
+ }
+
+ try {
+ return BigInt(
+ bigInteger(this.response.data.replace("0x", ""), 16).toString()
+ );
+ } catch {
+ return BigInt(0);
+ }
+ }
+}
+
+export class ObservableQueryERC20AllowanceMap {
+ protected readonly queries: Map =
+ new Map();
+
+ constructor(
+ protected readonly sharedContext: QuerySharedContext,
+ protected readonly chainId: string,
+ protected readonly chainGetter: ChainGetter
+ ) {}
+
+ getAllowance(
+ contractAddress: string,
+ owner: string,
+ spender: string
+ ): ObservableQueryERC20Allowance {
+ const key = `${contractAddress}:${owner}:${spender}`.toLowerCase();
+
+ if (!this.queries.has(key)) {
+ this.queries.set(
+ key,
+ new ObservableQueryERC20Allowance(
+ this.sharedContext,
+ this.chainId,
+ this.chainGetter,
+ contractAddress,
+ owner,
+ spender
+ )
+ );
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return this.queries.get(key)!;
+ }
+}
diff --git a/packages/stores-eth/src/queries/index.ts b/packages/stores-eth/src/queries/index.ts
index 7427220f5f..0047788521 100644
--- a/packages/stores-eth/src/queries/index.ts
+++ b/packages/stores-eth/src/queries/index.ts
@@ -15,6 +15,7 @@ import { ObservableQueryCoingeckoTokenInfo } from "./coingecko-token-info";
import { ObservableQueryEthereumERC20BalanceRegistry } from "./erc20-balance";
import { ObservableQueryEthereumGasPrice } from "./gas-price";
import { ObservableQueryEthereumTxReceipt } from "./tx-receipt";
+import { ObservableQueryERC20AllowanceMap } from "./erc20-allowance";
export interface EthereumQueries {
ethereum: EthereumQueriesImpl;
@@ -66,6 +67,7 @@ export class EthereumQueriesImpl {
public readonly queryEthereumCoingeckoTokenInfo: DeepReadonly;
public readonly queryEthereumGasPrice: DeepReadonly;
public readonly queryEthereumTxReceipt: DeepReadonly;
+ public readonly queryERC20Allowance: DeepReadonly;
constructor(
base: QueriesSetBase,
@@ -145,5 +147,11 @@ export class EthereumQueriesImpl {
chainId,
chainGetter
);
+
+ this.queryERC20Allowance = new ObservableQueryERC20AllowanceMap(
+ sharedContext,
+ chainId,
+ chainGetter
+ );
}
}