@turtleclub/opportunities 0.1.0-beta.1 → 0.1.0-beta.11
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 +42 -0
- package/package.json +7 -4
- package/src/cover-offer/components/CoverOfferCard.tsx +291 -0
- package/src/cover-offer/components/CoverRequestForm.tsx +152 -0
- package/src/cover-offer/components/NexusCoverSection.tsx +65 -0
- package/src/cover-offer/constants.ts +6 -0
- package/src/cover-offer/hooks/useCoverQuote.ts +124 -0
- package/src/cover-offer/hooks/useNexusProduct.ts +74 -0
- package/src/cover-offer/hooks/useNexusPurchase.ts +94 -0
- package/src/cover-offer/index.ts +6 -0
- package/src/cover-offer/types/index.ts +49 -0
- package/src/cover-offer/utils/index.ts +45 -0
- package/src/index.ts +3 -0
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.11](https://github.com/turtle-dao/turtle-tools/compare/@turtleclub/opportunities@0.1.0-beta.10...@turtleclub/opportunities@0.1.0-beta.11) (2025-12-16)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @turtleclub/opportunities
|
|
9
|
+
|
|
10
|
+
# [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)
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- 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))
|
|
15
|
+
|
|
16
|
+
# [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)
|
|
17
|
+
|
|
18
|
+
**Note:** Version bump only for package @turtleclub/opportunities
|
|
19
|
+
|
|
20
|
+
# [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)
|
|
21
|
+
|
|
22
|
+
**Note:** Version bump only for package @turtleclub/opportunities
|
|
23
|
+
|
|
24
|
+
# [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)
|
|
25
|
+
|
|
26
|
+
**Note:** Version bump only for package @turtleclub/opportunities
|
|
27
|
+
|
|
28
|
+
# [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)
|
|
29
|
+
|
|
30
|
+
**Note:** Version bump only for package @turtleclub/opportunities
|
|
31
|
+
|
|
32
|
+
# [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)
|
|
33
|
+
|
|
34
|
+
**Note:** Version bump only for package @turtleclub/opportunities
|
|
35
|
+
|
|
36
|
+
# [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)
|
|
37
|
+
|
|
38
|
+
**Note:** Version bump only for package @turtleclub/opportunities
|
|
39
|
+
|
|
40
|
+
# [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)
|
|
41
|
+
|
|
42
|
+
**Note:** Version bump only for package @turtleclub/opportunities
|
|
43
|
+
|
|
44
|
+
# [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)
|
|
45
|
+
|
|
46
|
+
**Note:** Version bump only for package @turtleclub/opportunities
|
|
47
|
+
|
|
6
48
|
# [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)
|
|
7
49
|
|
|
8
50
|
**Note:** Version bump only for package @turtleclub/opportunities
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@turtleclub/opportunities",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.11",
|
|
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
|
-
"@
|
|
13
|
-
"@turtleclub/
|
|
12
|
+
"@nexusmutual/sdk": "^1.26.0",
|
|
13
|
+
"@turtleclub/hooks": "0.5.0-beta.5",
|
|
14
|
+
"@turtleclub/ui": "0.7.0-beta.7",
|
|
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": "
|
|
30
|
+
"gitHead": "544e9a5d3933fcfcf50166e1718eaa255acb3223"
|
|
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'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'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'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,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
|
+
|