@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.
@@ -0,0 +1,120 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ChevronDown, ChevronUp, type LucideProps } from "lucide-react";
5
+ import type { ComponentType } from "react";
6
+ import { NEXUS_PROTOCOL_COVER_TERMS_URL } from "../constants";
7
+
8
+ const ChevronDownIcon = ChevronDown as ComponentType<LucideProps>;
9
+ const ChevronUpIcon = ChevronUp as ComponentType<LucideProps>;
10
+
11
+ const COVERED_EVENTS = [
12
+ "Smart contract exploits/hacks",
13
+ "Oracle failure or manipulation",
14
+ "Liquidation failure",
15
+ "Governance takeovers",
16
+ ];
17
+
18
+ const EXCLUSIONS = [
19
+ "Loss of value of any asset (i.e., depeg event)",
20
+ "Losses due to phishing, private key breaches, malware",
21
+ "Losses due to frontend attacks where the protocol is unaffected",
22
+ ];
23
+
24
+ export function CoveredEventsInfo() {
25
+ const [isExpanded, setIsExpanded] = useState(false);
26
+
27
+ return (
28
+ <div className="p-3 rounded-lg bg-background/50 border border-border space-y-2">
29
+ {/* Summary - Always visible */}
30
+ <p className="text-xs text-muted-foreground leading-relaxed">
31
+ <span className="text-primary font-medium">Single Protocol Cover</span> protects against
32
+ loss of funds on all EVM-compatible chains.
33
+ </p>
34
+
35
+ {/* Covered Events - Compact */}
36
+ <div className="flex flex-wrap gap-1.5">
37
+ {COVERED_EVENTS.map((event) => (
38
+ <span
39
+ key={event}
40
+ className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20"
41
+ >
42
+ {event}
43
+ </span>
44
+ ))}
45
+ </div>
46
+
47
+ {/* Expand/Collapse Button */}
48
+ <button
49
+ onClick={() => setIsExpanded(!isExpanded)}
50
+ className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
51
+ >
52
+ {isExpanded ? (
53
+ <>
54
+ <ChevronUpIcon className="w-3 h-3" />
55
+ <span>Show less</span>
56
+ </>
57
+ ) : (
58
+ <>
59
+ <ChevronDownIcon className="w-3 h-3" />
60
+ <span>View exclusions & claim details</span>
61
+ </>
62
+ )}
63
+ </button>
64
+
65
+ {/* Expanded Details */}
66
+ {isExpanded && (
67
+ <div className="space-y-3 pt-2 border-t border-border">
68
+ {/* Exclusions */}
69
+ <div className="space-y-1.5">
70
+ <p className="text-xs uppercase tracking-wide text-muted-foreground font-medium">
71
+ Not Covered
72
+ </p>
73
+ <ul className="text-xs text-muted-foreground space-y-1">
74
+ {EXCLUSIONS.map((exclusion) => (
75
+ <li key={exclusion} className="flex items-start gap-1.5">
76
+ <span className="text-destructive mt-0.5">✕</span>
77
+ <span>{exclusion}</span>
78
+ </li>
79
+ ))}
80
+ </ul>
81
+ </div>
82
+
83
+ {/* Deductible & Claims */}
84
+ <div className="space-y-1.5">
85
+ <p className="text-xs uppercase tracking-wide text-muted-foreground font-medium">
86
+ Claim Process
87
+ </p>
88
+ <ul className="text-xs text-muted-foreground space-y-1">
89
+ <li className="flex items-start gap-1.5">
90
+ <span className="text-primary mt-0.5">•</span>
91
+ <span>5% deductible of the Cover Amount</span>
92
+ </li>
93
+ <li className="flex items-start gap-1.5">
94
+ <span className="text-primary mt-0.5">•</span>
95
+ <span>14-day waiting period after loss event</span>
96
+ </li>
97
+ <li className="flex items-start gap-1.5">
98
+ <span className="text-primary mt-0.5">•</span>
99
+ <span>Claims accepted up to 35 days after cover ends</span>
100
+ </li>
101
+ </ul>
102
+ </div>
103
+
104
+ {/* Disclaimer */}
105
+ <p className="text-xs text-muted-foreground/70 leading-relaxed">
106
+ This is not a contract of insurance. Cover is provided on a discretionary basis.{" "}
107
+ <a
108
+ href={NEXUS_PROTOCOL_COVER_TERMS_URL}
109
+ target="_blank"
110
+ rel="noopener noreferrer"
111
+ className="text-primary hover:text-primary/80 underline underline-offset-2"
112
+ >
113
+ Read complete terms
114
+ </a>
115
+ </p>
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ }
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import type { ComponentType } from "react";
4
+ import { CheckCircle, type LucideProps } from "lucide-react";
5
+
6
+ import { parseCoverTiming } from "../utils";
7
+ import { NEXUS_AVAILABLE_TOKENS, NEXUS_PROTOCOL_COVER_TERMS_URL } from "../constants";
8
+ import type { CoverData } from "../hooks/useExistingCovers";
9
+ import { CoverAsset } from "@nexusmutual/sdk";
10
+ import { formatToken } from "@turtleclub/utils";
11
+ import { formatUnits } from "viem";
12
+
13
+ const CheckCircleIcon = CheckCircle as ComponentType<LucideProps>;
14
+
15
+ export interface ExistingCoverInfoProps {
16
+ cover: CoverData;
17
+ }
18
+
19
+ export function ExistingCoverInfo({ cover }: ExistingCoverInfoProps) {
20
+ const asset = CoverAsset[cover.coverAsset];
21
+ const token = NEXUS_AVAILABLE_TOKENS.find((token) => token.symbol === asset);
22
+
23
+ return (
24
+ <>
25
+ <div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20">
26
+ <div className="flex items-center gap-2 mb-2">
27
+ <CheckCircleIcon className="w-4 h-4 text-green-500" />
28
+ <span className="text-sm font-medium text-green-500">
29
+ You already have cover for this protocol
30
+ </span>
31
+ </div>
32
+ <p className="text-xs text-muted-foreground leading-relaxed">
33
+ Your deposit is protected against smart contract bugs, oracle failures, and governance
34
+ attacks on this protocol. See{" "}
35
+ <a
36
+ href={NEXUS_PROTOCOL_COVER_TERMS_URL}
37
+ target="_blank"
38
+ rel="noopener noreferrer"
39
+ className="text-primary hover:text-primary/80 underline underline-offset-2"
40
+ >
41
+ terms
42
+ </a>{" "}
43
+ for details.
44
+ </p>
45
+ </div>
46
+
47
+ <div className="grid grid-cols-2 gap-3">
48
+ <div className="p-3 rounded-lg bg-background border border-border text-center">
49
+ <p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
50
+ Cover Amount
51
+ </p>
52
+ {token?.decimals && (
53
+ <p className="text-lg font-bold text-foreground">
54
+ {Number(formatUnits(cover.amount, token?.decimals)).toLocaleString('en-US', { maximumFractionDigits: 6 })} {token?.symbol}
55
+ </p>
56
+ )}
57
+ </div>
58
+ <div className="p-3 rounded-lg bg-background border border-border text-center">
59
+ <p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">Period</p>
60
+ <p className="text-lg font-bold text-foreground">
61
+ {parseCoverTiming(cover.start, cover.period, cover.gracePeriod).periodDays} days
62
+ </p>
63
+ </div>
64
+ </div>
65
+
66
+ <div className="p-3 rounded-lg bg-background border border-border">
67
+ <div className="flex items-center justify-between">
68
+ <span className="text-xs text-muted-foreground">Cover ID</span>
69
+ <span className="text-sm font-medium text-foreground">#{cover.coverId.toString()}</span>
70
+ </div>
71
+ </div>
72
+ </>
73
+ );
74
+ }
@@ -1,87 +1,49 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
4
- import { Shield, ExternalLink, ChevronDown, ChevronUp, type LucideProps } from "lucide-react";
5
- import type { ComponentType } from "react";
6
- import { Button } from "@turtleclub/ui";
7
3
  import type { NexusCoverSectionProps } from "../types";
8
4
  import { getOpportunityAPY, formatAPY } from "../utils";
9
5
  import { useNexusProduct } from "../hooks/useNexusProduct";
10
- import { useCheckNexusMembership } from "../hooks/useCheckNexusMembership";
11
6
  import { CoverOfferCard } from "./CoverOfferCard";
12
7
  import { CoverRequestForm } from "./CoverRequestForm";
13
- import { NEXUS_MEMBERSHIP_URL } from "../constants";
14
- import { MembershipRequestCard } from "./MembershipRequestCard";
15
-
16
- const ShieldIcon = Shield as ComponentType<LucideProps>;
17
- const ExternalLinkIcon = ExternalLink as ComponentType<LucideProps>;
18
- const ChevronDownIcon = ChevronDown as ComponentType<LucideProps>;
19
- const ChevronUpIcon = ChevronUp as ComponentType<LucideProps>;
8
+ import { TurtleHooksProvider } from "@turtleclub/hooks";
20
9
 
21
10
  export function NexusCoverSection({
22
11
  opportunity,
23
- amountToCover,
24
12
  buyerAddress,
25
13
  onSuccess,
26
14
  onError,
27
15
  startExpanded = false,
28
16
  className,
29
17
  }: NexusCoverSectionProps) {
30
- const [isExpanded, setIsExpanded] = useState(startExpanded);
31
- const { isCoverable, productId, protocolName, coverProductName } = useNexusProduct(opportunity);
18
+ const { isCoverable, productId, protocolName, coverProductName, coverAssets } =
19
+ useNexusProduct(opportunity);
32
20
  const baseApy = getOpportunityAPY(opportunity);
33
- const { isMember, isLoading: isMembershipLoading } = useCheckNexusMembership(buyerAddress);
34
-
35
- if (isCoverable && isMembershipLoading) {
36
- return null;
37
- }
38
21
 
39
- //If the user is not a member but the opportunity is coverable, show the membership url.
40
- // if the user is not a member and the opportunity is not coverable, show nothing
41
- if (protocolName === "Unknown" || !buyerAddress || (!isCoverable && !isMember)) return null;
22
+ if (protocolName === "Unknown" || !buyerAddress) return null;
42
23
 
43
- if (isCoverable && !isMember) {
44
- return (
45
- <MembershipRequestCard
46
- isExpanded={isExpanded}
47
- onToggle={() => setIsExpanded(!isExpanded)}
48
- membershipUrl={NEXUS_MEMBERSHIP_URL}
49
- ShieldIcon={ShieldIcon}
50
- ExternalLinkIcon={ExternalLinkIcon}
51
- ChevronUpIcon={ChevronUpIcon}
52
- ChevronDownIcon={ChevronDownIcon}
53
- />
54
- );
55
- }
56
-
57
- if (isCoverable && productId !== null && isMember) {
58
- return (
59
- <div className={className}>
24
+ return (
25
+ <div className={className}>
26
+ {isCoverable && productId !== null && coverAssets && coverAssets.length > 0 ? (
60
27
  <CoverOfferCard
61
28
  productId={productId}
62
29
  opportunity={opportunity}
63
- amountToCover={amountToCover}
64
30
  buyerAddress={buyerAddress}
65
31
  protocolName={protocolName}
66
32
  coverProductName={coverProductName ?? "Protocol Cover"}
67
33
  onSuccess={onSuccess}
68
34
  onError={onError}
69
35
  startExpanded={startExpanded}
36
+ coverAvailableAssets={coverAssets || []}
70
37
  />
71
- </div>
72
- );
73
- }
74
-
75
- return (
76
- <div className={className}>
77
- <CoverRequestForm
78
- protocolName={protocolName}
79
- baseApyLabel={formatAPY(baseApy)}
80
- depositedAmount={amountToCover}
81
- onSuccess={onSuccess}
82
- onError={onError}
83
- startExpanded={startExpanded}
84
- />
38
+ ) : (
39
+ <CoverRequestForm
40
+ protocolName={protocolName}
41
+ baseApyLabel={formatAPY(baseApy)}
42
+ onSuccess={onSuccess}
43
+ onError={onError}
44
+ startExpanded={startExpanded}
45
+ />
46
+ )}
85
47
  </div>
86
48
  );
87
49
  }
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import type { ComponentType } from "react";
4
+ import { ExternalLink, type LucideProps } from "lucide-react";
5
+ import { Button, Skeleton, Tooltip, TooltipTrigger, TooltipContent, Card } from "@turtleclub/ui";
6
+ import { NEXUS_MEMBERSHIP_URL } from "../constants";
7
+
8
+ const ExternalLinkIcon = ExternalLink as ComponentType<LucideProps>;
9
+
10
+ export interface PurchaseButtonSectionProps {
11
+ isWrongNetwork: boolean;
12
+ isMembershipLoading: boolean;
13
+ isMember: boolean;
14
+ disabledReason: string | null;
15
+ purchaseButton: React.ReactNode;
16
+ onSwitchNetwork: () => void;
17
+ needsApproval?: boolean;
18
+ isCheckingApproval?: boolean;
19
+ onApprove?: () => Promise<void>;
20
+ isApproving?: boolean;
21
+ tokenSymbol?: string;
22
+ }
23
+
24
+ export function PurchaseButtonSection({
25
+ isWrongNetwork,
26
+ isMembershipLoading,
27
+ isMember,
28
+ disabledReason,
29
+ purchaseButton,
30
+ onSwitchNetwork,
31
+ needsApproval = false,
32
+ isCheckingApproval = false,
33
+ onApprove,
34
+ isApproving = false,
35
+ tokenSymbol,
36
+ }: PurchaseButtonSectionProps) {
37
+ if (isWrongNetwork) {
38
+ return (
39
+ <Card variant="border" className="h-fit">
40
+ <Button
41
+ onClick={onSwitchNetwork}
42
+ className="text-primary hover:text-primary/80 w-full cursor-pointer font-medium underline transition-colors"
43
+ >
44
+ Switch to Ethereum network to Buy Cover
45
+ </Button>
46
+ </Card>
47
+ );
48
+ }
49
+
50
+ if (isMembershipLoading) {
51
+ return <Skeleton className="h-10 w-full bg-neutral-alpha-10" />;
52
+ }
53
+
54
+ if (!isMember) {
55
+ return (
56
+ <div className="space-y-3">
57
+ <div className="p-3 rounded-lg bg-background/50 border border-border">
58
+ <p className="text-xs text-muted-foreground leading-relaxed">
59
+ <span className="text-primary font-medium">Membership required.</span> Join Nexus Mutual
60
+ to protect your deposit against smart contract exploits, oracle failures, and
61
+ protocol-specific risks.
62
+ </p>
63
+ </div>
64
+ <Button asChild className="w-full" variant="green">
65
+ <a
66
+ href={NEXUS_MEMBERSHIP_URL}
67
+ target="_blank"
68
+ rel="noopener noreferrer"
69
+ className="flex items-center justify-center gap-2"
70
+ >
71
+ Become a Member
72
+ <ExternalLinkIcon className="w-4 h-4" />
73
+ </a>
74
+ </Button>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ if (needsApproval && tokenSymbol && onApprove) {
80
+ return (
81
+ <div className="space-y-3">
82
+ <Button
83
+ onClick={onApprove}
84
+ disabled={isApproving || isCheckingApproval}
85
+ className="w-full"
86
+ variant="green"
87
+ >
88
+ {isApproving || isCheckingApproval ? "Approving..." : `Approve ${tokenSymbol} spending`}
89
+ </Button>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ if (disabledReason) {
95
+ return (
96
+ <Tooltip>
97
+ <TooltipTrigger asChild>
98
+ <div>{purchaseButton}</div>
99
+ </TooltipTrigger>
100
+ <TooltipContent side="top">{disabledReason}</TooltipContent>
101
+ </Tooltip>
102
+ );
103
+ }
104
+
105
+ return <>{purchaseButton}</>;
106
+ }
@@ -4,5 +4,29 @@ export const NEXUS_KYC_REQUIREMENTS_URL =
4
4
  "https://docs.nexusmutual.io/overview/membership/#kyc-requirements";
5
5
  export const NEXUS_MEMBERSHIP_URL = "https://app.nexusmutual.io/become-member";
6
6
  export const NEXUS_COVER_NFT_ADDRESS = "0xcafeaca76be547f14d0220482667b42d8e7bc3eb";
7
- export const USER_NFTS_API_URL = "https://lumon.turtle.xyz/query/token/erc721_portfolio";
7
+ export const NEXUS_COVER_ROUTER = "0xcafeac0fF5dA0A2777d915531bfA6B29d282Ee62";
8
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;
@@ -1,54 +1,32 @@
1
- import { useState, useEffect } from 'react';
2
- import { createPublicClient, http, getAddress } from 'viem';
3
- import { mainnet } from 'viem/chains';
1
+ import { getAddress } from "viem";
2
+ import type { Abi } from "viem";
3
+ import { useReadContract } from "wagmi";
4
4
  import nexusSdk from "@nexusmutual/sdk";
5
5
 
6
- const publicClient = createPublicClient({
7
- chain: mainnet,
8
- transport: http(),
9
- });
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;
10
14
 
11
- export function useCheckNexusMembership(userAddress: string | undefined) {
12
- const [isMember, setIsMember] = useState<boolean>(false);
13
- const [isLoading, setIsLoading] = useState<boolean>(true);
14
- const [error, setError] = useState<Error | null>(null);
15
-
16
- useEffect(() => {
17
- if (!userAddress) {
18
- setIsMember(false);
19
- setIsLoading(false);
20
- return;
21
- }
22
-
23
- const checkMembership = async () => {
24
- setIsLoading(true);
25
- setError(null);
26
- try {
27
- const masterAddress = nexusSdk.addresses?.NXMaster;
28
- const masterAbi = nexusSdk.abis?.NXMaster;
29
-
30
- if (!masterAddress || !masterAbi) {
31
- throw new Error('Nexus SDK not configured correctly for NXMaster.');
32
- }
33
- const checksummedAddress = getAddress(userAddress);
34
- const result = await publicClient.readContract({
35
- address: getAddress(masterAddress),
36
- abi: masterAbi,
37
- functionName: 'isMember',
38
- args: [checksummedAddress],
39
- });
40
- setIsMember(result as boolean);
41
- } catch (err: any) {
42
- console.error("Error checking membership:", err.message);
43
- setError(err);
44
- setIsMember(false);
45
- } finally {
46
- setIsLoading(false);
47
- }
48
- };
49
-
50
- checkMembership();
51
- }, [userAddress]);
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
+ });
52
30
 
53
31
  return { isMember, isLoading, error };
54
32
  }