@turtleclub/opportunities 0.1.0-beta.2 → 0.1.0-beta.21
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 +80 -0
- package/package.json +9 -6
- package/src/cover-offer/components/CoverOfferCard.tsx +419 -0
- package/src/cover-offer/components/CoverRequestForm.tsx +267 -0
- package/src/cover-offer/components/MembershipRequestCard.tsx +77 -0
- package/src/cover-offer/components/NexusCoverSection.tsx +87 -0
- package/src/cover-offer/constants.ts +8 -0
- package/src/cover-offer/hooks/useCheckNexusMembership.ts +54 -0
- package/src/cover-offer/hooks/useCoverQuote.ts +122 -0
- package/src/cover-offer/hooks/useExistingCovers.ts +126 -0
- package/src/cover-offer/hooks/useNexusProduct.ts +74 -0
- package/src/cover-offer/hooks/useNexusPurchase.ts +65 -0
- package/src/cover-offer/hooks/useUserCoverNfts.ts +83 -0
- package/src/cover-offer/index.ts +4 -0
- package/src/cover-offer/types/index.ts +65 -0
- package/src/cover-offer/utils/index.ts +82 -0
- package/src/index.ts +3 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ChangeEvent, FormEvent, useMemo, useState } from "react";
|
|
4
|
+
import { Button, Checkbox, Input } from "@turtleclub/ui";
|
|
5
|
+
import { ShieldAlert, Send, ChevronDown, ChevronUp, type LucideProps, Shield } from "lucide-react";
|
|
6
|
+
import type { ComponentType } from "react";
|
|
7
|
+
import type { CoverRequestData, CoverRequestFormProps } from "../types";
|
|
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
|
+
const ShieldIcon = Shield as ComponentType<LucideProps>;
|
|
13
|
+
|
|
14
|
+
const PERIOD_OPTIONS = [
|
|
15
|
+
{ label: "28d", days: 28 },
|
|
16
|
+
{ label: "3m", days: 90 },
|
|
17
|
+
{ label: "6m", days: 180 },
|
|
18
|
+
{ label: "1y", days: 365 },
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export function CoverRequestForm({
|
|
22
|
+
protocolName,
|
|
23
|
+
depositedAmount,
|
|
24
|
+
baseApyLabel,
|
|
25
|
+
onSuccess,
|
|
26
|
+
onError,
|
|
27
|
+
startExpanded,
|
|
28
|
+
}: CoverRequestFormProps) {
|
|
29
|
+
const [coverageAmount, setCoverageAmount] = useState("");
|
|
30
|
+
const [useDepositedAmount, setUseDepositedAmount] = useState(false);
|
|
31
|
+
const [desiredApySacrifice, setDesiredApySacrifice] = useState("");
|
|
32
|
+
const [selectedPeriodDays, setSelectedPeriodDays] = useState(90);
|
|
33
|
+
const [inputError, setInputError] = useState<string | null>(null);
|
|
34
|
+
const [isExpanded, setIsExpanded] = useState(startExpanded ?? false);
|
|
35
|
+
|
|
36
|
+
const parsedCoverageAmount = useMemo(() => {
|
|
37
|
+
const amountStr = useDepositedAmount ? (depositedAmount ?? "") : coverageAmount;
|
|
38
|
+
if (!amountStr.trim()) return null;
|
|
39
|
+
const value = Number(amountStr);
|
|
40
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
41
|
+
}, [coverageAmount, useDepositedAmount, depositedAmount]);
|
|
42
|
+
|
|
43
|
+
const parsedSacrifice = useMemo(() => {
|
|
44
|
+
if (!desiredApySacrifice.trim()) return null;
|
|
45
|
+
const value = Number(desiredApySacrifice);
|
|
46
|
+
return Number.isFinite(value) ? value : null;
|
|
47
|
+
}, [desiredApySacrifice]);
|
|
48
|
+
|
|
49
|
+
const parsedBaseApy = useMemo(() => {
|
|
50
|
+
if (!baseApyLabel) return null;
|
|
51
|
+
const value = Number(baseApyLabel.replace(/[^0-9.]/g, ""));
|
|
52
|
+
return Number.isFinite(value) ? value : null;
|
|
53
|
+
}, [baseApyLabel]);
|
|
54
|
+
|
|
55
|
+
const netApy = useMemo(() => {
|
|
56
|
+
if (parsedBaseApy === null || parsedSacrifice === null) return null;
|
|
57
|
+
return parsedBaseApy - parsedSacrifice;
|
|
58
|
+
}, [parsedBaseApy, parsedSacrifice]);
|
|
59
|
+
|
|
60
|
+
const estimatedPremium = useMemo(() => {
|
|
61
|
+
if (parsedCoverageAmount === null || parsedSacrifice === null) return null;
|
|
62
|
+
// Premium = Amount × (APY/100) × (Days/365)
|
|
63
|
+
return parsedCoverageAmount * (parsedSacrifice / 100) * (selectedPeriodDays / 365);
|
|
64
|
+
}, [parsedCoverageAmount, parsedSacrifice, selectedPeriodDays]);
|
|
65
|
+
|
|
66
|
+
const resetForm = () => {
|
|
67
|
+
setCoverageAmount("");
|
|
68
|
+
setUseDepositedAmount(false);
|
|
69
|
+
setDesiredApySacrifice("");
|
|
70
|
+
setSelectedPeriodDays(90);
|
|
71
|
+
setInputError(null);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleSubmit = (): CoverRequestData | undefined => {
|
|
75
|
+
if (parsedCoverageAmount === null) {
|
|
76
|
+
setInputError("Enter a valid coverage amount");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (parsedSacrifice === null) {
|
|
80
|
+
setInputError("Enter a valid APY sacrifice");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (parsedSacrifice < 0) {
|
|
84
|
+
setInputError("APY sacrifice cannot be negative");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
setInputError(null);
|
|
88
|
+
|
|
89
|
+
const requestData: CoverRequestData = {
|
|
90
|
+
protocolName,
|
|
91
|
+
coverageAmount: parsedCoverageAmount,
|
|
92
|
+
periodDays: selectedPeriodDays,
|
|
93
|
+
desiredApySacrifice: parsedSacrifice,
|
|
94
|
+
estimatedPremium: estimatedPremium ?? 0,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
onSuccess?.("Cover request submitted");
|
|
98
|
+
console.info("Cover request submitted", requestData);
|
|
99
|
+
|
|
100
|
+
return requestData;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleFormSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
const requestData = handleSubmit();
|
|
106
|
+
if (requestData) {
|
|
107
|
+
console.log("Submitted data successfully", requestData);
|
|
108
|
+
resetForm();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="rounded-xl bg-gradient-to-br from-primary/5 to-card overflow-hidden border border-primary/20">
|
|
114
|
+
<button
|
|
115
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
116
|
+
type="button"
|
|
117
|
+
className="w-full p-4 flex items-center justify-between hover:bg-primary/5 transition-colors"
|
|
118
|
+
>
|
|
119
|
+
<div className="flex items-center gap-3 text-left">
|
|
120
|
+
<div className="p-2 rounded-lg bg-primary/10 border border-primary/20">
|
|
121
|
+
<ShieldAlertIcon className="w-5 h-5 text-primary" />
|
|
122
|
+
</div>
|
|
123
|
+
<div className="space-y-1">
|
|
124
|
+
<p className="text-sm font-semibold text-foreground">
|
|
125
|
+
Request coverage for this opportunity
|
|
126
|
+
</p>
|
|
127
|
+
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
128
|
+
We don't yet have Nexus Mutual cover available for{" "}
|
|
129
|
+
<span className="font-medium">{protocolName}</span>.
|
|
130
|
+
</p>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
<div className="flex items-center gap-2">
|
|
134
|
+
{isExpanded ? (
|
|
135
|
+
<ChevronUpIcon className="w-5 h-5 text-muted-foreground" />
|
|
136
|
+
) : (
|
|
137
|
+
<ChevronDownIcon className="w-5 h-5 text-muted-foreground" />
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
</button>
|
|
141
|
+
|
|
142
|
+
{isExpanded && (
|
|
143
|
+
<form onSubmit={handleFormSubmit} className="p-4 space-y-4">
|
|
144
|
+
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
145
|
+
<span className="font-medium text-foreground">{protocolName}</span>
|
|
146
|
+
{baseApyLabel && <span>Current APR: {baseApyLabel}</span>}
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{/* Coverage Amount */}
|
|
150
|
+
<div className="space-y-2">
|
|
151
|
+
<label className="text-xs text-foreground">Coverage Amount</label>
|
|
152
|
+
<Input
|
|
153
|
+
variant=""
|
|
154
|
+
value={useDepositedAmount ? (depositedAmount ?? "") : coverageAmount}
|
|
155
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
156
|
+
const value = e.target.value;
|
|
157
|
+
if (value === "" || /^\d*\.?\d*$/.test(value)) {
|
|
158
|
+
setCoverageAmount(value);
|
|
159
|
+
}
|
|
160
|
+
}}
|
|
161
|
+
placeholder="e.g. 1"
|
|
162
|
+
className="w-full bg-secondary border border-border rounded-sm px-4 py-3 pr-12 text-foreground placeholder-muted-foreground focus:outline-none focus:border-primary/50 transition-colors"
|
|
163
|
+
disabled={useDepositedAmount}
|
|
164
|
+
/>
|
|
165
|
+
{depositedAmount && (
|
|
166
|
+
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
|
167
|
+
<Checkbox
|
|
168
|
+
checked={useDepositedAmount}
|
|
169
|
+
onCheckedChange={(checked) => setUseDepositedAmount(!useDepositedAmount)}
|
|
170
|
+
className="rounded border-border"
|
|
171
|
+
/>
|
|
172
|
+
Use my deposited amount ({depositedAmount})
|
|
173
|
+
</label>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* Period Selector */}
|
|
178
|
+
<div className="space-y-3">
|
|
179
|
+
<label className="text-xs text-foreground text-center block">Coverage Period</label>
|
|
180
|
+
<div className="flex gap-2">
|
|
181
|
+
{PERIOD_OPTIONS.map((option) => (
|
|
182
|
+
<Button
|
|
183
|
+
key={option.days}
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={() => setSelectedPeriodDays(option.days)}
|
|
186
|
+
variant="none"
|
|
187
|
+
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
|
188
|
+
selectedPeriodDays === option.days
|
|
189
|
+
? "bg-primary text-primary-foreground border-primary hover:border-primary"
|
|
190
|
+
: "bg-secondary text-foreground border-border hover:border-primary/50"
|
|
191
|
+
}`}
|
|
192
|
+
>
|
|
193
|
+
{option.label}
|
|
194
|
+
</Button>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{/* APY Sacrifice */}
|
|
200
|
+
<div className="space-y-2">
|
|
201
|
+
<label className="text-xs text-foreground">
|
|
202
|
+
APY you're willing to sacrifice for cover (%)
|
|
203
|
+
</label>
|
|
204
|
+
<Input
|
|
205
|
+
type="text"
|
|
206
|
+
variant="bordered"
|
|
207
|
+
value={desiredApySacrifice}
|
|
208
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
209
|
+
const value = e.target.value;
|
|
210
|
+
if (value === "" || /^\d*\.?\d*$/.test(value)) {
|
|
211
|
+
setDesiredApySacrifice(e.target.value);
|
|
212
|
+
}
|
|
213
|
+
}}
|
|
214
|
+
placeholder="e.g. 2.5"
|
|
215
|
+
className="w-full bg-secondary border border-border rounded-sm px-4 py-3 pr-12 text-foreground placeholder-muted-foreground focus:outline-none focus:border-primary/50 transition-colors"
|
|
216
|
+
/>
|
|
217
|
+
|
|
218
|
+
<div className="flex items-center justify-between text-xs p-2 rounded-md bg-muted/50">
|
|
219
|
+
<span className="text-muted-foreground">
|
|
220
|
+
{parsedBaseApy ?? "—"}% − {parsedSacrifice ?? "—"}% =
|
|
221
|
+
</span>
|
|
222
|
+
<span
|
|
223
|
+
className={`font-medium ${netApy !== null && netApy >= 0 ? "text-green-500" : "text-muted-foreground"}`}
|
|
224
|
+
>
|
|
225
|
+
{netApy !== null ? `${netApy.toFixed(2)}% Net APY` : "— Net APY"}
|
|
226
|
+
</span>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{/* Estimated Premium */}
|
|
231
|
+
<div className="p-3 rounded-sm bg-primary/5 border border-primary/20 space-y-1">
|
|
232
|
+
<p className="text-xs text-muted-foreground">Estimated Premium</p>
|
|
233
|
+
<p className="text-lg font-semibold text-foreground">
|
|
234
|
+
{estimatedPremium !== null
|
|
235
|
+
? estimatedPremium.toLocaleString(undefined, {
|
|
236
|
+
minimumFractionDigits: 2,
|
|
237
|
+
maximumFractionDigits: 2,
|
|
238
|
+
})
|
|
239
|
+
: "—"}
|
|
240
|
+
</p>
|
|
241
|
+
<p className="text-[10px] text-muted-foreground">
|
|
242
|
+
{parsedCoverageAmount?.toLocaleString() ?? "—"} × {parsedSacrifice ?? "—"}% ×{" "}
|
|
243
|
+
{selectedPeriodDays} days
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{inputError && <p className="text-[11px] text-destructive">{inputError}</p>}
|
|
248
|
+
|
|
249
|
+
<p className="text-base text-muted-foreground">
|
|
250
|
+
We'll use this to gauge interest and bring cover to the protocols you care about.
|
|
251
|
+
</p>
|
|
252
|
+
|
|
253
|
+
<div className="flex justify-center gap-2 w-full ">
|
|
254
|
+
<Button
|
|
255
|
+
className=" flex w-full text-lg font-semibold rounded-sm"
|
|
256
|
+
variant="green"
|
|
257
|
+
type="submit"
|
|
258
|
+
>
|
|
259
|
+
<ShieldIcon className="w-5 h-5 text-primary" />
|
|
260
|
+
Request Cover
|
|
261
|
+
</Button>
|
|
262
|
+
</div>
|
|
263
|
+
</form>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ComponentType } from "react";
|
|
4
|
+
import { Button } from "@turtleclub/ui";
|
|
5
|
+
|
|
6
|
+
interface MembershipRequestCardProps {
|
|
7
|
+
isExpanded: boolean;
|
|
8
|
+
onToggle: () => void;
|
|
9
|
+
membershipUrl: string;
|
|
10
|
+
ShieldIcon: ComponentType<any>;
|
|
11
|
+
ExternalLinkIcon: ComponentType<any>;
|
|
12
|
+
ChevronUpIcon: ComponentType<any>;
|
|
13
|
+
ChevronDownIcon: ComponentType<any>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function MembershipRequestCard({
|
|
17
|
+
isExpanded,
|
|
18
|
+
onToggle,
|
|
19
|
+
membershipUrl,
|
|
20
|
+
ShieldIcon,
|
|
21
|
+
ExternalLinkIcon,
|
|
22
|
+
ChevronUpIcon,
|
|
23
|
+
ChevronDownIcon,
|
|
24
|
+
}: MembershipRequestCardProps) {
|
|
25
|
+
return (
|
|
26
|
+
<div className="rounded-xl bg-gradient-to-br from-primary/10 to-card border border-primary/20 overflow-hidden">
|
|
27
|
+
<button
|
|
28
|
+
onClick={onToggle}
|
|
29
|
+
className="w-full p-4 flex items-center justify-between hover:bg-primary/5 transition-colors"
|
|
30
|
+
>
|
|
31
|
+
<div className="flex items-center gap-3">
|
|
32
|
+
<div className="p-2 rounded-lg bg-primary/10 border border-primary/20">
|
|
33
|
+
<ShieldIcon className="w-5 h-5 text-primary" />
|
|
34
|
+
</div>
|
|
35
|
+
<div className="text-left">
|
|
36
|
+
<div className="flex items-center gap-2">
|
|
37
|
+
<span className="text-sm font-semibold text-foreground">Protect Your Deposit</span>
|
|
38
|
+
<span className="px-2 py-0.5 text-[10px] font-medium text-primary bg-primary/10 rounded-full border border-primary/20">
|
|
39
|
+
NEW
|
|
40
|
+
</span>
|
|
41
|
+
</div>
|
|
42
|
+
<p className="text-xs text-muted-foreground">Nexus Mutual Single Protocol Cover</p>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
{isExpanded ? (
|
|
46
|
+
<ChevronUpIcon className="w-5 h-5 text-muted-foreground" />
|
|
47
|
+
) : (
|
|
48
|
+
<ChevronDownIcon className="w-5 h-5 text-muted-foreground" />
|
|
49
|
+
)}
|
|
50
|
+
</button>
|
|
51
|
+
|
|
52
|
+
{isExpanded && (
|
|
53
|
+
<div className="px-4 pb-4 space-y-4">
|
|
54
|
+
<div className="p-3 rounded-lg bg-background/50 border border-border">
|
|
55
|
+
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
56
|
+
<span className="text-primary font-medium">Membership required.</span> Join Nexus
|
|
57
|
+
Mutual to protect your deposit against smart contract exploits, oracle failures, and
|
|
58
|
+
protocol-specific risks.
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<Button asChild className="w-full" variant="default">
|
|
63
|
+
<a
|
|
64
|
+
href={membershipUrl}
|
|
65
|
+
target="_blank"
|
|
66
|
+
rel="noopener noreferrer"
|
|
67
|
+
className="flex items-center justify-center gap-2"
|
|
68
|
+
>
|
|
69
|
+
Become a Member
|
|
70
|
+
<ExternalLinkIcon className="w-4 h-4" />
|
|
71
|
+
</a>
|
|
72
|
+
</Button>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client";
|
|
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
|
+
import type { NexusCoverSectionProps } from "../types";
|
|
8
|
+
import { getOpportunityAPY, formatAPY } from "../utils";
|
|
9
|
+
import { useNexusProduct } from "../hooks/useNexusProduct";
|
|
10
|
+
import { useCheckNexusMembership } from "../hooks/useCheckNexusMembership";
|
|
11
|
+
import { CoverOfferCard } from "./CoverOfferCard";
|
|
12
|
+
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>;
|
|
20
|
+
|
|
21
|
+
export function NexusCoverSection({
|
|
22
|
+
opportunity,
|
|
23
|
+
amountToCover,
|
|
24
|
+
buyerAddress,
|
|
25
|
+
onSuccess,
|
|
26
|
+
onError,
|
|
27
|
+
startExpanded = false,
|
|
28
|
+
className,
|
|
29
|
+
}: NexusCoverSectionProps) {
|
|
30
|
+
const [isExpanded, setIsExpanded] = useState(startExpanded);
|
|
31
|
+
const { isCoverable, productId, protocolName, coverProductName } = useNexusProduct(opportunity);
|
|
32
|
+
const baseApy = getOpportunityAPY(opportunity);
|
|
33
|
+
const { isMember, isLoading: isMembershipLoading } = useCheckNexusMembership(buyerAddress);
|
|
34
|
+
|
|
35
|
+
if (isCoverable && isMembershipLoading) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
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;
|
|
42
|
+
|
|
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}>
|
|
60
|
+
<CoverOfferCard
|
|
61
|
+
productId={productId}
|
|
62
|
+
opportunity={opportunity}
|
|
63
|
+
amountToCover={amountToCover}
|
|
64
|
+
buyerAddress={buyerAddress}
|
|
65
|
+
protocolName={protocolName}
|
|
66
|
+
coverProductName={coverProductName ?? "Protocol Cover"}
|
|
67
|
+
onSuccess={onSuccess}
|
|
68
|
+
onError={onError}
|
|
69
|
+
startExpanded={startExpanded}
|
|
70
|
+
/>
|
|
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
|
+
/>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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 USER_NFTS_API_URL = "https://lumon.turtle.xyz/query/token/erc721_portfolio";
|
|
8
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { createPublicClient, http, getAddress } from 'viem';
|
|
3
|
+
import { mainnet } from 'viem/chains';
|
|
4
|
+
import nexusSdk from "@nexusmutual/sdk";
|
|
5
|
+
|
|
6
|
+
const publicClient = createPublicClient({
|
|
7
|
+
chain: mainnet,
|
|
8
|
+
transport: http(),
|
|
9
|
+
});
|
|
10
|
+
|
|
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]);
|
|
52
|
+
|
|
53
|
+
return { isMember, isLoading, error };
|
|
54
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
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";
|
|
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 } = await getNexusSdk();
|
|
43
|
+
const amountWei = parseEther(amount).toString();
|
|
44
|
+
|
|
45
|
+
const { result, error } = await nexusSdk.quote.getQuoteAndBuyCoverInputs({
|
|
46
|
+
productId,
|
|
47
|
+
amount: amountWei,
|
|
48
|
+
period,
|
|
49
|
+
coverAsset: CoverAsset.ETH,
|
|
50
|
+
paymentAsset: CoverAsset.ETH,
|
|
51
|
+
buyerAddress,
|
|
52
|
+
ipfsCidOrContent: TERMS_IPFS_CID,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (error) {
|
|
56
|
+
throw new Error(error.message || "Failed to fetch Nexus quote");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result as NexusQuoteResult;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useCoverQuote({
|
|
63
|
+
productId,
|
|
64
|
+
buyerAddress,
|
|
65
|
+
amountToCover,
|
|
66
|
+
debounceMs = 500,
|
|
67
|
+
}: 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]);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
coverPeriod,
|
|
113
|
+
setCoverPeriod,
|
|
114
|
+
isLoading,
|
|
115
|
+
error,
|
|
116
|
+
quoteResult,
|
|
117
|
+
premiumEth,
|
|
118
|
+
yearlyCostPerc,
|
|
119
|
+
refetch: fetchQuote,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|