@turtleclub/opportunities 0.1.0-beta.0 → 0.1.0-beta.10

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 CHANGED
@@ -3,6 +3,48 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [0.1.0-beta.10](https://github.com/turtle-dao/turtle-tools/compare/@turtleclub/opportunities@0.1.0-beta.9...@turtleclub/opportunities@0.1.0-beta.10) (2025-12-12)
7
+
8
+ ### Features
9
+
10
+ - rony/tur 588 create a reusable cover component ([#196](https://github.com/turtle-dao/turtle-tools/issues/196)) ([34a77b3](https://github.com/turtle-dao/turtle-tools/commit/34a77b3e4befc4bc5a4923f8e75694f7c980c280))
11
+
12
+ # [0.1.0-beta.9](https://github.com/turtle-dao/turtle-tools/compare/@turtleclub/opportunities@0.1.0-beta.8...@turtleclub/opportunities@0.1.0-beta.9) (2025-12-10)
13
+
14
+ **Note:** Version bump only for package @turtleclub/opportunities
15
+
16
+ # [0.1.0-beta.8](https://github.com/turtle-dao/turtle-tools/compare/@turtleclub/opportunities@0.1.0-beta.7...@turtleclub/opportunities@0.1.0-beta.8) (2025-12-10)
17
+
18
+ **Note:** Version bump only for package @turtleclub/opportunities
19
+
20
+ # [0.1.0-beta.7](https://github.com/turtle-dao/turtle-tools/compare/@turtleclub/opportunities@0.1.0-beta.6...@turtleclub/opportunities@0.1.0-beta.7) (2025-12-09)
21
+
22
+ **Note:** Version bump only for package @turtleclub/opportunities
23
+
24
+ # [0.1.0-beta.6](https://github.com/turtle-dao/turtle-tools/compare/@turtleclub/opportunities@0.1.0-beta.5...@turtleclub/opportunities@0.1.0-beta.6) (2025-12-04)
25
+
26
+ **Note:** Version bump only for package @turtleclub/opportunities
27
+
28
+ # [0.1.0-beta.5](https://github.com/turtle-dao/turtle-tools/compare/@turtleclub/opportunities@0.1.0-beta.4...@turtleclub/opportunities@0.1.0-beta.5) (2025-12-03)
29
+
30
+ **Note:** Version bump only for package @turtleclub/opportunities
31
+
32
+ # [0.1.0-beta.4](https://github.com/turtle-dao/turtle-tools/compare/@turtleclub/opportunities@0.1.0-beta.3...@turtleclub/opportunities@0.1.0-beta.4) (2025-12-03)
33
+
34
+ **Note:** Version bump only for package @turtleclub/opportunities
35
+
36
+ # [0.1.0-beta.3](https://github.com/turtle-dao/turtle-tools/compare/@turtleclub/opportunities@0.1.0-beta.2...@turtleclub/opportunities@0.1.0-beta.3) (2025-12-02)
37
+
38
+ **Note:** Version bump only for package @turtleclub/opportunities
39
+
40
+ # [0.1.0-beta.2](https://github.com/turtle-dao/turtle-tools/compare/@turtleclub/opportunities@0.1.0-beta.1...@turtleclub/opportunities@0.1.0-beta.2) (2025-11-28)
41
+
42
+ **Note:** Version bump only for package @turtleclub/opportunities
43
+
44
+ # [0.1.0-beta.1](https://github.com/turtle-dao/turtle-tools/compare/@turtleclub/opportunities@0.1.0-beta.0...@turtleclub/opportunities@0.1.0-beta.1) (2025-11-26)
45
+
46
+ **Note:** Version bump only for package @turtleclub/opportunities
47
+
6
48
  # 0.1.0-beta.0 (2025-11-25)
7
49
 
8
50
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turtleclub/opportunities",
3
- "version": "0.1.0-beta.0",
3
+ "version": "0.1.0-beta.10",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -9,10 +9,13 @@
9
9
  "typecheck": "tsc --noEmit"
10
10
  },
11
11
  "dependencies": {
12
- "@turtleclub/hooks": "0.5.0-beta.0",
13
- "@turtleclub/ui": "0.7.0-beta.1",
12
+ "@nexusmutual/sdk": "^1.26.0",
13
+ "@turtleclub/hooks": "0.5.0-beta.5",
14
+ "@turtleclub/ui": "0.7.0-beta.6",
14
15
  "@turtleclub/utils": "0.4.0-beta.0",
16
+ "ethers": "^6.15.0",
15
17
  "jotai": "^2.10.3",
18
+ "lucide-react": "^0.542.0",
16
19
  "viem": "^2.21.54"
17
20
  },
18
21
  "peerDependencies": {
@@ -24,5 +27,5 @@
24
27
  "@types/react-dom": "^18.3.5",
25
28
  "typescript": "^5.7.2"
26
29
  },
27
- "gitHead": "d896f0404e2bcb958a52def686e57893b4c68f6b"
30
+ "gitHead": "7afd1f23d40ef0617af745b33364e7c252c46b04"
28
31
  }
@@ -0,0 +1,291 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Shield, ChevronDown, ChevronUp, type LucideProps } from "lucide-react";
5
+ import type { ComponentType } from "react";
6
+ import {
7
+ Button,
8
+ Slider,
9
+ Skeleton,
10
+ Checkbox,
11
+ Tooltip,
12
+ TooltipTrigger,
13
+ TooltipContent,
14
+ } from "@turtleclub/ui";
15
+ import { useCoverQuote } from "../hooks/useCoverQuote";
16
+ import { getOpportunityAPY, calculateAdjustedAPY, formatAPY, formatEthAmount } from "../utils";
17
+ import { NEXUS_KYC_REQUIREMENTS_URL, NEXUS_PROTOCOL_COVER_TERMS_URL } from "../constants";
18
+ import type { CoverOfferCardProps } from "../types";
19
+ const ShieldIcon = Shield as ComponentType<LucideProps>;
20
+ const ChevronDownIcon = ChevronDown as ComponentType<LucideProps>;
21
+ const ChevronUpIcon = ChevronUp as ComponentType<LucideProps>;
22
+
23
+ export function CoverOfferCard({
24
+ productId,
25
+ opportunity,
26
+ amountToCover,
27
+ buyerAddress,
28
+ onPurchase,
29
+ onDismiss,
30
+ isPurchasing = false,
31
+ purchaseError,
32
+ }: CoverOfferCardProps) {
33
+ const [isExpanded, setIsExpanded] = useState(true);
34
+ const [hasAgreedToTerms, setHasAgreedToTerms] = useState(false);
35
+
36
+ const { coverPeriod, setCoverPeriod, isLoading, error, quoteResult, premiumEth, yearlyCostPerc } =
37
+ useCoverQuote({
38
+ productId,
39
+ buyerAddress,
40
+ amountToCover,
41
+ });
42
+
43
+ const originalAPY = getOpportunityAPY(opportunity);
44
+ const adjustedAPY =
45
+ yearlyCostPerc !== null ? calculateAdjustedAPY(originalAPY, yearlyCostPerc) : null;
46
+ const coverCostPerc = yearlyCostPerc !== null ? yearlyCostPerc * 100 : null;
47
+
48
+ const handlePurchase = () => {
49
+ if (isPurchasing) return;
50
+ if (quoteResult) {
51
+ onPurchase(quoteResult, coverPeriod);
52
+ }
53
+ };
54
+
55
+ const disabledReason = (() => {
56
+ if (!hasAgreedToTerms) return "Checkbox not marked";
57
+ if (!quoteResult) return "Quote not ready yet";
58
+ if (isLoading) return "Fetching quote...";
59
+ if (error) return error;
60
+ if (isPurchasing) return "Purchasing cover...";
61
+ return null;
62
+ })();
63
+ const isPurchaseDisabled =
64
+ !quoteResult || isLoading || !!error || !hasAgreedToTerms || isPurchasing;
65
+
66
+ const purchaseButton = (
67
+ <Button
68
+ onClick={handlePurchase}
69
+ disabled={isPurchaseDisabled}
70
+ className="w-full"
71
+ variant="green"
72
+ >
73
+ {isLoading ? "Getting Quote..." : isPurchasing ? "Purchasing Cover..." : "Purchase Cover"}
74
+ </Button>
75
+ );
76
+
77
+ return (
78
+ <div className="rounded-xl bg-gradient-to-br from-primary/10 to-card border border-primary/20 overflow-hidden">
79
+ <button
80
+ onClick={() => setIsExpanded(!isExpanded)}
81
+ className="w-full p-4 flex items-center justify-between hover:bg-primary/5 transition-colors"
82
+ >
83
+ <div className="flex items-center gap-3">
84
+ <div className="p-2 rounded-lg bg-primary/10 border border-primary/20">
85
+ <ShieldIcon className="w-5 h-5 text-primary" />
86
+ </div>
87
+ <div className="text-left">
88
+ <div className="flex items-center gap-2">
89
+ <span className="text-sm font-semibold text-foreground">Protect Your Deposit</span>
90
+ <span className="px-2 py-0.5 text-[10px] font-medium text-primary bg-primary/10 rounded-full border border-primary/20">
91
+ NEW
92
+ </span>
93
+ </div>
94
+ <p className="text-xs text-muted-foreground">Nexus Mutual Single Protocol Cover</p>
95
+ </div>
96
+ </div>
97
+ <div className="flex items-center gap-2">
98
+ {onDismiss && (
99
+ <Button
100
+ variant="ghost"
101
+ size="icon"
102
+ onClick={(e) => {
103
+ e.stopPropagation();
104
+ onDismiss();
105
+ }}
106
+ className="h-6 w-6 text-muted-foreground hover:text-foreground"
107
+ >
108
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
109
+ <path
110
+ strokeLinecap="round"
111
+ strokeLinejoin="round"
112
+ strokeWidth={2}
113
+ d="M6 18L18 6M6 6l12 12"
114
+ />
115
+ </svg>
116
+ </Button>
117
+ )}
118
+ {isExpanded ? (
119
+ <ChevronUpIcon className="w-5 h-5 text-muted-foreground" />
120
+ ) : (
121
+ <ChevronDownIcon className="w-5 h-5 text-muted-foreground" />
122
+ )}
123
+ </div>
124
+ </button>
125
+
126
+ {/* Expandable Content */}
127
+ {isExpanded && (
128
+ <div className="px-4 pb-4 space-y-4">
129
+ {/* Value Proposition */}
130
+ <div className="p-3 rounded-lg bg-background/50 border border-border">
131
+ <p className="text-xs text-muted-foreground leading-relaxed">
132
+ <span className="text-primary font-medium">Safeguard your yield</span> against smart
133
+ contract exploits, oracle failures, and protocol-specific risks. Deposit with
134
+ confidence.
135
+ </p>
136
+ </div>
137
+
138
+ {/* APY Comparison */}
139
+ <div className="grid grid-cols-3 gap-3">
140
+ <div className="p-3 rounded-lg bg-background border border-border text-center">
141
+ <p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
142
+ Base APR
143
+ </p>
144
+ <p className="text-lg font-bold text-foreground">{formatAPY(originalAPY)}</p>
145
+ </div>
146
+ <div className="p-3 rounded-lg bg-background border border-border text-center">
147
+ <p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
148
+ Cover Cost
149
+ </p>
150
+ {isLoading ? (
151
+ <Skeleton className="h-7 w-12 mx-auto" />
152
+ ) : coverCostPerc !== null ? (
153
+ <>
154
+ <p className="text-lg font-bold text-orange-400">-{coverCostPerc.toFixed(2)}%</p>
155
+ <p className="text-[10px] text-muted-foreground">per year</p>
156
+ </>
157
+ ) : (
158
+ <p className="text-lg font-bold text-muted-foreground">—</p>
159
+ )}
160
+ </div>
161
+ <div className="p-3 rounded-lg bg-primary/5 border border-primary/20 text-center">
162
+ <p className="text-[10px] uppercase tracking-wide text-primary/70 mb-1">Net APY</p>
163
+ {isLoading ? (
164
+ <Skeleton className="h-7 w-12 mx-auto" />
165
+ ) : adjustedAPY !== null ? (
166
+ <>
167
+ <p className="text-lg font-bold text-primary">{formatAPY(adjustedAPY)}</p>
168
+ <p className="text-[10px] text-primary/60">protected</p>
169
+ </>
170
+ ) : (
171
+ <p className="text-lg font-bold text-muted-foreground">—</p>
172
+ )}
173
+ </div>
174
+ </div>
175
+
176
+ {/* Coverage Period Slider */}
177
+ <div className="space-y-3">
178
+ <div className="flex items-center justify-between">
179
+ <span className="text-xs text-muted-foreground">Coverage Period</span>
180
+ <span className="text-sm font-medium text-foreground">
181
+ {coverPeriod} days
182
+ {premiumEth && (
183
+ <span className="text-muted-foreground ml-1">
184
+ ({formatEthAmount(premiumEth)} ETH)
185
+ </span>
186
+ )}
187
+ </span>
188
+ </div>
189
+ <Slider
190
+ value={[coverPeriod]}
191
+ onValueChange={(value) => setCoverPeriod(value[0])}
192
+ min={28}
193
+ max={365}
194
+ step={1}
195
+ className="w-full"
196
+ />
197
+ <div className="flex justify-between text-[10px] text-muted-foreground">
198
+ <span>28 days</span>
199
+ <span>365 days</span>
200
+ </div>
201
+ </div>
202
+
203
+ {/* Premium Details */}
204
+ <div className="flex items-center justify-between p-3 rounded-lg bg-background border border-border">
205
+ <div className="flex items-center gap-2">
206
+ <ShieldIcon className="w-4 h-4 text-primary" />
207
+ <span className="text-sm text-foreground">Premium</span>
208
+ </div>
209
+ {isLoading ? (
210
+ <Skeleton className="h-5 w-20" />
211
+ ) : premiumEth ? (
212
+ <span className="text-sm font-semibold text-foreground">
213
+ {formatEthAmount(premiumEth)} ETH
214
+ </span>
215
+ ) : error ? (
216
+ <span className="text-sm text-destructive">No cover available</span>
217
+ ) : (
218
+ <span className="text-sm text-muted-foreground">—</span>
219
+ )}
220
+ </div>
221
+
222
+ {/* Terms Agreement */}
223
+ <div className="space-y-3 p-3 rounded-lg bg-muted/20 border border-border ">
224
+ <p className="text-[10px] text-muted-foreground leading-relaxed">
225
+ By buying Nexus Mutual Bundled Protocol Cover, you agree to the{" "}
226
+ <a
227
+ href={NEXUS_PROTOCOL_COVER_TERMS_URL}
228
+ target="_blank"
229
+ rel="noopener noreferrer"
230
+ className="text-primary hover:text-primary/80 underline underline-offset-2"
231
+ >
232
+ terms
233
+ </a>{" "}
234
+ and{" "}
235
+ <a
236
+ href={NEXUS_PROTOCOL_COVER_TERMS_URL}
237
+ target="_blank"
238
+ rel="noopener noreferrer"
239
+ className="text-primary hover:text-primary/80 underline underline-offset-2"
240
+ >
241
+ conditions
242
+ </a>
243
+ </p>
244
+
245
+ <label className="flex items-start gap-2 cursor-pointer group">
246
+ <div className="relative flex-shrink-0 mt-0.5">
247
+ <Checkbox
248
+ checked={hasAgreedToTerms}
249
+ onCheckedChange={(hasAgreedToTerms) =>
250
+ setHasAgreedToTerms(hasAgreedToTerms === true)
251
+ }
252
+ />
253
+ </div>
254
+ <span className="text-[10px] text-muted-foreground leading-relaxed group-hover:text-foreground/70 transition-colors">
255
+ I confirm that I do not reside in the{" "}
256
+ <a
257
+ href={NEXUS_KYC_REQUIREMENTS_URL}
258
+ target="_blank"
259
+ rel="noopener noreferrer"
260
+ className="text-primary hover:text-primary/80 underline underline-offset-2"
261
+ onClick={(e) => e.stopPropagation()}
262
+ >
263
+ countries listed here
264
+ </a>
265
+ , and acknowledge that in the event of a loss, I will be required to join as a
266
+ member of Nexus Mutual to file my claim.
267
+ </span>
268
+ </label>
269
+ </div>
270
+
271
+ {/* Purchase Button */}
272
+ {disabledReason ? (
273
+ <Tooltip>
274
+ <TooltipTrigger asChild>
275
+ <div>{purchaseButton}</div>
276
+ </TooltipTrigger>
277
+ <TooltipContent side="top">{disabledReason}</TooltipContent>
278
+ </Tooltip>
279
+ ) : (
280
+ purchaseButton
281
+ )}
282
+ {Boolean(purchaseError) && (
283
+ <p className="text-[10px] text-destructive mt-1">
284
+ Failed to purchase cover. Please try again.
285
+ </p>
286
+ )}
287
+ </div>
288
+ )}
289
+ </div>
290
+ );
291
+ }
@@ -0,0 +1,152 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { Button } from "@turtleclub/ui";
5
+ import { ShieldAlert, Send, ChevronDown, ChevronUp, type LucideProps } from "lucide-react";
6
+ import type { ComponentType } from "react";
7
+
8
+ const ShieldAlertIcon = ShieldAlert as ComponentType<LucideProps>;
9
+ const SendIcon = Send as ComponentType<LucideProps>;
10
+ const ChevronUpIcon = ChevronUp as ComponentType<LucideProps>;
11
+ const ChevronDownIcon = ChevronDown as ComponentType<LucideProps>;
12
+
13
+ export interface CoverRequestFormProps {
14
+ protocolName: string;
15
+ baseApyLabel?: string;
16
+ onDismiss?: () => void;
17
+ onSubmit?: (desiredApySacrifice: number | null) => void;
18
+ }
19
+
20
+ export function CoverRequestForm({
21
+ protocolName,
22
+ baseApyLabel,
23
+ onDismiss,
24
+ onSubmit,
25
+ }: CoverRequestFormProps) {
26
+ const [desiredApySacrifice, setDesiredApySacrifice] = useState("");
27
+ const [inputError, setInputError] = useState<string | null>(null);
28
+ const [isExpanded, setIsExpanded] = useState(true);
29
+
30
+ const parsedSacrifice = useMemo(() => {
31
+ if (!desiredApySacrifice.trim()) return null;
32
+ const value = Number(desiredApySacrifice);
33
+ return Number.isFinite(value) ? value : null;
34
+ }, [desiredApySacrifice]);
35
+
36
+ const handleSubmit = () => {
37
+ if (desiredApySacrifice.trim() && parsedSacrifice === null) {
38
+ setInputError("Enter a valid non-negative number");
39
+ return;
40
+ }
41
+ if (parsedSacrifice !== null && parsedSacrifice < 0) {
42
+ setInputError("APY sacrifice cannot be negative");
43
+ return;
44
+ }
45
+ setInputError(null);
46
+
47
+ onSubmit?.(parsedSacrifice);
48
+
49
+ if (!onSubmit) {
50
+ // Basic placeholder to surface the interaction during integration.
51
+ console.info("Cover request submitted", {
52
+ desiredApySacrifice: parsedSacrifice,
53
+ protocolName,
54
+ });
55
+ }
56
+ };
57
+
58
+ return (
59
+ <div className="rounded-xl bg-gradient-to-br from-primary/5 to-card border border-primary/20">
60
+ <button
61
+ onClick={() => setIsExpanded(!isExpanded)}
62
+ className="w-full p-4 flex items-center justify-between hover:bg-primary/5 transition-colors"
63
+ >
64
+ <div className="flex items-center gap-3 text-left">
65
+ <div className="p-2 rounded-lg bg-primary/10 border border-primary/20">
66
+ <ShieldAlertIcon className="w-5 h-5 text-primary" />
67
+ </div>
68
+ <div className="space-y-1">
69
+ <p className="text-sm font-semibold text-foreground">
70
+ Request coverage for this opportunity
71
+ </p>
72
+ <p className="text-xs text-muted-foreground leading-relaxed">
73
+ We don&apos;t yet have Nexus Mutual cover available for{" "}
74
+ <span className="font-medium">{protocolName}</span>.
75
+ </p>
76
+ </div>
77
+ </div>
78
+ <div className="flex items-center gap-2">
79
+ {onDismiss && (
80
+ <Button
81
+ variant="ghost"
82
+ size="icon"
83
+ onClick={(e) => {
84
+ e.stopPropagation();
85
+ onDismiss();
86
+ }}
87
+ className="h-6 w-6 text-muted-foreground hover:text-foreground"
88
+ >
89
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
90
+ <path
91
+ strokeLinecap="round"
92
+ strokeLinejoin="round"
93
+ strokeWidth={2}
94
+ d="M6 18L18 6M6 6l12 12"
95
+ />
96
+ </svg>
97
+ </Button>
98
+ )}
99
+ {isExpanded ? (
100
+ <ChevronUpIcon className="w-5 h-5 text-muted-foreground" />
101
+ ) : (
102
+ <ChevronDownIcon className="w-5 h-5 text-muted-foreground" />
103
+ )}
104
+ </div>
105
+ </button>
106
+
107
+ {isExpanded && (
108
+ <div className="p-4 space-y-4">
109
+ <div className="space-y-2">
110
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
111
+ <span className="font-medium text-foreground">{protocolName}</span>
112
+ {baseApyLabel && <span>Current APR: {baseApyLabel}</span>}
113
+ </div>
114
+ <label className="text-xs text-muted-foreground">
115
+ APY you&apos;re willing to sacrifice for cover (percentage points)
116
+ </label>
117
+ <input
118
+ type="text"
119
+ value={desiredApySacrifice}
120
+ onChange={(e) => {
121
+ const sanitized = e.target.value.replace(/[^0-9.]/g, "");
122
+ setDesiredApySacrifice(sanitized);
123
+ }}
124
+ placeholder="e.g. 1.5"
125
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/30"
126
+ />
127
+ {inputError && <p className="text-[11px] text-destructive">{inputError}</p>}
128
+ <p className="text-[11px] text-muted-foreground">
129
+ We&apos;ll use this to gauge interest and bring cover to the protocols you care about.
130
+ </p>
131
+ </div>
132
+
133
+ <div className="flex justify-end gap-2">
134
+ {onDismiss && (
135
+ <Button
136
+ variant="ghost"
137
+ onClick={onDismiss}
138
+ className="text-muted-foreground hover:text-foreground"
139
+ >
140
+ Dismiss
141
+ </Button>
142
+ )}
143
+ <Button onClick={handleSubmit} className="flex items-center gap-2" variant="green">
144
+ <SendIcon className="w-4 h-4" />
145
+ Request Cover
146
+ </Button>
147
+ </div>
148
+ </div>
149
+ )}
150
+ </div>
151
+ );
152
+ }
@@ -0,0 +1,65 @@
1
+ "use client";
2
+
3
+ import type { NexusCoverSectionProps, NexusQuoteResult } from "../types";
4
+ import { getOpportunityAPY, formatAPY } from "../utils";
5
+ import { useNexusProduct } from "../hooks/useNexusProduct";
6
+ import { useNexusPurchase } from "../hooks/useNexusPurchase";
7
+ import { CoverOfferCard } from "./CoverOfferCard";
8
+ import { CoverRequestForm } from "./CoverRequestForm";
9
+
10
+ export function NexusCoverSection({
11
+ opportunity,
12
+ amountToCover,
13
+ buyerAddress,
14
+ onSuccess,
15
+ onError,
16
+ onDismiss,
17
+ className,
18
+ }: NexusCoverSectionProps) {
19
+ const { isCoverable, productId, protocolName } = useNexusProduct(opportunity);
20
+ const baseApy = getOpportunityAPY(opportunity);
21
+
22
+ const {
23
+ purchase,
24
+ isPurchasing,
25
+ error: purchaseError,
26
+ } = useNexusPurchase({
27
+ buyerAddress,
28
+ productId: productId ?? 0,
29
+ amountToCover,
30
+ onSuccess: onSuccess,
31
+ onError: onError,
32
+ });
33
+
34
+ const handlePurchase = (quoteResult: NexusQuoteResult, coverPeriod: number) => {
35
+ return purchase(quoteResult, coverPeriod);
36
+ };
37
+
38
+ if (isCoverable && productId !== null) {
39
+ return (
40
+ <div className={className}>
41
+ <CoverOfferCard
42
+ productId={productId}
43
+ opportunity={opportunity}
44
+ amountToCover={amountToCover}
45
+ buyerAddress={buyerAddress}
46
+ onPurchase={handlePurchase}
47
+ isPurchasing={isPurchasing}
48
+ purchaseError={purchaseError}
49
+ onDismiss={onDismiss}
50
+ />
51
+ </div>
52
+ );
53
+ }
54
+
55
+ return (
56
+ <div className={className}>
57
+ <CoverRequestForm
58
+ protocolName={protocolName}
59
+ baseApyLabel={formatAPY(baseApy)}
60
+ onDismiss={onDismiss}
61
+ onSubmit={() => {}}
62
+ />
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,6 @@
1
+ export const NEXUS_PROTOCOL_COVER_TERMS_URL =
2
+ "https://nexusmutual.io/pages/ProtocolCoverv1.0.pdf";
3
+
4
+ export const NEXUS_KYC_REQUIREMENTS_URL =
5
+ "https://docs.nexusmutual.io/overview/membership/#kyc-requirements";
6
+
@@ -0,0 +1,124 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import * as ethers from "ethers";
3
+ import type { NexusQuoteResult, CoverQuoteState } from "../types";
4
+ import { CoverAsset, NexusSDK } from "@nexusmutual/sdk";
5
+
6
+ interface UseCoverQuoteOptions {
7
+ productId: number;
8
+ buyerAddress: string;
9
+ amountToCover: string;
10
+ /** Debounce delay in ms */
11
+ debounceMs?: number;
12
+ }
13
+
14
+ interface UseCoverQuoteReturn extends CoverQuoteState {
15
+ setCoverPeriod: (period: number) => void;
16
+ refetch: () => void;
17
+ }
18
+
19
+ let nexusSdkInstance: import("@nexusmutual/sdk").NexusSDK | null = null;
20
+ let coverAssetEnum: typeof import("@nexusmutual/sdk").CoverAsset | null = null;
21
+
22
+ const TERMS_IPFS_CID = "QmXUzXDMbeKSCewUie34vPD7mCAGnshi4ULRy4h7DLmoRS";
23
+
24
+ async function getNexusSdk() {
25
+ if (!nexusSdkInstance || !coverAssetEnum) {
26
+ const { NexusSDK, CoverAsset } = await import("@nexusmutual/sdk");
27
+ nexusSdkInstance = new NexusSDK();
28
+ coverAssetEnum = CoverAsset;
29
+ }
30
+
31
+ return { nexusSdk: nexusSdkInstance, CoverAsset: coverAssetEnum };
32
+ }
33
+
34
+
35
+ async function fetchNexusQuote(
36
+ productId: number,
37
+ buyerAddress: string,
38
+ amount: string,
39
+ period: number
40
+ ): Promise<NexusQuoteResult> {
41
+
42
+ //const { nexusSdk, CoverAsset } = await getNexusSdk();
43
+ const nexusSdk = new NexusSDK();
44
+ const amountWei = ethers.parseEther(amount).toString();
45
+
46
+ const { result, error } = await nexusSdk.quote.getQuoteAndBuyCoverInputs({
47
+ productId,
48
+ amount: amountWei,
49
+ period,
50
+ coverAsset: CoverAsset.ETH,
51
+ paymentAsset: CoverAsset.ETH,
52
+ buyerAddress,
53
+ ipfsCidOrContent: TERMS_IPFS_CID,
54
+ });
55
+
56
+ if (error) {
57
+ throw new Error(error.message || "Failed to fetch Nexus quote");
58
+ }
59
+
60
+ return result as NexusQuoteResult;
61
+ }
62
+
63
+ export function useCoverQuote({
64
+ productId,
65
+ buyerAddress,
66
+ amountToCover,
67
+ debounceMs = 500,
68
+ }: UseCoverQuoteOptions): UseCoverQuoteReturn {
69
+ const [coverPeriod, setCoverPeriod] = useState(30);
70
+ const [isLoading, setIsLoading] = useState(false);
71
+ const [error, setError] = useState<string | null>(null);
72
+ const [quoteResult, setQuoteResult] = useState<NexusQuoteResult | null>(null);
73
+ const [premiumEth, setPremiumEth] = useState<string | null>(null);
74
+ const [yearlyCostPerc, setYearlyCostPerc] = useState<number | null>(null);
75
+
76
+ const fetchQuote = useCallback(async () => {
77
+ console.log("fetchQuote", productId, buyerAddress, amountToCover, coverPeriod);
78
+ if (!productId || !buyerAddress || !amountToCover || parseFloat(amountToCover) <= 0) {
79
+ console.error("Validation failed", productId, buyerAddress, amountToCover, coverPeriod);
80
+ return;
81
+ }
82
+
83
+ setIsLoading(true);
84
+ setError(null);
85
+
86
+ try {
87
+ const result = await fetchNexusQuote(productId, buyerAddress, amountToCover, coverPeriod);
88
+
89
+ setQuoteResult(result);
90
+ setPremiumEth(ethers.formatEther(result.displayInfo.premiumInAsset));
91
+ setYearlyCostPerc(result.displayInfo.yearlyCostPerc);
92
+ } catch (err: unknown) {
93
+ const errorMessage = err instanceof Error ? err.message : "Failed to fetch quote";
94
+ console.error("Cover quote error:", err);
95
+ setError(errorMessage);
96
+ setQuoteResult(null);
97
+ setPremiumEth(null);
98
+ setYearlyCostPerc(null);
99
+ } finally {
100
+ setIsLoading(false);
101
+ }
102
+ }, [productId, buyerAddress, amountToCover, coverPeriod]);
103
+
104
+ // Debounced fetch on input changes
105
+ useEffect(() => {
106
+ const timeoutId = setTimeout(() => {
107
+ fetchQuote();
108
+ }, debounceMs);
109
+
110
+ return () => clearTimeout(timeoutId);
111
+ }, [fetchQuote, debounceMs]);
112
+
113
+ return {
114
+ coverPeriod,
115
+ setCoverPeriod,
116
+ isLoading,
117
+ error,
118
+ quoteResult,
119
+ premiumEth,
120
+ yearlyCostPerc,
121
+ refetch: fetchQuote,
122
+ };
123
+ }
124
+
@@ -0,0 +1,74 @@
1
+ import { useMemo } from "react";
2
+ import { 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
+ return {
57
+ protocolName: protocolName ?? "Unknown",
58
+ productId: nexusProduct?.id ?? null,
59
+ coverProductName: nexusProduct?.name ?? null,
60
+ isCoverable: !!nexusProduct,
61
+ };
62
+ };
63
+
64
+ export interface NexusProductLookup {
65
+ protocolName: string;
66
+ productId: number | null;
67
+ coverProductName: string | null;
68
+ isCoverable: boolean;
69
+ }
70
+
71
+ export function useNexusProduct(opportunity: Opportunity | null | undefined): NexusProductLookup {
72
+ return useMemo(() => getNexusProductLookup(opportunity), [opportunity]);
73
+ }
74
+
@@ -0,0 +1,94 @@
1
+ import { useCallback, useState } from "react";
2
+ import nexusSdk from "@nexusmutual/sdk";
3
+ import * as ethers from "ethers";
4
+ import type { NexusQuoteResult } from "../types";
5
+
6
+ type UseNexusPurchaseArgs = {
7
+ buyerAddress: string;
8
+ productId: number;
9
+ amountToCover: string;
10
+ onSuccess?: (result: NexusPurchaseResult) => void;
11
+ onError?: (error: unknown) => void;
12
+ };
13
+
14
+ type NexusPurchaseTxRequest = {
15
+ to: `0x${string}`;
16
+ data: `0x${string}`;
17
+ value: bigint;
18
+ account?: `0x${string}`;
19
+ };
20
+
21
+ type NexusPurchaseResult = {
22
+ txRequest: NexusPurchaseTxRequest;
23
+ quoteResult: NexusQuoteResult;
24
+ meta: {
25
+ buyerAddress: string;
26
+ productId: number;
27
+ amountToCover: string;
28
+ coverPeriod: number;
29
+ };
30
+ };
31
+
32
+ type UseNexusPurchaseReturn = {
33
+ purchase: (quoteResult: NexusQuoteResult, coverPeriod: number) => Promise<NexusPurchaseResult>;
34
+ isPurchasing: boolean;
35
+ error: unknown | null;
36
+ result: NexusPurchaseResult | null;
37
+ };
38
+
39
+ /** Encapsulate Nexus buyCover prep to mirror executeNexusTransaction flow */
40
+ export function useNexusPurchase(args: UseNexusPurchaseArgs): UseNexusPurchaseReturn {
41
+ const { buyerAddress, productId, amountToCover, onSuccess, onError } = args;
42
+ const [isPurchasing, setIsPurchasing] = useState(false);
43
+ const [error, setError] = useState<unknown | null>(null);
44
+ const [result, setResult] = useState<NexusPurchaseResult | null>(null);
45
+
46
+ const purchase = useCallback(
47
+ async (quoteResult: NexusQuoteResult, coverPeriod: number) => {
48
+ try {
49
+ setIsPurchasing(true);
50
+ setError(null);
51
+
52
+ const { buyCoverParams, poolAllocationRequests } = quoteResult.buyCoverInput;
53
+ const coverBrokerInterface = new ethers.Interface(nexusSdk.abis?.CoverBroker);
54
+ const data = coverBrokerInterface.encodeFunctionData("buyCover", [
55
+ buyCoverParams,
56
+ poolAllocationRequests,
57
+ ]);
58
+
59
+ const txRequest: NexusPurchaseTxRequest = {
60
+ to: nexusSdk.addresses?.CoverBroker as `0x${string}`,
61
+ data: data as `0x${string}`,
62
+ value: BigInt(quoteResult.displayInfo.premiumInAsset),
63
+ account: buyerAddress as `0x${string}`,
64
+ };
65
+
66
+ const purchaseResult: NexusPurchaseResult = {
67
+ txRequest,
68
+ quoteResult,
69
+ meta: {
70
+ buyerAddress,
71
+ productId,
72
+ amountToCover,
73
+ coverPeriod,
74
+ },
75
+ };
76
+
77
+ setResult(purchaseResult);
78
+ onSuccess?.(purchaseResult);
79
+ return purchaseResult;
80
+ } catch (err) {
81
+ setError(err);
82
+
83
+ onError?.(err);
84
+ throw err;
85
+ } finally {
86
+ setIsPurchasing(false);
87
+ }
88
+ },
89
+ [amountToCover, buyerAddress, onError, onSuccess, productId]
90
+ );
91
+
92
+ return { purchase, isPurchasing, error, result };
93
+ }
94
+
@@ -0,0 +1,6 @@
1
+ export { NexusCoverSection } from "./components/NexusCoverSection";
2
+ export type { NexusCoverSectionProps, NexusQuoteResult } from "./types";
3
+
4
+ export { useCoverQuote } from "./hooks/useCoverQuote";
5
+ export { useNexusPurchase } from "./hooks/useNexusPurchase";
6
+ export { useNexusProduct, getNexusProductLookup, getProtocolForInsurance } from "./hooks/useNexusProduct";
@@ -0,0 +1,49 @@
1
+ import type { Opportunity } from "@turtleclub/hooks";
2
+
3
+ /** Result from Nexus SDK quote API */
4
+ export interface NexusQuoteResult {
5
+ buyCoverInput: {
6
+ buyCoverParams: unknown;
7
+ poolAllocationRequests: unknown;
8
+ };
9
+ displayInfo: {
10
+ premiumInAsset: string;
11
+ yearlyCostPerc: number;
12
+ };
13
+ }
14
+ export type NexusProduct = {
15
+ id: number;
16
+ name: string;
17
+ coverAssets: number[];
18
+ };
19
+
20
+ export interface CoverOfferCardProps {
21
+ productId: number;
22
+ opportunity: Opportunity;
23
+ amountToCover: string;
24
+ buyerAddress: string;
25
+
26
+ onPurchase: (quoteResult: NexusQuoteResult, coverPeriod: number) => void;
27
+ isPurchasing?: boolean;
28
+ purchaseError?: unknown;
29
+
30
+ onDismiss?: () => void;
31
+ }
32
+
33
+ export interface CoverQuoteState {
34
+ isLoading: boolean;
35
+ error: string | null;
36
+ quoteResult: NexusQuoteResult | null;
37
+ coverPeriod: number;
38
+ premiumEth: string | null;
39
+ yearlyCostPerc: number | null;
40
+ }
41
+ export interface NexusCoverSectionProps {
42
+ opportunity: Opportunity;
43
+ amountToCover: string;
44
+ buyerAddress: string;
45
+ onSuccess: (res: any) => void;
46
+ onError: (err: any) => void;
47
+ onDismiss?: () => void;
48
+ className?: string;
49
+ }
@@ -0,0 +1,45 @@
1
+ import type { Opportunity } from "@turtleclub/hooks";
2
+
3
+ /**
4
+ * Calculate the total APY from an opportunity's incentives
5
+ */
6
+ export function getOpportunityAPY(opportunity: Opportunity): number {
7
+ if (!opportunity.incentives || opportunity.incentives.length === 0) {
8
+ return 0;
9
+ }
10
+
11
+ return opportunity.incentives.reduce((total, incentive) => {
12
+ return total + (incentive.yield ?? 0);
13
+ }, 0);
14
+ }
15
+
16
+ /**
17
+ * Calculate the adjusted APY after subtracting the cover cost
18
+ * @param opportunityAPY - The original APY as a percentage (e.g., 5 for 5%)
19
+ * @param yearlyCostPerc - The yearly cost as a decimal (e.g., 0.025 for 2.5%)
20
+ * @returns The adjusted APY as a percentage
21
+ */
22
+ export function calculateAdjustedAPY(
23
+ opportunityAPY: number,
24
+ yearlyCostPerc: number
25
+ ): number {
26
+ const coverCostPercentage = yearlyCostPerc * 100;
27
+ return Math.max(0, opportunityAPY - coverCostPercentage);
28
+ }
29
+
30
+ /**
31
+ * Format APY for display
32
+ */
33
+ export function formatAPY(apy: number): string {
34
+ return `${apy.toFixed(2)}%`;
35
+ }
36
+
37
+ /**
38
+ * Format ETH amount for display
39
+ */
40
+ export function formatEthAmount(amount: string, decimals: number = 6): string {
41
+ const num = parseFloat(amount);
42
+ if (isNaN(num)) return "0";
43
+ return num.toFixed(decimals);
44
+ }
45
+
package/src/index.ts CHANGED
@@ -7,6 +7,9 @@ export * from "./deposit";
7
7
  // Transaction Status
8
8
  export * from "./transaction-status";
9
9
 
10
+ // Cover Offer
11
+ export * from "./cover-offer";
12
+
10
13
  // Opportunity Table
11
14
  export * from "./opportunity-table/components";
12
15