@turtleclub/opportunities 0.1.0-beta.6 → 0.1.0-beta.60

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +224 -0
  2. package/README.md +179 -0
  3. package/package.json +13 -6
  4. package/src/components/index.ts +1 -1
  5. package/src/cover-offer/README.md +46 -0
  6. package/src/cover-offer/components/CoverOfferCard.tsx +445 -0
  7. package/src/cover-offer/components/CoverRequestForm.tsx +342 -0
  8. package/src/cover-offer/components/CoveredEventsInfo.tsx +120 -0
  9. package/src/cover-offer/components/ExistingCoverInfo.tsx +74 -0
  10. package/src/cover-offer/components/NexusCoverSection.tsx +49 -0
  11. package/src/cover-offer/components/PurchaseButtonSection.tsx +106 -0
  12. package/src/cover-offer/constants.ts +32 -0
  13. package/src/cover-offer/hooks/useCheckNexusMembership.ts +32 -0
  14. package/src/cover-offer/hooks/useCoverQuote.ts +126 -0
  15. package/src/cover-offer/hooks/useCoverTokenSelection.ts +84 -0
  16. package/src/cover-offer/hooks/useDebouncedValue.ts +12 -0
  17. package/src/cover-offer/hooks/useExistingCovers.ts +101 -0
  18. package/src/cover-offer/hooks/useNexusProduct.ts +79 -0
  19. package/src/cover-offer/hooks/useNexusPurchase.ts +67 -0
  20. package/src/cover-offer/hooks/useTokenApproval.ts +118 -0
  21. package/src/cover-offer/hooks/useUserCoverNfts.ts +26 -0
  22. package/src/cover-offer/index.ts +4 -0
  23. package/src/cover-offer/types/index.ts +41 -0
  24. package/src/cover-offer/utils/index.ts +90 -0
  25. package/src/deposit/NativeDepositSection.tsx +199 -0
  26. package/src/deposit/TemporalWrapper.tsx +155 -0
  27. package/src/{components → deposit/components}/balances-data-table.tsx +6 -0
  28. package/src/deposit/components/index.ts +3 -0
  29. package/src/deposit/components/swap-input-v3.tsx +194 -0
  30. package/src/deposit/components/token-selector-v3.tsx +122 -0
  31. package/src/deposit/index.ts +4 -0
  32. package/src/index.ts +9 -0
  33. package/src/opportunity-actions/OpportunityActions.tsx +182 -0
  34. package/src/opportunity-actions/index.ts +1 -0
  35. package/src/opportunity-table/components/opportunities-table.tsx +6 -5
  36. package/src/route-details/index.ts +6 -0
  37. package/src/route-details/route-details-v2.tsx +137 -0
  38. package/src/route-details/route-details.tsx +5 -4
  39. package/src/route-details/types.ts +7 -0
  40. package/src/transaction-status/hooks/useTransactionQueue.ts +1 -1
  41. package/src/withdraw/NativeWithdrawSection.tsx +45 -0
  42. package/src/withdraw/index.ts +1 -0
