@turtleclub/opportunities 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/package.json +28 -0
- package/src/components/balances-data-table.tsx +85 -0
- package/src/components/index.ts +1 -0
- package/src/constants.ts +1 -0
- package/src/deposit/components/confirm-button.tsx +137 -0
- package/src/deposit/components/geo-check-blocker.tsx +40 -0
- package/src/deposit/components/index.ts +2 -0
- package/src/deposit/index.ts +1 -0
- package/src/images/enso.png +0 -0
- package/src/index.ts +17 -0
- package/src/opportunity-table/components/apr-breakdown-tooltip.tsx +103 -0
- package/src/opportunity-table/components/chain-list.tsx +28 -0
- package/src/opportunity-table/components/incentives-breakdown.tsx +263 -0
- package/src/opportunity-table/components/index.ts +4 -0
- package/src/opportunity-table/components/opportunities-table.tsx +218 -0
- package/src/opportunity-table/hooks/index.ts +2 -0
- package/src/opportunity-table/hooks/useNetAPR.ts +15 -0
- package/src/opportunity-table/hooks/useTotalYield.ts +12 -0
- package/src/opportunity-table/utils/calculateNetAPR.ts +37 -0
- package/src/opportunity-table/utils/index.ts +1 -0
- package/src/route-details/index.ts +2 -0
- package/src/route-details/route-details.tsx +100 -0
- package/src/transaction-status/components/TransactionStatusSection.tsx +81 -0
- package/src/transaction-status/hooks/useTransactionQueue.ts +322 -0
- package/src/transaction-status/index.ts +8 -0
- package/src/transaction-status/types/index.ts +22 -0
- package/src/transaction-status/utils/index.ts +80 -0
- package/src/transaction-status/utils/selectors.ts +66 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { Separator, TurtleTooltip, cn } from "@turtleclub/ui";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { InfoIcon } from "lucide-react";
|
|
4
|
+
import type { Incentive, VaultConfig } from "@turtleclub/hooks";
|
|
5
|
+
import { formatNumber } from "@turtleclub/utils";
|
|
6
|
+
import { useNetAPR } from "../hooks/useNetAPR";
|
|
7
|
+
|
|
8
|
+
interface IncentivesBreakdownProps {
|
|
9
|
+
incentives: Incentive[];
|
|
10
|
+
vaultConfig?: VaultConfig;
|
|
11
|
+
showIcons?: boolean;
|
|
12
|
+
showNestedTooltips?: boolean;
|
|
13
|
+
showDescriptions?: boolean;
|
|
14
|
+
size?: "xs" | "sm";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function IncentivesBreakdown({
|
|
18
|
+
incentives,
|
|
19
|
+
vaultConfig,
|
|
20
|
+
showIcons = true,
|
|
21
|
+
showNestedTooltips = true,
|
|
22
|
+
showDescriptions = false,
|
|
23
|
+
size = "xs",
|
|
24
|
+
}: IncentivesBreakdownProps) {
|
|
25
|
+
const sortedIncentives = useMemo(() => {
|
|
26
|
+
return incentives
|
|
27
|
+
.filter(
|
|
28
|
+
(incentive) => incentive.rewardType === "points" || (incentive.yield !== null && incentive.yield !== undefined),
|
|
29
|
+
)
|
|
30
|
+
.sort((a, b) => {
|
|
31
|
+
// Points always go to the end
|
|
32
|
+
if (a.rewardType === "points" && b.rewardType !== "points") return 1;
|
|
33
|
+
if (a.rewardType !== "points" && b.rewardType === "points") return -1;
|
|
34
|
+
// Sort by yield for non-points
|
|
35
|
+
return (b.yield ?? 0) - (a.yield ?? 0);
|
|
36
|
+
});
|
|
37
|
+
}, [incentives]);
|
|
38
|
+
|
|
39
|
+
// Calculate fee amounts for display
|
|
40
|
+
const { performanceFeeAmount, managementFeeAmount } = useMemo(() => {
|
|
41
|
+
const native = sortedIncentives.filter((incentive) => incentive.name === "Native Yield");
|
|
42
|
+
const nativeYieldValue = native.reduce((sum, i) => sum + (i.yield ?? 0), 0);
|
|
43
|
+
const perfFee = vaultConfig?.performanceFee ?? 0;
|
|
44
|
+
const mgmtFee = vaultConfig?.managementFee ?? 0;
|
|
45
|
+
const perfFeeAmount = nativeYieldValue === 0 ? 0 : (nativeYieldValue * perfFee) / 100;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
performanceFeeAmount: perfFeeAmount,
|
|
49
|
+
managementFeeAmount: mgmtFee,
|
|
50
|
+
};
|
|
51
|
+
}, [sortedIncentives, vaultConfig]);
|
|
52
|
+
|
|
53
|
+
// Use shared hook for net APR calculation
|
|
54
|
+
const netAPR = useNetAPR(incentives, vaultConfig);
|
|
55
|
+
|
|
56
|
+
const hasFeeConfig = vaultConfig !== undefined;
|
|
57
|
+
const performanceFee = vaultConfig?.performanceFee ?? 0;
|
|
58
|
+
const depositFee = vaultConfig?.depositFee ?? 0;
|
|
59
|
+
const withdrawalFee = vaultConfig?.withdrawalFee ?? 0;
|
|
60
|
+
const textSize = size === "sm" ? "text-sm" : "text-xs";
|
|
61
|
+
const headerTextSize = size === "sm" ? "text-xs" : "text-xs";
|
|
62
|
+
const descriptionTextSize = size === "sm" ? "text-xs" : "text-[10px]";
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="space-y-3">
|
|
66
|
+
<div
|
|
67
|
+
className={cn(
|
|
68
|
+
"text-muted-foreground grid gap-4",
|
|
69
|
+
headerTextSize,
|
|
70
|
+
showDescriptions ? "grid-cols-2 md:grid-cols-3" : "grid-cols-2"
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
<span>Source</span>
|
|
74
|
+
{showDescriptions && <span className="hidden md:block">Description</span>}
|
|
75
|
+
<span className="text-right">Est. APR</span>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{sortedIncentives.map((incentive) => (
|
|
79
|
+
<div
|
|
80
|
+
key={incentive.id}
|
|
81
|
+
className={cn(
|
|
82
|
+
"grid items-center gap-4",
|
|
83
|
+
textSize,
|
|
84
|
+
showDescriptions ? "grid-cols-2 md:grid-cols-3" : "grid-cols-2"
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
<div className="flex items-center gap-2">
|
|
88
|
+
{showIcons && (
|
|
89
|
+
<div className="ring-border rounded-full ring">
|
|
90
|
+
<img src={incentive.iconUrl} alt={incentive.name} className="size-4 rounded-full" loading="lazy" />
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
<span className="text-secondary-foreground/80 truncate">{incentive.name}</span>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{showDescriptions && (
|
|
97
|
+
<div className={cn("text-secondary-foreground/80 wrap-break-word hidden md:block", descriptionTextSize)}>
|
|
98
|
+
<span className="block">{incentive.description || "—"}</span>
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
<div
|
|
103
|
+
className={
|
|
104
|
+
incentive.rewardType === "points" ? "text-muted-foreground text-right" : "text-success text-right"
|
|
105
|
+
}
|
|
106
|
+
>
|
|
107
|
+
{incentive.rewardType === "points" ? "-" : `+${formatNumber(incentive.yield ?? 0, 2, false, true)}%`}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
))}
|
|
111
|
+
|
|
112
|
+
{hasFeeConfig && (
|
|
113
|
+
<>
|
|
114
|
+
<Separator />
|
|
115
|
+
<div
|
|
116
|
+
className={cn(
|
|
117
|
+
"grid items-center gap-4",
|
|
118
|
+
textSize,
|
|
119
|
+
showDescriptions ? "grid-cols-2 md:grid-cols-3" : "grid-cols-2"
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
<div className="flex items-center gap-2">
|
|
123
|
+
<span className="text-secondary-foreground/80">
|
|
124
|
+
Performance Fee
|
|
125
|
+
{!showNestedTooltips && performanceFee > 0 && (
|
|
126
|
+
<span className="text-muted-foreground ml-1 text-[10px]">
|
|
127
|
+
({formatNumber(performanceFee, 2, false, true)}% on native yield)
|
|
128
|
+
</span>
|
|
129
|
+
)}
|
|
130
|
+
</span>
|
|
131
|
+
{showNestedTooltips && performanceFee > 0 && (
|
|
132
|
+
<TurtleTooltip
|
|
133
|
+
trigger={<InfoIcon className="size-3 shrink-0" />}
|
|
134
|
+
content={
|
|
135
|
+
<div className="text-xs">
|
|
136
|
+
{formatNumber(performanceFee, 0, false, true)}% performance fee on native yield.
|
|
137
|
+
</div>
|
|
138
|
+
}
|
|
139
|
+
/>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
{showDescriptions && <div className="hidden md:block" />}
|
|
143
|
+
<div
|
|
144
|
+
className={performanceFeeAmount > 0 ? "text-destructive text-right" : "text-muted-foreground text-right"}
|
|
145
|
+
>
|
|
146
|
+
{performanceFeeAmount > 0 ? "-" : ""}
|
|
147
|
+
{formatNumber(performanceFeeAmount, 2, false, true)}%
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
<div
|
|
151
|
+
className={cn(
|
|
152
|
+
"grid items-center gap-4",
|
|
153
|
+
textSize,
|
|
154
|
+
showDescriptions ? "grid-cols-2 md:grid-cols-3" : "grid-cols-2"
|
|
155
|
+
)}
|
|
156
|
+
>
|
|
157
|
+
<div className="flex items-center gap-2">
|
|
158
|
+
<span className="text-secondary-foreground/80">
|
|
159
|
+
Management Fee
|
|
160
|
+
{!showNestedTooltips && managementFeeAmount > 0 && (
|
|
161
|
+
<span className="text-muted-foreground ml-1 text-[10px]">
|
|
162
|
+
({formatNumber(managementFeeAmount, 2, false, true)}% annual fee)
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
</span>
|
|
166
|
+
{showNestedTooltips && managementFeeAmount > 0 && (
|
|
167
|
+
<TurtleTooltip
|
|
168
|
+
trigger={<InfoIcon className="size-3 shrink-0" />}
|
|
169
|
+
content={
|
|
170
|
+
<div className="text-xs">
|
|
171
|
+
{formatNumber(managementFeeAmount, 0, false, true)}% annual management fee on vault TVL.
|
|
172
|
+
</div>
|
|
173
|
+
}
|
|
174
|
+
/>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
{showDescriptions && <div className="hidden md:block" />}
|
|
178
|
+
<div
|
|
179
|
+
className={managementFeeAmount > 0 ? "text-destructive text-right" : "text-muted-foreground text-right"}
|
|
180
|
+
>
|
|
181
|
+
{managementFeeAmount > 0 ? "-" : ""}
|
|
182
|
+
{formatNumber(managementFeeAmount, 2, false, true)}%
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
<div
|
|
186
|
+
className={cn(
|
|
187
|
+
"grid items-center gap-4",
|
|
188
|
+
textSize,
|
|
189
|
+
showDescriptions ? "grid-cols-2 md:grid-cols-3" : "grid-cols-2"
|
|
190
|
+
)}
|
|
191
|
+
>
|
|
192
|
+
<div className="flex items-center gap-2">
|
|
193
|
+
<span className="text-secondary-foreground/80">Deposit Fee</span>
|
|
194
|
+
{!showNestedTooltips && depositFee > 0 && (
|
|
195
|
+
<span className="text-muted-foreground ml-1 text-[10px]">
|
|
196
|
+
{formatNumber(depositFee, 2, false, true)}% on deposit)
|
|
197
|
+
</span>
|
|
198
|
+
)}
|
|
199
|
+
{showNestedTooltips && depositFee > 0 && (
|
|
200
|
+
<TurtleTooltip
|
|
201
|
+
trigger={<InfoIcon className="size-3 shrink-0" />}
|
|
202
|
+
content={
|
|
203
|
+
<div className="text-xs">
|
|
204
|
+
{formatNumber(depositFee, 2, false, true)}% fee on every deposit into the vault.
|
|
205
|
+
</div>
|
|
206
|
+
}
|
|
207
|
+
/>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
{showDescriptions && <div className="hidden md:block" />}
|
|
211
|
+
<div className={depositFee > 0 ? "text-destructive text-right" : "text-muted-foreground text-right"}>
|
|
212
|
+
{formatNumber(depositFee, 2, false, true)}%
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
<div
|
|
216
|
+
className={cn(
|
|
217
|
+
"grid items-center gap-4",
|
|
218
|
+
textSize,
|
|
219
|
+
showDescriptions ? "grid-cols-2 md:grid-cols-3" : "grid-cols-2"
|
|
220
|
+
)}
|
|
221
|
+
>
|
|
222
|
+
<div className="flex items-center gap-2">
|
|
223
|
+
<span className="text-secondary-foreground/80">Withdrawal Fee</span>
|
|
224
|
+
{!showNestedTooltips && withdrawalFee > 0 && (
|
|
225
|
+
<TurtleTooltip
|
|
226
|
+
trigger={<InfoIcon className="size-3 shrink-0" />}
|
|
227
|
+
content={<div className="text-xs">{formatNumber(withdrawalFee, 2, false, true)}% on withdrawal</div>}
|
|
228
|
+
/>
|
|
229
|
+
)}
|
|
230
|
+
{showNestedTooltips && withdrawalFee > 0 && (
|
|
231
|
+
<TurtleTooltip
|
|
232
|
+
trigger={<InfoIcon className="size-3 shrink-0" />}
|
|
233
|
+
content={
|
|
234
|
+
<div className="text-xs">
|
|
235
|
+
{formatNumber(withdrawalFee, 2, false, true)}% fee on every withdrawal from the vault.
|
|
236
|
+
</div>
|
|
237
|
+
}
|
|
238
|
+
/>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
{showDescriptions && <div className="hidden md:block" />}
|
|
242
|
+
<div className={withdrawalFee > 0 ? "text-destructive text-right" : "text-muted-foreground text-right"}>
|
|
243
|
+
{withdrawalFee > 0 ? "-" : ""}
|
|
244
|
+
{formatNumber(withdrawalFee, 2, false, true)}%
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
<Separator />
|
|
248
|
+
<div
|
|
249
|
+
className={cn(
|
|
250
|
+
"grid items-center gap-4 font-semibold",
|
|
251
|
+
textSize,
|
|
252
|
+
showDescriptions ? "grid-cols-2 md:grid-cols-3" : "grid-cols-2"
|
|
253
|
+
)}
|
|
254
|
+
>
|
|
255
|
+
<span>Est. Net APR</span>
|
|
256
|
+
{showDescriptions && <div className="hidden md:block" />}
|
|
257
|
+
<div className="text-primary text-right">{formatNumber(netAPR, 2, false, true)}%</div>
|
|
258
|
+
</div>
|
|
259
|
+
</>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { Opportunity, TokenBalance } from "@turtleclub/hooks";
|
|
2
|
+
import { APIStatus, DataTable } from "@turtleclub/ui";
|
|
3
|
+
import type { ColumnDef } from "@tanstack/react-table";
|
|
4
|
+
import { formatCurrency } from "@turtleclub/utils";
|
|
5
|
+
import { getTotalYield } from "../hooks/useTotalYield";
|
|
6
|
+
import { calculateNetAPR } from "../utils/calculateNetAPR";
|
|
7
|
+
import { APRBreakdownTooltip } from "./apr-breakdown-tooltip";
|
|
8
|
+
import { ChainBadge } from "./chain-list";
|
|
9
|
+
|
|
10
|
+
interface OpportunitiesTableProps {
|
|
11
|
+
opportunities: Opportunity[];
|
|
12
|
+
currentNetwork?: number;
|
|
13
|
+
onSelectOpportunity: (opportunity: Opportunity) => void;
|
|
14
|
+
onChangeNetwork?: (chainId: number) => void;
|
|
15
|
+
onResetToken?: () => void;
|
|
16
|
+
onClose?: () => void;
|
|
17
|
+
userTvlByOpportunity?: Record<string, number>;
|
|
18
|
+
turtleTvl?: Record<string, number>;
|
|
19
|
+
isWidget?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function OpportunitiesTable({
|
|
23
|
+
opportunities,
|
|
24
|
+
currentNetwork,
|
|
25
|
+
onSelectOpportunity,
|
|
26
|
+
onChangeNetwork,
|
|
27
|
+
onResetToken,
|
|
28
|
+
onClose,
|
|
29
|
+
userTvlByOpportunity = {},
|
|
30
|
+
turtleTvl = {},
|
|
31
|
+
isWidget = false,
|
|
32
|
+
}: OpportunitiesTableProps) {
|
|
33
|
+
const handleSelectOpportunity = (opportunity: Opportunity) => {
|
|
34
|
+
onSelectOpportunity(opportunity);
|
|
35
|
+
const opportunityChainId = Number(opportunity.receiptToken.chain.chainId);
|
|
36
|
+
if (currentNetwork && opportunityChainId !== currentNetwork && onChangeNetwork) {
|
|
37
|
+
onChangeNetwork(opportunityChainId);
|
|
38
|
+
// Reset token to trigger auto-selection of first token in new chain
|
|
39
|
+
onResetToken?.();
|
|
40
|
+
}
|
|
41
|
+
onClose?.();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const columns: ColumnDef<Opportunity>[] = [
|
|
45
|
+
{
|
|
46
|
+
accessorKey: "name",
|
|
47
|
+
header: () => <div>Opportunity</div>,
|
|
48
|
+
cell: ({ row }) => (
|
|
49
|
+
<div className="flex items-center gap-2">
|
|
50
|
+
<div className="relative shrink-0">
|
|
51
|
+
<img
|
|
52
|
+
src={row.original.receiptToken.logoUrl}
|
|
53
|
+
className="size-6 rounded-full"
|
|
54
|
+
loading="lazy"
|
|
55
|
+
alt={row.original.receiptToken.name}
|
|
56
|
+
/>
|
|
57
|
+
{row.original.receiptToken.chain.logoUrl && (
|
|
58
|
+
<img
|
|
59
|
+
src={row.original.receiptToken.chain.logoUrl}
|
|
60
|
+
alt={row.original.receiptToken.chain.name}
|
|
61
|
+
className="absolute right-[-5px] top-0 size-3 rounded-full"
|
|
62
|
+
loading="lazy"
|
|
63
|
+
/>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
{row.original.name || row.original.receiptToken.name}
|
|
67
|
+
</div>
|
|
68
|
+
),
|
|
69
|
+
enableSorting: true,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "type",
|
|
73
|
+
accessorFn: (row) => row.type ?? "-",
|
|
74
|
+
header: () => <div>Type</div>,
|
|
75
|
+
cell: ({ row }) =>
|
|
76
|
+
row.original.type ? (
|
|
77
|
+
<div className="text-sm capitalize">{row.original.type.toLowerCase()}</div>
|
|
78
|
+
) : (
|
|
79
|
+
"-"
|
|
80
|
+
),
|
|
81
|
+
enableSorting: true,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "organization",
|
|
85
|
+
accessorFn: (row) => (row.products.length ? row.products[0].organization.name : "-"),
|
|
86
|
+
header: () => <div>Partner</div>,
|
|
87
|
+
cell: ({ row }) =>
|
|
88
|
+
row.original.products.length ? (
|
|
89
|
+
<div className="flex h-[30px] items-center gap-2">
|
|
90
|
+
<img
|
|
91
|
+
src={row.original.products[0].organization.iconUrl || ""}
|
|
92
|
+
className="size-6 rounded-full"
|
|
93
|
+
loading="lazy"
|
|
94
|
+
/>
|
|
95
|
+
{row.original.products[0].organization.name}
|
|
96
|
+
</div>
|
|
97
|
+
) : (
|
|
98
|
+
"-"
|
|
99
|
+
),
|
|
100
|
+
enableSorting: true,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "curator",
|
|
104
|
+
accessorFn: (row) => row.vaultConfig?.curator?.name ?? "-",
|
|
105
|
+
header: () => <div>Curator</div>,
|
|
106
|
+
cell: ({ row }) =>
|
|
107
|
+
row.original.vaultConfig?.curator ? (
|
|
108
|
+
<div className="flex h-[30px] items-center gap-2">
|
|
109
|
+
{row.original.vaultConfig.curator.iconUrl && (
|
|
110
|
+
<img
|
|
111
|
+
src={row.original.vaultConfig.curator.iconUrl}
|
|
112
|
+
className="size-6 rounded-full"
|
|
113
|
+
loading="lazy"
|
|
114
|
+
alt={row.original.vaultConfig.curator.name}
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
{row.original.vaultConfig.curator.name}
|
|
118
|
+
</div>
|
|
119
|
+
) : (
|
|
120
|
+
"-"
|
|
121
|
+
),
|
|
122
|
+
enableSorting: true,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: "provider",
|
|
126
|
+
accessorFn: (row) => row.vaultConfig?.infraProvider?.name ?? "-",
|
|
127
|
+
header: () => <div>Provider</div>,
|
|
128
|
+
cell: ({ row }) =>
|
|
129
|
+
row.original.vaultConfig?.infraProvider ? (
|
|
130
|
+
<div className="flex h-[30px] items-center gap-2">
|
|
131
|
+
{row.original.vaultConfig.infraProvider.iconUrl && (
|
|
132
|
+
<img
|
|
133
|
+
src={row.original.vaultConfig.infraProvider.iconUrl}
|
|
134
|
+
className="size-6 rounded-full"
|
|
135
|
+
loading="lazy"
|
|
136
|
+
alt={row.original.vaultConfig.infraProvider.name}
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
139
|
+
{row.original.vaultConfig.infraProvider.name}
|
|
140
|
+
</div>
|
|
141
|
+
) : (
|
|
142
|
+
"-"
|
|
143
|
+
),
|
|
144
|
+
enableSorting: true,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "yourTvl",
|
|
148
|
+
accessorKey: "yourTvl",
|
|
149
|
+
header: () => <div className="flex w-full justify-end">Your TVL</div>,
|
|
150
|
+
cell: ({ row }) => {
|
|
151
|
+
const yourTvl = userTvlByOpportunity[row.original.id || ""] || 0;
|
|
152
|
+
return (
|
|
153
|
+
<div className="flex h-[30px] items-center justify-end text-end">
|
|
154
|
+
{formatCurrency(yourTvl, 0, true)}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
enableSorting: true,
|
|
159
|
+
sortingFn: (rowA, rowB) => {
|
|
160
|
+
const a = userTvlByOpportunity[rowA.original.id || ""] || 0;
|
|
161
|
+
const b = userTvlByOpportunity[rowB.original.id || ""] || 0;
|
|
162
|
+
return a - b;
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
accessorKey: "turtleTvl",
|
|
167
|
+
header: () => <div className="flex w-full justify-end">Turtle TVL</div>,
|
|
168
|
+
cell: ({ row }) => {
|
|
169
|
+
const turtle = turtleTvl[row.original.id || ""] || 0;
|
|
170
|
+
return <div className="text-end">{formatCurrency(turtle, 0, true)}</div>;
|
|
171
|
+
},
|
|
172
|
+
enableSorting: true,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
accessorKey: "tvl",
|
|
176
|
+
header: () => <div className="flex w-full justify-end">Total TVL</div>,
|
|
177
|
+
cell: ({ row }) => (
|
|
178
|
+
<div className="text-end">{formatCurrency(row.original.tvl, 0, true)}</div>
|
|
179
|
+
),
|
|
180
|
+
enableSorting: true,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
accessorKey: "totalYield",
|
|
184
|
+
header: () => <div className="flex w-full justify-end">Est. APR</div>,
|
|
185
|
+
cell: ({ row }) => (
|
|
186
|
+
<div className="flex flex-row-reverse">
|
|
187
|
+
<APRBreakdownTooltip
|
|
188
|
+
incentives={row.original.incentives}
|
|
189
|
+
vaultConfig={row.original.vaultConfig ?? undefined}
|
|
190
|
+
isWidget={isWidget}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
),
|
|
194
|
+
enableSorting: true,
|
|
195
|
+
sortingFn: (rowA, rowB) => {
|
|
196
|
+
const a = calculateNetAPR(rowA.original.incentives, rowA.original.vaultConfig ?? undefined);
|
|
197
|
+
const b = calculateNetAPR(rowB.original.incentives, rowB.original.vaultConfig ?? undefined);
|
|
198
|
+
return a - b;
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<DataTable
|
|
205
|
+
columns={columns}
|
|
206
|
+
onRowClick={handleSelectOpportunity}
|
|
207
|
+
data={opportunities}
|
|
208
|
+
enableSearch
|
|
209
|
+
grid={{
|
|
210
|
+
displayAsGrid: true,
|
|
211
|
+
headerSlot: "name",
|
|
212
|
+
rightSlot: "totalYield",
|
|
213
|
+
excludeColumns: ["turtleTvl", "type", "yourTvl"],
|
|
214
|
+
className: "grid-cols-1 gap-2.5",
|
|
215
|
+
}}
|
|
216
|
+
/>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { Incentive, VaultConfig } from "@turtleclub/hooks";
|
|
3
|
+
import { calculateNetAPR } from "../utils/calculateNetAPR";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* React hook that calculates the net APR after fees for an opportunity
|
|
7
|
+
* @param incentives - Array of incentive objects
|
|
8
|
+
* @param vaultConfig - Optional vault configuration with fee information
|
|
9
|
+
* @returns Memoized net APR after performance and management fees
|
|
10
|
+
*/
|
|
11
|
+
export function useNetAPR(incentives: Incentive[], vaultConfig?: VaultConfig) {
|
|
12
|
+
return useMemo(() => {
|
|
13
|
+
return calculateNetAPR(incentives, vaultConfig);
|
|
14
|
+
}, [incentives, vaultConfig]);
|
|
15
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { Incentive } from "@turtleclub/hooks";
|
|
3
|
+
|
|
4
|
+
export const getTotalYield = (incentives: Incentive[]) =>
|
|
5
|
+
incentives
|
|
6
|
+
.filter((i) => i.rewardType !== "points")
|
|
7
|
+
.reduce((acc, incentive) => acc + (incentive.yield ?? 0), 0);
|
|
8
|
+
|
|
9
|
+
export const useTotalYield = (incentives: Incentive[]) => {
|
|
10
|
+
const totalYield = useMemo(() => getTotalYield(incentives), [incentives]);
|
|
11
|
+
return { totalYield };
|
|
12
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Incentive, VaultConfig } from "@turtleclub/hooks";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calculates the net APR after fees for an opportunity
|
|
5
|
+
* @param incentives - Array of incentive objects
|
|
6
|
+
* @param vaultConfig - Optional vault configuration with fee information
|
|
7
|
+
* @returns Net APR after performance and management fees
|
|
8
|
+
*/
|
|
9
|
+
export function calculateNetAPR(incentives: Incentive[], vaultConfig?: VaultConfig): number {
|
|
10
|
+
// Calculate total yield from all non-points incentives
|
|
11
|
+
const totalYield = incentives
|
|
12
|
+
.filter(
|
|
13
|
+
(incentive) => incentive.rewardType !== "points" && incentive.yield !== null && incentive.yield !== undefined,
|
|
14
|
+
)
|
|
15
|
+
.reduce((sum, incentive) => sum + (incentive.yield ?? 0), 0);
|
|
16
|
+
|
|
17
|
+
// If no vault config, return total yield
|
|
18
|
+
if (!vaultConfig) {
|
|
19
|
+
return totalYield;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Identify native vault yield (base yield from the vault strategy itself)
|
|
23
|
+
const nativeYield = incentives
|
|
24
|
+
.filter(
|
|
25
|
+
(incentive) => incentive.name === "Native Yield" && incentive.yield !== null && incentive.yield !== undefined,
|
|
26
|
+
)
|
|
27
|
+
.reduce((sum, incentive) => sum + (incentive.yield ?? 0), 0);
|
|
28
|
+
|
|
29
|
+
const performanceFee = vaultConfig.performanceFee ?? 0;
|
|
30
|
+
const managementFee = vaultConfig.managementFee ?? 0;
|
|
31
|
+
|
|
32
|
+
// Performance fee applies only to native vault yield
|
|
33
|
+
const performanceFeeAmount = nativeYield === 0 ? 0 : (nativeYield * performanceFee) / 100;
|
|
34
|
+
|
|
35
|
+
// Net APR = total yield - performance fee on native - management fee on total
|
|
36
|
+
return totalYield - performanceFeeAmount - managementFee;
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { calculateNetAPR } from "./calculateNetAPR";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Card, CardContent, LabelWithIcon, ScrollArea } from "@turtleclub/ui";
|
|
3
|
+
import { ChevronsRight } from "lucide-react";
|
|
4
|
+
import ensoLogo from "../images/enso.png";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import type { TokenStep } from "@turtleclub/hooks";
|
|
7
|
+
|
|
8
|
+
export type { TokenStep } from "@turtleclub/hooks";
|
|
9
|
+
|
|
10
|
+
interface RouteDetailsProps {
|
|
11
|
+
value: {
|
|
12
|
+
steps: TokenStep[];
|
|
13
|
+
};
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const RouteDetails = ({ value, className }: RouteDetailsProps) => {
|
|
18
|
+
const { steps } = value;
|
|
19
|
+
|
|
20
|
+
const isLongRoute = useMemo(() => {
|
|
21
|
+
return steps.length > 2;
|
|
22
|
+
}, [steps]);
|
|
23
|
+
|
|
24
|
+
const defaultIcon = (symbol: string) => (
|
|
25
|
+
<div className="bg-primary/20 flex size-3 items-center justify-center rounded-full">
|
|
26
|
+
<span className="text-primary text-xs font-bold">{symbol.charAt(0)}</span>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const capitalize = (str: string) => {
|
|
31
|
+
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (steps.length === 0) return null;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="text-foreground px-3 pb-3">
|
|
38
|
+
<div className="text-sm font-medium">Swap Route</div>
|
|
39
|
+
<div className="mb-1 flex items-center gap-1 text-[10px]">
|
|
40
|
+
<span className="text-sm opacity-50">Powered by</span>
|
|
41
|
+
<img src={ensoLogo} className="w-10" alt="Enso" />
|
|
42
|
+
</div>
|
|
43
|
+
{/* Route visualization */}
|
|
44
|
+
<ScrollArea className="mt-2 rounded-md whitespace-nowrap">
|
|
45
|
+
<div className="grid w-full auto-cols-fr grid-flow-col items-center gap-2 py-2">
|
|
46
|
+
{steps.map((step, index) => (
|
|
47
|
+
<React.Fragment key={`${step.in.symbol}-${step.type}-${index}`}>
|
|
48
|
+
<Card variant="border" className="border-muted-foreground/60">
|
|
49
|
+
<CardContent className="space-y-2 pt-4">
|
|
50
|
+
{/* Type */}
|
|
51
|
+
<div className="text-secondary-foreground text-sm">
|
|
52
|
+
{capitalize(step.type)}
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div className="flex flex-row items-center gap-2">
|
|
56
|
+
{/* Token in */}
|
|
57
|
+
<LabelWithIcon
|
|
58
|
+
icon={step.in.icon || defaultIcon(step.in.symbol)}
|
|
59
|
+
textSize="xs"
|
|
60
|
+
iconSize="xs"
|
|
61
|
+
>
|
|
62
|
+
{!isLongRoute && (
|
|
63
|
+
<span className="hidden gap-2 sm:block">
|
|
64
|
+
{step.in.symbol}
|
|
65
|
+
</span>
|
|
66
|
+
)}
|
|
67
|
+
{step.type === "approve" && (
|
|
68
|
+
<span className="gap-2"> {step.amount}</span>
|
|
69
|
+
)}
|
|
70
|
+
</LabelWithIcon>
|
|
71
|
+
|
|
72
|
+
{step.type !== "approve" && (
|
|
73
|
+
<>
|
|
74
|
+
{/* Arrow between tokens */}
|
|
75
|
+
<ChevronsRight className="text-primary size-4 shrink-0" />
|
|
76
|
+
|
|
77
|
+
{/* Token out */}
|
|
78
|
+
<LabelWithIcon
|
|
79
|
+
icon={step.out.icon || defaultIcon(step.out.symbol)}
|
|
80
|
+
textSize="xs"
|
|
81
|
+
iconSize="xs"
|
|
82
|
+
>
|
|
83
|
+
{!isLongRoute && (
|
|
84
|
+
<span className="hidden gap-2 sm:block">
|
|
85
|
+
{step.in.symbol}
|
|
86
|
+
</span>
|
|
87
|
+
)}
|
|
88
|
+
</LabelWithIcon>
|
|
89
|
+
</>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
</CardContent>
|
|
93
|
+
</Card>
|
|
94
|
+
</React.Fragment>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
</ScrollArea>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
};
|