@turtleclub/covers 0.1.0-beta.1
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.
- package/CHANGELOG.md +10 -0
- package/README.md +51 -0
- package/package.json +38 -0
- package/src/index.ts +1 -0
- package/src/nexus-mutual-cover/components/CoverOfferCard.tsx +495 -0
- package/src/nexus-mutual-cover/components/CoverRequestForm.tsx +362 -0
- package/src/nexus-mutual-cover/components/CoveredEventsInfo.tsx +120 -0
- package/src/nexus-mutual-cover/components/ExistingCoverInfo.tsx +74 -0
- package/src/nexus-mutual-cover/components/ExistingCoverRequest.tsx +65 -0
- package/src/nexus-mutual-cover/components/NexusCoverSection.tsx +49 -0
- package/src/nexus-mutual-cover/components/PurchaseButtonSection.tsx +106 -0
- package/src/nexus-mutual-cover/components/index.ts +7 -0
- package/src/nexus-mutual-cover/constants.ts +39 -0
- package/src/nexus-mutual-cover/hooks/index.ts +9 -0
- package/src/nexus-mutual-cover/hooks/useCheckNexusMembership.ts +32 -0
- package/src/nexus-mutual-cover/hooks/useCoverQuote.ts +126 -0
- package/src/nexus-mutual-cover/hooks/useCoverTokenSelection.ts +84 -0
- package/src/nexus-mutual-cover/hooks/useDebouncedValue.ts +12 -0
- package/src/nexus-mutual-cover/hooks/useExistingCovers.ts +110 -0
- package/src/nexus-mutual-cover/hooks/useNexusProduct.ts +79 -0
- package/src/nexus-mutual-cover/hooks/useNexusPurchase.ts +67 -0
- package/src/nexus-mutual-cover/hooks/useTokenApproval.ts +118 -0
- package/src/nexus-mutual-cover/hooks/useUserCoverNfts.ts +31 -0
- package/src/nexus-mutual-cover/index.ts +4 -0
- package/src/nexus-mutual-cover/types/index.ts +41 -0
- package/src/nexus-mutual-cover/utils/index.ts +95 -0
- package/tsconfig.json +22 -0
|
@@ -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,31 @@
|
|
|
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 UseUserCoverNftsOptions {
|
|
7
|
+
pollingInterval?: number | false;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface UseUserCoverNftsReturn {
|
|
11
|
+
coverNfts: CoverNft[];
|
|
12
|
+
isLoading: boolean;
|
|
13
|
+
error: string | null;
|
|
14
|
+
refetch: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useUserCoverNfts(userAddress: string | undefined, options?: UseUserCoverNftsOptions): UseUserCoverNftsReturn {
|
|
18
|
+
const { data, isLoading, error, refetch } = useUserNfts({
|
|
19
|
+
userAddress,
|
|
20
|
+
chain: 1,
|
|
21
|
+
tokenAddress: NEXUS_COVER_NFT_ADDRESS,
|
|
22
|
+
refetchInterval: options?.pollingInterval,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
coverNfts: data ?? [],
|
|
27
|
+
isLoading,
|
|
28
|
+
error: error?.message ?? null,
|
|
29
|
+
refetch,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { CoverAsset } from "@nexusmutual/sdk";
|
|
2
|
+
import type { Opportunity } from "@turtleclub/hooks";
|
|
3
|
+
import type { Asset } from "@turtleclub/ui";
|
|
4
|
+
|
|
5
|
+
// The Nexus SDK doesn't export the NexusProduct type, so we need to define it ourselves.
|
|
6
|
+
// The product has more attributes than just id, name, and coverAssets, but this are the only ones we need for now.
|
|
7
|
+
export type NexusProduct = {
|
|
8
|
+
id: number;
|
|
9
|
+
name: string;
|
|
10
|
+
coverAssets: string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export interface CoverOfferCardProps {
|
|
14
|
+
productId: number;
|
|
15
|
+
opportunity: Opportunity;
|
|
16
|
+
buyerAddress: string;
|
|
17
|
+
protocolName: string;
|
|
18
|
+
coverProductName: string;
|
|
19
|
+
coverAvailableAssets: string[];
|
|
20
|
+
onSuccess?: (message: string) => void;
|
|
21
|
+
onError?: (message: string) => void;
|
|
22
|
+
startExpanded?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface NexusCoverSectionProps {
|
|
26
|
+
opportunity: Opportunity;
|
|
27
|
+
buyerAddress: string;
|
|
28
|
+
onSuccess?: (message: string) => void;
|
|
29
|
+
onError?: (message: string) => void;
|
|
30
|
+
className?: string;
|
|
31
|
+
startExpanded?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CoverRequestFormProps {
|
|
35
|
+
protocolName: string;
|
|
36
|
+
baseApyLabel?: string;
|
|
37
|
+
onDismiss?: () => void;
|
|
38
|
+
onSuccess?: (message: string) => void;
|
|
39
|
+
onError?: (message: string) => void;
|
|
40
|
+
startExpanded?: boolean;
|
|
41
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { CoverAsset } from "@nexusmutual/sdk";
|
|
2
|
+
import type { Opportunity } from "@turtleclub/hooks";
|
|
3
|
+
import { BaseError, UserRejectedRequestError } from "viem";
|
|
4
|
+
|
|
5
|
+
export function getOpportunityAPY(opportunity: Opportunity): number {
|
|
6
|
+
if (!opportunity.incentives || opportunity.incentives.length === 0) {
|
|
7
|
+
return 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return opportunity.incentives.reduce((total, incentive) => {
|
|
11
|
+
return total + (incentive.yield ?? 0);
|
|
12
|
+
}, 0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Calculate the adjusted APY after subtracting the cover cost
|
|
17
|
+
* @param opportunityAPY - The original APY as a percentage (e.g., 5 for 5%)
|
|
18
|
+
* @param yearlyCostPerc - The yearly cost as a decimal (e.g., 0.025 for 2.5%)
|
|
19
|
+
* @returns The adjusted APY as a percentage
|
|
20
|
+
*/
|
|
21
|
+
export function calculateAdjustedAPY(
|
|
22
|
+
opportunityAPY: number,
|
|
23
|
+
yearlyCostPerc: number
|
|
24
|
+
): number {
|
|
25
|
+
const coverCostPercentage = yearlyCostPerc * 100;
|
|
26
|
+
return Math.max(0, opportunityAPY - coverCostPercentage);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
export function formatAPY(apy: number): string {
|
|
31
|
+
return `${apy.toFixed(2)}%`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatEthAmount(amount: string, decimals: number = 6): string {
|
|
35
|
+
const num = parseFloat(amount);
|
|
36
|
+
if (isNaN(num)) return "0";
|
|
37
|
+
return num.toFixed(decimals);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
export const formatPurchaseError = (err: unknown): string => {
|
|
42
|
+
console.error("Purchase error:", err, typeof err);
|
|
43
|
+
if (err instanceof UserRejectedRequestError) {
|
|
44
|
+
return "Transaction cancelled";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (err instanceof BaseError) {
|
|
48
|
+
const userRejected =
|
|
49
|
+
typeof err.walk === "function"
|
|
50
|
+
? err.walk((error) => error instanceof UserRejectedRequestError)
|
|
51
|
+
: false;
|
|
52
|
+
if (userRejected) {
|
|
53
|
+
return "Transaction cancelled";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return err.shortMessage || err.message;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (err instanceof Error) {
|
|
60
|
+
return err.message;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return "Failed to purchase cover";
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
const SECS_IN_DAY = 24 * 60 * 60;
|
|
69
|
+
|
|
70
|
+
export function parseCoverTiming(startSec: number, periodSec: number, gracePeriodSec: number = 0) {
|
|
71
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
72
|
+
const expiresSec = startSec + periodSec;
|
|
73
|
+
const graceEndsSec = expiresSec + gracePeriodSec;
|
|
74
|
+
const remainingSec = Math.max(0, expiresSec - nowSec);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
startDate: new Date(startSec * 1000),
|
|
78
|
+
expiresDate: new Date(expiresSec * 1000),
|
|
79
|
+
graceEndsDate: new Date(graceEndsSec * 1000),
|
|
80
|
+
periodDays: periodSec / SECS_IN_DAY,
|
|
81
|
+
graceDays: gracePeriodSec / SECS_IN_DAY,
|
|
82
|
+
remainingDays: Math.ceil(remainingSec / SECS_IN_DAY),
|
|
83
|
+
isExpired: nowSec > expiresSec,
|
|
84
|
+
isInGracePeriod: nowSec > expiresSec && nowSec <= graceEndsSec,
|
|
85
|
+
expiresSec,
|
|
86
|
+
graceEndsSec,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const TOKEN_SYMBOL_TO_COVER_ASSET: Record<string, CoverAsset> = {
|
|
91
|
+
ETH: CoverAsset.ETH,
|
|
92
|
+
DAI: CoverAsset.DAI,
|
|
93
|
+
USDC: CoverAsset.USDC,
|
|
94
|
+
cbBTC: CoverAsset.cbBTC,
|
|
95
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"resolveJsonModule": true,
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"allowSyntheticDefaultImports": true,
|
|
15
|
+
"forceConsistentCasingInFileNames": true,
|
|
16
|
+
"isolatedModules": true,
|
|
17
|
+
"outDir": "dist",
|
|
18
|
+
"rootDir": "src"
|
|
19
|
+
},
|
|
20
|
+
"include": ["src"],
|
|
21
|
+
"exclude": ["node_modules", "dist"]
|
|
22
|
+
}
|