@@ -0,0 +1,32 @@
1
+ export const NEXUS_PROTOCOL_COVER_TERMS_URL =
2
+ "https://api.nexusmutual.io/ipfs/QmQz38DSo6DyrHkRj8uvtGFyx842izVvnx8a3qqF99dctG";
3
+ export const NEXUS_KYC_REQUIREMENTS_URL =
4
+ "https://docs.nexusmutual.io/overview/membership/#kyc-requirements";
5
+ export const NEXUS_MEMBERSHIP_URL = "https://app.nexusmutual.io/become-member";
6
+ export const NEXUS_COVER_NFT_ADDRESS = "0xcafeaca76be547f14d0220482667b42d8e7bc3eb";
7
+ export const NEXUS_COVER_ROUTER = "0xcafeac0fF5dA0A2777d915531bfA6B29d282Ee62";
8
+
9
+ // These are the tokens that Nexus Mutual supports.
10
+ export const NEXUS_AVAILABLE_TOKENS = [
11
+ {
12
+ symbol: "ETH",
13
+ name: "Ethereum",
14
+ address: "0x0000000000000000000000000000000000000000",
15
+ logoUrl: "https://storage.googleapis.com/turtle-assets/tokens/eth.png",
16
+ decimals: 18,
17
+ },
18
+ {
19
+ symbol: "USDC",
20
+ name: "USD Coin",
21
+ address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
22
+ logoUrl: "https://storage.googleapis.com/turtle-assets/tokens/usdc.png",
23
+ decimals: 6,
24
+ },
25
+ {
26
+ symbol: "cbBTC",
27
+ name: "Coinbase Wrapped BTC",
28
+ address: "0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf",
29
+ logoUrl: "https://storage.googleapis.com/turtle-assets/tokens/cbbtc.png",
30
+ decimals: 8,
31
+ },
32
+ ] as const;
@@ -0,0 +1,32 @@
1
+ import { getAddress } from "viem";
2
+ import type { Abi } from "viem";
3
+ import { useReadContract } from "wagmi";
4
+ import nexusSdk from "@nexusmutual/sdk";
5
+
6
+ export function useCheckNexusMembership(
7
+ userAddress: string | undefined,
8
+ chainId: string | number | undefined
9
+ ) {
10
+ const masterAddress = nexusSdk.addresses?.NXMaster;
11
+ const masterAbi = nexusSdk.abis?.NXMaster;
12
+ const numericChainId = typeof chainId === "string" ? parseInt(chainId, 10) : chainId;
13
+ const isOnEthereum = numericChainId === 1;
14
+
15
+ const {
16
+ data: isMember = false,
17
+ isLoading,
18
+ error,
19
+ } = useReadContract({
20
+ address: masterAddress ? getAddress(masterAddress) : undefined,
21
+ abi: masterAbi as Abi,
22
+ functionName: "isMember",
23
+ chainId: 1,
24
+ args: userAddress ? [getAddress(userAddress)] : undefined,
25
+ query: {
26
+ enabled: isOnEthereum && !!userAddress && !!masterAddress && !!masterAbi,
27
+ select: (data) => data as boolean,
28
+ },
29
+ });
30
+
31
+ return { isMember, isLoading, error };
32
+ }
@@ -0,0 +1,126 @@
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";
6
+
7
+ interface UseCoverQuoteOptions {
8
+ productId: number;
9
+ buyerAddress: string;
10
+ amountToCover: string;
11
+ coverPeriod: number;
12
+ tokenSymbol: string;
13
+ decimals: number | undefined;
14
+ opportunity: Opportunity;
15
+ }
16
+
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;
25
+ refetch: () => void;
26
+ }
27
+
28
+ let nexusSdkInstance: import("@nexusmutual/sdk").NexusSDK | null = null;
29
+
30
+ const TERMS_IPFS_CID = "QmXUzXDMbeKSCewUie34vPD7mCAGnshi4ULRy4h7DLmoRS";
31
+
32
+ async function getNexusSdk() {
33
+ if (!nexusSdkInstance) {
34
+ const { NexusSDK } = await import("@nexusmutual/sdk");
35
+ nexusSdkInstance = new NexusSDK();
36
+ }
37
+ return nexusSdkInstance;
38
+ }
39
+
40
+ async function fetchNexusQuote(
41
+ productId: number,
42
+ buyerAddress: string,
43
+ amountToCover: string,
44
+ period: number,
45
+ coverAsset: CoverAsset
46
+ ): Promise<GetQuoteResponse> {
47
+ const nexusSdk = await getNexusSdk();
48
+
49
+ const { result, error } = await nexusSdk.quote.getQuoteAndBuyCoverInputs({
50
+ productId,
51
+ amount: amountToCover,
52
+ period,
53
+ coverAsset,
54
+ paymentAsset: coverAsset,
55
+ buyerAddress,
56
+ ipfsCidOrContent: TERMS_IPFS_CID,
57
+ });
58
+
59
+
60
+ if (error) {
61
+ throw new Error(error.message || "Failed to fetch Nexus quote");
62
+ }
63
+
64
+ return result as GetQuoteResponse;
65
+ }
66
+
67
+ export function useCoverQuote({
68
+ productId,
69
+ buyerAddress,
70
+ amountToCover,
71
+ coverPeriod,
72
+ tokenSymbol,
73
+ decimals,
74
+ opportunity,
75
+ }: UseCoverQuoteOptions): UseCoverQuoteReturn {
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;
115
+
116
+ return {
117
+ isLoading,
118
+ error: queryError?.message ?? null,
119
+ quoteResult: data?.quoteResult ?? null,
120
+ premium: data?.premium ?? null,
121
+ originalAPY,
122
+ adjustedAPY,
123
+ coverCostPerc,
124
+ refetch,
125
+ };
126
+ }
@@ -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
+ }
@@ -0,0 +1,101 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { getAddress } from "viem";
3
+ import { useReadContract } from "wagmi";
4
+ import nexusSdk from "@nexusmutual/sdk";
5
+ import { useUserCoverNfts } from "./useUserCoverNfts";
6
+ import type { Abi } from "viem";
7
+ // Raw cover data from contract (all numbers are BigInt)
8
+ interface RawCoverData {
9
+ coverId: bigint;
10
+ productId: bigint;
11
+ coverAsset: bigint;
12
+ amountPaidOut: bigint;
13
+ amount: bigint;
14
+ start: bigint;
15
+ period: bigint;
16
+ gracePeriod: bigint;
17
+ segmentId: bigint;
18
+ expired: boolean;
19
+ }
20
+
21
+ export interface CoverData {
22
+ coverId: bigint;
23
+ productId: number;
24
+ coverAsset: number;
25
+ amountPaidOut: bigint;
26
+ amount: bigint;
27
+ start: number;
28
+ period: number;
29
+ gracePeriod: number;
30
+ segmentId: number;
31
+ expired: boolean;
32
+ }
33
+
34
+ function parseCoverData(raw: RawCoverData): CoverData {
35
+ return {
36
+ coverId: raw.coverId,
37
+ productId: Number(raw.productId),
38
+ coverAsset: Number(raw.coverAsset),
39
+ amountPaidOut: raw.amountPaidOut,
40
+ amount: raw.amount,
41
+ start: Number(raw.start),
42
+ period: Number(raw.period),
43
+ gracePeriod: Number(raw.gracePeriod),
44
+ segmentId: Number(raw.segmentId),
45
+ expired: raw.expired,
46
+ };
47
+ }
48
+
49
+ interface UseExistingCoversReturn {
50
+ covers: CoverData[];
51
+ isLoading: boolean;
52
+ error: string | null;
53
+ hasCoverForProduct: (productId: number) => CoverData | undefined;
54
+ refetch: () => void;
55
+ }
56
+
57
+ export function useExistingCovers(userAddress: string | undefined): UseExistingCoversReturn {
58
+ const { coverNfts, isLoading: isNftsLoading, refetch: refetchNfts } = useUserCoverNfts(userAddress);
59
+
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;
66
+
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
+ });
82
+
83
+ const hasCoverForProduct = useCallback(
84
+ (productId: number): CoverData | undefined => {
85
+ return covers.find((cover) => cover.productId === productId && !cover.expired);
86
+ },
87
+ [covers]
88
+ );
89
+
90
+ return {
91
+ covers,
92
+ isLoading: isNftsLoading || isContractLoading,
93
+ error: contractError?.message ?? null,
94
+ hasCoverForProduct,
95
+ refetch: () => {
96
+ refetchNfts();
97
+ refetchCovers();
98
+ },
99
+ };
100
+ }
101
+
@@ -0,0 +1,79 @@
1
+ import { useMemo } from "react";
2
+ import { CoverAsset, products, ProductTypes } from "@nexusmutual/sdk";
3
+ import type { Opportunity } from "@turtleclub/hooks";
4
+ import { NexusProduct } from "../types";
5
+
6
+ const getHighestPriorityProduct = (
7
+ currentBest: NexusProduct | null,
8
+ newProduct: NexusProduct,
9
+ ): boolean => {
10
+ // Prefer v3 products, and for v3 prefer the highest id
11
+ if (newProduct.name.includes("v3") && currentBest && !currentBest.name.includes("v3")) {
12
+ return true;
13
+ }
14
+
15
+ if (newProduct.name.includes("v3") && currentBest?.name.includes("v3") && newProduct.id > currentBest.id) {
16
+ return true;
17
+ }
18
+
19
+ if (!currentBest) {
20
+ return true;
21
+ }
22
+
23
+ return false;
24
+ };
25
+
26
+ const NEXUS_PRODUCT_MAP: Record<string, NexusProduct> = products
27
+ .filter((x) => !x.isDeprecated && !x.isPrivate && x.productType === ProductTypes.singleProtocol)
28
+ .reduce((acc: Record<string, NexusProduct>, product: any) => {
29
+ const protocolNameMatch = product.name.match(/^(\w+)/);
30
+ const protocolName = protocolNameMatch ? protocolNameMatch[1].toLowerCase() : null;
31
+
32
+ if (!protocolName) {
33
+ return acc;
34
+ }
35
+
36
+ const currentBest = acc[protocolName] || null;
37
+
38
+ if (getHighestPriorityProduct(currentBest, product)) {
39
+ acc[protocolName] = {
40
+ id: product.id,
41
+ name: product.name,
42
+ coverAssets: product.coverAssets,
43
+ };
44
+ }
45
+ return acc;
46
+ }, {});
47
+
48
+ export const getProtocolForInsurance = (opportunity: Opportunity | null | undefined) => {
49
+ return opportunity?.vaultConfig?.infraProvider?.name ?? null;
50
+ };
51
+
52
+ export const getNexusProductLookup = (opportunity: Opportunity | null | undefined) => {
53
+ const protocolName = getProtocolForInsurance(opportunity);
54
+ const nexusProduct = protocolName ? NEXUS_PRODUCT_MAP[protocolName.toLowerCase()] : null;
55
+
56
+
57
+
58
+
59
+ return {
60
+ protocolName: protocolName ?? "Unknown",
61
+ productId: nexusProduct?.id ?? null,
62
+ coverProductName: nexusProduct?.name ?? null,
63
+ isCoverable: !!nexusProduct,
64
+ coverAssets: nexusProduct?.coverAssets ?? [],
65
+ };
66
+ };
67
+
68
+ export interface NexusProductLookup {
69
+ protocolName: string;
70
+ productId: number | null;
71
+ coverProductName: string | null;
72
+ isCoverable: boolean;
73
+ coverAssets: string[];
74
+ }
75
+
76
+ export function useNexusProduct(opportunity: Opportunity | null | undefined): NexusProductLookup {
77
+ return useMemo(() => getNexusProductLookup(opportunity), [opportunity]);
78
+ }
79
+
@@ -0,0 +1,67 @@
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";
5
+ import { useWriteContract, useConfig } from "wagmi";
6
+ import { waitForTransactionReceipt } from "wagmi/actions";
7
+ import { formatPurchaseError } from "../utils";
8
+
9
+ type UseNexusPurchaseArgs = {
10
+ onSuccess?: (message: string) => void;
11
+ onError?: (message: string) => void;
12
+ };
13
+
14
+ type UseNexusPurchaseReturn = {
15
+ purchase: (quoteResult: GetQuoteResponse) => Promise<void>;
16
+ isPurchasing: boolean;
17
+ };
18
+
19
+ export function useNexusPurchase({
20
+ onSuccess,
21
+ onError,
22
+ }: UseNexusPurchaseArgs): UseNexusPurchaseReturn {
23
+ const config = useConfig();
24
+ const { writeContractAsync } = useWriteContract();
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;
31
+
32
+ if (!coverAddress || !coverAbi) {
33
+ throw new Error("Nexus SDK not configured correctly for Cover.");
34
+ }
35
+ const isNativePayment = buyCoverParams.paymentAsset === 0;
36
+
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
+ });
45
+
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
+ });
58
+
59
+ const purchase = useCallback(
60
+ async (quoteResult: GetQuoteResponse) => {
61
+ await mutateAsync(quoteResult);
62
+ },
63
+ [mutateAsync]
64
+ );
65
+
66
+ return { purchase, isPurchasing };
67
+ }
@@ -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
+ }
@@ -0,0 +1,26 @@
1
+ import { useUserNfts, type UserNft } from "@turtleclub/hooks";
2
+ import { NEXUS_COVER_NFT_ADDRESS } from "../constants";
3
+
4
+ export type CoverNft = UserNft;
5
+
6
+ interface UseUserCoverNftsReturn {
7
+ coverNfts: CoverNft[];
8
+ isLoading: boolean;
9
+ error: string | null;
10
+ refetch: () => void;
11
+ }
12
+
13
+ export function useUserCoverNfts(userAddress: string | undefined): UseUserCoverNftsReturn {
14
+ const { data, isLoading, error, refetch } = useUserNfts({
15
+ userAddress,
16
+ chain: 1,
17
+ tokenAddress: NEXUS_COVER_NFT_ADDRESS,
18
+ });
19
+
20
+ return {
21
+ coverNfts: data ?? [],
22
+ isLoading,
23
+ error: error?.message ?? null,
24
+ refetch,
25
+ };
26
+ }
@@ -0,0 +1,4 @@
1
+ export { NexusCoverSection } from "./components/NexusCoverSection";
2
+ export type { NexusCoverSectionProps } from "./types";
3
+
4
+ export { useNexusProduct, getNexusProductLookup, getProtocolForInsurance } from "./hooks/useNexusProduct";