@turtleclub/opportunities 0.1.0-beta.20 → 0.1.0-beta.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,122 +1,126 @@
1
- import { useState, useEffect, useCallback } from "react";
2
- import { parseEther, formatEther } from "viem";
3
- import type { NexusQuoteResult, CoverQuoteState } from "../types";
4
- import { CoverAsset, NexusSDK } from "@nexusmutual/sdk";
1
+ import { parseUnits, formatUnits } from "viem";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { CoverAsset, GetQuoteResponse } from "@nexusmutual/sdk";
4
+ import { TOKEN_SYMBOL_TO_COVER_ASSET, getOpportunityAPY, calculateAdjustedAPY } from "../utils";
5
+ import type { Opportunity } from "@turtleclub/hooks";
5
6
 
6
7
  interface UseCoverQuoteOptions {
7
8
  productId: number;
8
9
  buyerAddress: string;
9
10
  amountToCover: string;
10
- /** Debounce delay in ms */
11
- debounceMs?: number;
11
+ coverPeriod: number;
12
+ tokenSymbol: string;
13
+ decimals: number | undefined;
14
+ opportunity: Opportunity;
12
15
  }
13
16
 
14
- interface UseCoverQuoteReturn extends CoverQuoteState {
15
- setCoverPeriod: (period: number) => void;
17
+ interface UseCoverQuoteReturn {
18
+ isLoading: boolean;
19
+ error: string | null;
20
+ quoteResult: GetQuoteResponse | null;
21
+ premium: string | null;
22
+ originalAPY: number;
23
+ adjustedAPY: number | null;
24
+ coverCostPerc: number | null;
16
25
  refetch: () => void;
17
26
  }
18
27
 
19
28
  let nexusSdkInstance: import("@nexusmutual/sdk").NexusSDK | null = null;
20
- let coverAssetEnum: typeof import("@nexusmutual/sdk").CoverAsset | null = null;
21
29
 
22
30
  const TERMS_IPFS_CID = "QmXUzXDMbeKSCewUie34vPD7mCAGnshi4ULRy4h7DLmoRS";
23
31
 
24
32
  async function getNexusSdk() {
25
- if (!nexusSdkInstance || !coverAssetEnum) {
26
- const { NexusSDK, CoverAsset } = await import("@nexusmutual/sdk");
33
+ if (!nexusSdkInstance) {
34
+ const { NexusSDK } = await import("@nexusmutual/sdk");
27
35
  nexusSdkInstance = new NexusSDK();
28
- coverAssetEnum = CoverAsset;
29
36
  }
30
-
31
- return { nexusSdk: nexusSdkInstance, CoverAsset: coverAssetEnum };
37
+ return nexusSdkInstance;
32
38
  }
33
39
 
34
-
35
40
  async function fetchNexusQuote(
36
41
  productId: number,
37
42
  buyerAddress: string,
38
- amount: string,
39
- period: number
40
- ): Promise<NexusQuoteResult> {
41
-
42
- const { nexusSdk } = await getNexusSdk();
43
- const amountWei = parseEther(amount).toString();
43
+ amountToCover: string,
44
+ period: number,
45
+ coverAsset: CoverAsset
46
+ ): Promise<GetQuoteResponse> {
47
+ const nexusSdk = await getNexusSdk();
44
48
 
45
49
  const { result, error } = await nexusSdk.quote.getQuoteAndBuyCoverInputs({
46
50
  productId,
47
- amount: amountWei,
51
+ amount: amountToCover,
48
52
  period,
49
- coverAsset: CoverAsset.ETH,
50
- paymentAsset: CoverAsset.ETH,
53
+ coverAsset,
54
+ paymentAsset: coverAsset,
51
55
  buyerAddress,
52
56
  ipfsCidOrContent: TERMS_IPFS_CID,
53
57
  });
54
58
 
59
+
55
60
  if (error) {
56
61
  throw new Error(error.message || "Failed to fetch Nexus quote");
57
62
  }
58
63
 
59
- return result as NexusQuoteResult;
64
+ return result as GetQuoteResponse;
60
65
  }
61
66
 
62
67
  export function useCoverQuote({
63
68
  productId,
64
69
  buyerAddress,
65
70
  amountToCover,
66
- debounceMs = 500,
71
+ coverPeriod,
72
+ tokenSymbol,
73
+ decimals,
74
+ opportunity,
67
75
  }: UseCoverQuoteOptions): UseCoverQuoteReturn {
68
- const [coverPeriod, setCoverPeriod] = useState(30);
69
- const [isLoading, setIsLoading] = useState(false);
70
- const [error, setError] = useState<string | null>(null);
71
- const [quoteResult, setQuoteResult] = useState<NexusQuoteResult | null>(null);
72
- const [premiumEth, setPremiumEth] = useState<string | null>(null);
73
- const [yearlyCostPerc, setYearlyCostPerc] = useState<number | null>(null);
74
-
75
- const fetchQuote = useCallback(async () => {
76
- if (!productId || !buyerAddress || !amountToCover || parseFloat(amountToCover) <= 0) {
77
- console.error("Validation failed", productId, buyerAddress, amountToCover, coverPeriod);
78
- return;
79
- }
80
-
81
- setIsLoading(true);
82
- setError(null);
83
-
84
- try {
85
- const result = await fetchNexusQuote(productId, buyerAddress, amountToCover, coverPeriod);
86
-
87
- setQuoteResult(result);
88
- setPremiumEth(formatEther(BigInt(result.displayInfo.premiumInAsset)));
89
- setYearlyCostPerc(result.displayInfo.yearlyCostPerc);
90
- } catch (err: unknown) {
91
- const errorMessage = err instanceof Error ? err.message : "Failed to fetch quote";
92
- console.error("Cover quote error:", err);
93
- setError(errorMessage);
94
- setQuoteResult(null);
95
- setPremiumEth(null);
96
- setYearlyCostPerc(null);
97
- } finally {
98
- setIsLoading(false);
99
- }
100
- }, [productId, buyerAddress, amountToCover, coverPeriod]);
101
-
102
- // Debounced fetch on input changes
103
- useEffect(() => {
104
- const timeoutId = setTimeout(() => {
105
- fetchQuote();
106
- }, debounceMs);
107
-
108
- return () => clearTimeout(timeoutId);
109
- }, [fetchQuote, debounceMs]);
76
+ const coverAsset = TOKEN_SYMBOL_TO_COVER_ASSET[tokenSymbol] ?? CoverAsset.ETH;
77
+ const originalAPY = getOpportunityAPY(opportunity);
78
+
79
+ if (!decimals) {
80
+ return {
81
+ isLoading: false,
82
+ error: "Decimals are required",
83
+ quoteResult: null,
84
+ premium: null,
85
+ originalAPY,
86
+ adjustedAPY: null,
87
+ coverCostPerc: null,
88
+ refetch: () => { },
89
+ };
90
+ }
91
+ const isValidInput =
92
+ !!productId && !!buyerAddress && !!amountToCover && parseFloat(amountToCover) > 0 && decimals !== undefined;
93
+
94
+ const parsedAmount = parseUnits(amountToCover, decimals);
95
+
96
+ const {
97
+ data,
98
+ isLoading,
99
+ error: queryError,
100
+ refetch,
101
+ } = useQuery({
102
+ queryKey: ["coverQuote", productId, buyerAddress, parsedAmount.toString(), coverPeriod, tokenSymbol],
103
+ queryFn: () => fetchNexusQuote(productId, buyerAddress, parsedAmount.toString(), coverPeriod, coverAsset),
104
+ enabled: isValidInput,
105
+ select: (result: GetQuoteResponse) => ({
106
+ quoteResult: result,
107
+ premium: formatUnits(BigInt(result.displayInfo.premiumInAsset), decimals),
108
+ yearlyCostPerc: result.displayInfo.yearlyCostPerc,
109
+ }),
110
+ });
111
+
112
+ const yearlyCostPerc = data?.yearlyCostPerc ?? null;
113
+ const adjustedAPY = yearlyCostPerc !== null ? calculateAdjustedAPY(originalAPY, yearlyCostPerc) : null;
114
+ const coverCostPerc = yearlyCostPerc !== null ? yearlyCostPerc * 100 : null;
110
115
 
111
116
  return {
112
- coverPeriod,
113
- setCoverPeriod,
114
117
  isLoading,
115
- error,
116
- quoteResult,
117
- premiumEth,
118
- yearlyCostPerc,
119
- refetch: fetchQuote,
118
+ error: queryError?.message ?? null,
119
+ quoteResult: data?.quoteResult ?? null,
120
+ premium: data?.premium ?? null,
121
+ originalAPY,
122
+ adjustedAPY,
123
+ coverCostPerc,
124
+ refetch,
120
125
  };
121
126
  }
122
-
@@ -0,0 +1,84 @@
1
+ import { useState, useMemo } from "react";
2
+ import { usePortfolioBalance, useTokenBalance } from "@turtleclub/hooks";
3
+ import { NEXUS_AVAILABLE_TOKENS } from "../constants";
4
+
5
+ type NexusToken = (typeof NEXUS_AVAILABLE_TOKENS)[number];
6
+
7
+ interface UseCoverTokenSelectionParams {
8
+ buyerAddress: string;
9
+ coverAvailableAssets: string[] | undefined;
10
+ amount: string;
11
+ setAmount: (amount: string) => void;
12
+ }
13
+
14
+ interface UseCoverTokenSelectionReturn {
15
+ filteredTokens: NexusToken[];
16
+ selectedTokenAddress: string;
17
+ setSelectedTokenAddress: (address: string) => void;
18
+ selectedTokenSymbol: string;
19
+ selectedTokenDecimals: number;
20
+ selectedTokenBalance: ReturnType<typeof usePortfolioBalance>["balances"][number] | null;
21
+ usdValue: string | undefined;
22
+ handleMaxClick: () => void;
23
+ }
24
+
25
+ export function useCoverTokenSelection({
26
+ buyerAddress,
27
+ coverAvailableAssets,
28
+ amount,
29
+ setAmount,
30
+ }: UseCoverTokenSelectionParams): UseCoverTokenSelectionReturn {
31
+ const filteredTokens = useMemo(
32
+ () =>
33
+ NEXUS_AVAILABLE_TOKENS.filter((token) =>
34
+ coverAvailableAssets?.includes(token.symbol)
35
+ ),
36
+ [coverAvailableAssets]
37
+ );
38
+
39
+ const [selectedTokenAddress, setSelectedTokenAddress] = useState<string>(
40
+ filteredTokens[0]?.address || ""
41
+ );
42
+
43
+ const { balances } = usePortfolioBalance({
44
+ address: buyerAddress,
45
+ });
46
+
47
+ const userEthereumBalances = useMemo(
48
+ () => balances.filter((balance) => balance.token.chain.chainId === "1"),
49
+ [balances]
50
+ );
51
+
52
+ const selectedTokenBalance = useMemo(
53
+ () =>
54
+ userEthereumBalances.find(
55
+ (balance) => balance.token.address === selectedTokenAddress
56
+ ) ?? null,
57
+ [userEthereumBalances, selectedTokenAddress]
58
+ );
59
+
60
+ const { usdValue, handleMaxClick } = useTokenBalance({
61
+ tokenBalance: selectedTokenBalance,
62
+ amount: amount || undefined,
63
+ setAmount: (newAmount) => setAmount(newAmount ?? ""),
64
+ });
65
+
66
+ const selectedToken = useMemo(
67
+ () => filteredTokens.find((t) => t.address === selectedTokenAddress),
68
+ [filteredTokens, selectedTokenAddress]
69
+ );
70
+
71
+ const selectedTokenSymbol = selectedToken?.symbol ?? "ETH";
72
+ const selectedTokenDecimals = selectedToken?.decimals ?? 18;
73
+
74
+ return {
75
+ filteredTokens,
76
+ selectedTokenAddress,
77
+ setSelectedTokenAddress,
78
+ selectedTokenSymbol,
79
+ selectedTokenDecimals,
80
+ selectedTokenBalance,
81
+ usdValue,
82
+ handleMaxClick,
83
+ };
84
+ }
@@ -0,0 +1,12 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export function useDebouncedValue<T>(value: T, delay: number): T {
4
+ const [debouncedValue, setDebouncedValue] = useState(value);
5
+
6
+ useEffect(() => {
7
+ const timer = setTimeout(() => setDebouncedValue(value), delay);
8
+ return () => clearTimeout(timer);
9
+ }, [value, delay]);
10
+
11
+ return debouncedValue;
12
+ }
@@ -1,9 +1,9 @@
1
- import { useState, useEffect, useCallback } from "react";
2
- import { createPublicClient, http, getAddress } from "viem";
3
- import { mainnet } from "viem/chains";
1
+ import { useCallback, useMemo } from "react";
2
+ import { getAddress } from "viem";
3
+ import { useReadContract } from "wagmi";
4
4
  import nexusSdk from "@nexusmutual/sdk";
5
- import { useUserCoverNfts, type CoverNft } from "./useUserCoverNfts";
6
-
5
+ import { useUserCoverNfts } from "./useUserCoverNfts";
6
+ import type { Abi } from "viem";
7
7
  // Raw cover data from contract (all numbers are BigInt)
8
8
  interface RawCoverData {
9
9
  coverId: bigint;
@@ -54,56 +54,31 @@ interface UseExistingCoversReturn {
54
54
  refetch: () => void;
55
55
  }
56
56
 
57
- const publicClient = createPublicClient({
58
- chain: mainnet,
59
- transport: http(),
60
- });
61
-
62
57
  export function useExistingCovers(userAddress: string | undefined): UseExistingCoversReturn {
63
58
  const { coverNfts, isLoading: isNftsLoading, refetch: refetchNfts } = useUserCoverNfts(userAddress);
64
- const [covers, setCovers] = useState<CoverData[]>([]);
65
- const [isLoading, setIsLoading] = useState(false);
66
- const [error, setError] = useState<string | null>(null);
67
-
68
- const fetchCovers = useCallback(async () => {
69
- if (isNftsLoading || coverNfts.length === 0) {
70
- setCovers([]);
71
- return;
72
- }
73
-
74
- setIsLoading(true);
75
- setError(null);
76
59
 
77
- try {
78
- const coverIds = coverNfts.map((nft) => BigInt(nft.token_id));
79
- const coverViewerAddress = nexusSdk.addresses?.CoverViewer;
80
- const coverViewerAbi = nexusSdk.abis?.CoverViewer;
81
- if (!coverViewerAddress || !coverViewerAbi) {
82
- throw new Error('Nexus SDK not configured correctly for CoverViewer.');
83
- }
84
- const result = await publicClient.readContract({
85
- address: getAddress(coverViewerAddress),
86
- abi: coverViewerAbi as any,
87
- functionName: "getCovers",
88
- args: [coverIds],
89
- });
90
-
91
- const rawCovers = result as RawCoverData[];
92
- const parsedCovers = rawCovers.map(parseCoverData);
93
- setCovers(parsedCovers);
94
- } catch (err: unknown) {
95
- const errorMessage = err instanceof Error ? err.message : "Failed to fetch cover details";
96
- console.error("Cover details fetch error:", err);
97
- setError(errorMessage);
98
- setCovers([]);
99
- } finally {
100
- setIsLoading(false);
101
- }
102
- }, [coverNfts, isNftsLoading]);
60
+ const coverIds = useMemo(
61
+ () => coverNfts.map((nft) => BigInt(nft.token_id)),
62
+ [coverNfts]
63
+ );
64
+ const coverViewerAddress = nexusSdk.addresses?.CoverViewer;
65
+ const coverViewerAbi = nexusSdk.abis?.CoverViewer;
103
66
 
104
- useEffect(() => {
105
- fetchCovers();
106
- }, [fetchCovers]);
67
+ const {
68
+ data: covers = [],
69
+ isLoading: isContractLoading,
70
+ error: contractError,
71
+ refetch: refetchCovers,
72
+ } = useReadContract({
73
+ address: coverViewerAddress ? getAddress(coverViewerAddress) : undefined,
74
+ abi: coverViewerAbi as Abi,
75
+ functionName: "getCovers",
76
+ args: [coverIds],
77
+ query: {
78
+ enabled: !isNftsLoading && coverNfts.length > 0 && !!coverViewerAddress && !!coverViewerAbi,
79
+ select: (data) => (data as RawCoverData[]).map(parseCoverData),
80
+ },
81
+ });
107
82
 
108
83
  const hasCoverForProduct = useCallback(
109
84
  (productId: number): CoverData | undefined => {
@@ -114,12 +89,12 @@ export function useExistingCovers(userAddress: string | undefined): UseExistingC
114
89
 
115
90
  return {
116
91
  covers,
117
- isLoading: isLoading || isNftsLoading,
118
- error,
92
+ isLoading: isNftsLoading || isContractLoading,
93
+ error: contractError?.message ?? null,
119
94
  hasCoverForProduct,
120
95
  refetch: () => {
121
96
  refetchNfts();
122
- fetchCovers();
97
+ refetchCovers();
123
98
  },
124
99
  };
125
100
  }
@@ -1,5 +1,5 @@
1
1
  import { useMemo } from "react";
2
- import { products, ProductTypes } from "@nexusmutual/sdk";
2
+ import { CoverAsset, products, ProductTypes } from "@nexusmutual/sdk";
3
3
  import type { Opportunity } from "@turtleclub/hooks";
4
4
  import { NexusProduct } from "../types";
5
5
 
@@ -53,11 +53,15 @@ export const getNexusProductLookup = (opportunity: Opportunity | null | undefine
53
53
  const protocolName = getProtocolForInsurance(opportunity);
54
54
  const nexusProduct = protocolName ? NEXUS_PRODUCT_MAP[protocolName.toLowerCase()] : null;
55
55
 
56
+
57
+
58
+
56
59
  return {
57
60
  protocolName: protocolName ?? "Unknown",
58
61
  productId: nexusProduct?.id ?? null,
59
62
  coverProductName: nexusProduct?.name ?? null,
60
63
  isCoverable: !!nexusProduct,
64
+ coverAssets: nexusProduct?.coverAssets ?? [],
61
65
  };
62
66
  };
63
67
 
@@ -66,6 +70,7 @@ export interface NexusProductLookup {
66
70
  productId: number | null;
67
71
  coverProductName: string | null;
68
72
  isCoverable: boolean;
73
+ coverAssets: string[];
69
74
  }
70
75
 
71
76
  export function useNexusProduct(opportunity: Opportunity | null | undefined): NexusProductLookup {
@@ -1,10 +1,10 @@
1
- import { useCallback, useState } from "react";
2
- import nexusSdk from "@nexusmutual/sdk";
3
- import { getAddress } from "viem";
1
+ import { useCallback } from "react";
2
+ import { useMutation } from "@tanstack/react-query";
3
+ import nexusSdk, { GetQuoteResponse } from "@nexusmutual/sdk";
4
+ import { getAddress, type Abi } from "viem";
4
5
  import { useWriteContract, useConfig } from "wagmi";
5
6
  import { waitForTransactionReceipt } from "wagmi/actions";
6
7
  import { formatPurchaseError } from "../utils";
7
- import type { NexusQuoteResult } from "../types";
8
8
 
9
9
  type UseNexusPurchaseArgs = {
10
10
  onSuccess?: (message: string) => void;
@@ -12,53 +12,55 @@ type UseNexusPurchaseArgs = {
12
12
  };
13
13
 
14
14
  type UseNexusPurchaseReturn = {
15
- purchase: (quoteResult: NexusQuoteResult) => Promise<string>;
15
+ purchase: (quoteResult: GetQuoteResponse) => Promise<void>;
16
16
  isPurchasing: boolean;
17
17
  };
18
18
 
19
- export function useNexusPurchase(args: UseNexusPurchaseArgs): UseNexusPurchaseReturn {
20
- const { onSuccess, onError } = args;
19
+ export function useNexusPurchase({
20
+ onSuccess,
21
+ onError,
22
+ }: UseNexusPurchaseArgs): UseNexusPurchaseReturn {
21
23
  const config = useConfig();
22
24
  const { writeContractAsync } = useWriteContract();
23
- const [isPurchasing, setIsPurchasing] = useState(false);
24
-
25
- const purchase = useCallback(
26
- async (quoteResult: NexusQuoteResult): Promise<string> => {
27
- try {
28
- setIsPurchasing(true);
29
25
 
26
+ const { mutateAsync, isPending: isPurchasing } = useMutation({
27
+ mutationFn: async (quoteResult: GetQuoteResponse) => {
28
+ const { buyCoverParams, poolAllocationRequests } = quoteResult.buyCoverInput;
29
+ const coverAddress = nexusSdk.addresses?.Cover;
30
+ const coverAbi = nexusSdk.abis?.Cover;
30
31
 
31
- const { buyCoverParams, poolAllocationRequests } = quoteResult.buyCoverInput;
32
- const coverAddress = nexusSdk.addresses?.Cover;
33
- const coverAbi = nexusSdk.abis?.Cover;
34
- if (!coverAddress || !coverAbi) {
35
- throw new Error('Nexus SDK not configured correctly for Cover.');
36
- }
32
+ if (!coverAddress || !coverAbi) {
33
+ throw new Error("Nexus SDK not configured correctly for Cover.");
34
+ }
35
+ const isNativePayment = buyCoverParams.paymentAsset === 0;
37
36
 
38
- const txHash = await writeContractAsync({
39
- address: getAddress(coverAddress),
40
- abi: coverAbi as any,
41
- functionName: "buyCover",
42
- args: [buyCoverParams, poolAllocationRequests],
43
- value: BigInt(quoteResult.displayInfo.premiumInAsset),
44
- chainId: 1,
45
- });
37
+ const txHash = await writeContractAsync({
38
+ address: getAddress(coverAddress),
39
+ abi: coverAbi as Abi,
40
+ functionName: "buyCover",
41
+ args: [buyCoverParams, poolAllocationRequests],
42
+ value: isNativePayment ? BigInt(quoteResult.displayInfo.premiumInAsset) : undefined,
43
+ chainId: 1,
44
+ });
46
45
 
47
- await waitForTransactionReceipt(config, {
48
- hash: txHash,
49
- confirmations: 1,
50
- });
46
+ await waitForTransactionReceipt(config, {
47
+ hash: txHash,
48
+ confirmations: 1,
49
+ });
50
+ },
51
+ onSuccess: () => {
52
+ onSuccess?.("Cover purchased successfully");
53
+ },
54
+ onError: (err) => {
55
+ onError?.(formatPurchaseError(err));
56
+ },
57
+ });
51
58
 
52
- onSuccess?.("Cover purchased successfully");
53
- return txHash;
54
- } catch (err) {
55
- onError?.(formatPurchaseError(err));
56
- throw err;
57
- } finally {
58
- setIsPurchasing(false);
59
- }
59
+ const purchase = useCallback(
60
+ async (quoteResult: GetQuoteResponse) => {
61
+ await mutateAsync(quoteResult);
60
62
  },
61
- [config, writeContractAsync, onError, onSuccess]
63
+ [mutateAsync]
62
64
  );
63
65
 
64
66
  return { purchase, isPurchasing };
@@ -0,0 +1,118 @@
1
+ import { useCallback } from "react";
2
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
3
+ import { useConfig, useWriteContract } from "wagmi";
4
+ import { readContract, waitForTransactionReceipt } from "wagmi/actions";
5
+ import { erc20Abi } from "viem";
6
+
7
+ interface UseTokenApprovalParams {
8
+ userAddress: `0x${string}` | undefined;
9
+ tokenAddress: `0x${string}`;
10
+ spenderAddress: `0x${string}`;
11
+ chainId: number;
12
+ amount: bigint | null | undefined;
13
+ }
14
+
15
+ interface UseTokenApprovalReturn {
16
+ needsApproval: boolean;
17
+ isCheckingApproval: boolean;
18
+ approve: () => Promise<void>;
19
+ isApproving: boolean;
20
+ refetchApprovalStatus: () => void;
21
+ }
22
+
23
+ export const NATIVE_TOKEN_ADDRESS_ZERO =
24
+ "0x0000000000000000000000000000000000000000" as const;
25
+ export const NATIVE_TOKEN_ADDRESS_EEEE =
26
+ "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" as const;
27
+
28
+ function isNativeTokenAddress(address: string): boolean {
29
+ const lower = address.toLowerCase();
30
+ return lower === NATIVE_TOKEN_ADDRESS_ZERO || lower === NATIVE_TOKEN_ADDRESS_EEEE;
31
+ }
32
+
33
+ export function useTokenApproval({
34
+ userAddress,
35
+ tokenAddress,
36
+ spenderAddress,
37
+ chainId,
38
+ amount,
39
+ }: UseTokenApprovalParams): UseTokenApprovalReturn {
40
+ const config = useConfig();
41
+ const queryClient = useQueryClient();
42
+ const { writeContractAsync } = useWriteContract();
43
+
44
+ const isNativeToken = isNativeTokenAddress(tokenAddress);
45
+ const hasValidAmount = !!amount && amount > 0n;
46
+ const isEnabled = !!userAddress && hasValidAmount && !isNativeToken;
47
+
48
+ const queryKey = [
49
+ "tokenApproval",
50
+ userAddress,
51
+ tokenAddress,
52
+ spenderAddress,
53
+ amount?.toString(),
54
+ chainId,
55
+ ];
56
+
57
+ const {
58
+ data: needsApproval = false,
59
+ isLoading: isCheckingApproval,
60
+ refetch,
61
+ } = useQuery({
62
+ queryKey,
63
+ queryFn: async () => {
64
+ if (!userAddress || !amount) return false;
65
+
66
+ const currentAllowance = await readContract(config, {
67
+ address: tokenAddress,
68
+ abi: erc20Abi,
69
+ functionName: "allowance",
70
+ args: [userAddress, spenderAddress],
71
+ chainId,
72
+ });
73
+
74
+ return currentAllowance < amount;
75
+ },
76
+ enabled: isEnabled,
77
+ });
78
+
79
+ const { mutateAsync: approveAsync, isPending: isApproving } = useMutation({
80
+ mutationFn: async () => {
81
+ if (!userAddress || !amount) {
82
+ throw new Error("User address and amount are required");
83
+ }
84
+
85
+ const approvalHash = await writeContractAsync({
86
+ address: tokenAddress,
87
+ abi: erc20Abi,
88
+ functionName: "approve",
89
+ args: [spenderAddress, amount],
90
+ chainId,
91
+ });
92
+
93
+ await waitForTransactionReceipt(config, {
94
+ hash: approvalHash,
95
+ chainId,
96
+ });
97
+ },
98
+ onSuccess: () => {
99
+ queryClient.invalidateQueries({ queryKey });
100
+ },
101
+ });
102
+
103
+ const approve = useCallback(async () => {
104
+ await approveAsync();
105
+ }, [approveAsync]);
106
+
107
+ const refetchApprovalStatus = useCallback(() => {
108
+ refetch();
109
+ }, [refetch]);
110
+
111
+ return {
112
+ needsApproval,
113
+ isCheckingApproval,
114
+ approve,
115
+ isApproving,
116
+ refetchApprovalStatus,
117
+ };
118
+ }