@turtleclub/opportunities 0.1.0-beta.6 → 0.1.0-beta.60

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +224 -0
  2. package/README.md +179 -0
  3. package/package.json +13 -6
  4. package/src/components/index.ts +1 -1
  5. package/src/cover-offer/README.md +46 -0
  6. package/src/cover-offer/components/CoverOfferCard.tsx +445 -0
  7. package/src/cover-offer/components/CoverRequestForm.tsx +342 -0
  8. package/src/cover-offer/components/CoveredEventsInfo.tsx +120 -0
  9. package/src/cover-offer/components/ExistingCoverInfo.tsx +74 -0
  10. package/src/cover-offer/components/NexusCoverSection.tsx +49 -0
  11. package/src/cover-offer/components/PurchaseButtonSection.tsx +106 -0
  12. package/src/cover-offer/constants.ts +32 -0
  13. package/src/cover-offer/hooks/useCheckNexusMembership.ts +32 -0
  14. package/src/cover-offer/hooks/useCoverQuote.ts +126 -0
  15. package/src/cover-offer/hooks/useCoverTokenSelection.ts +84 -0
  16. package/src/cover-offer/hooks/useDebouncedValue.ts +12 -0
  17. package/src/cover-offer/hooks/useExistingCovers.ts +101 -0
  18. package/src/cover-offer/hooks/useNexusProduct.ts +79 -0
  19. package/src/cover-offer/hooks/useNexusPurchase.ts +67 -0
  20. package/src/cover-offer/hooks/useTokenApproval.ts +118 -0
  21. package/src/cover-offer/hooks/useUserCoverNfts.ts +26 -0
  22. package/src/cover-offer/index.ts +4 -0
  23. package/src/cover-offer/types/index.ts +41 -0
  24. package/src/cover-offer/utils/index.ts +90 -0
  25. package/src/deposit/NativeDepositSection.tsx +199 -0
  26. package/src/deposit/TemporalWrapper.tsx +155 -0
  27. package/src/{components → deposit/components}/balances-data-table.tsx +6 -0
  28. package/src/deposit/components/index.ts +3 -0
  29. package/src/deposit/components/swap-input-v3.tsx +194 -0
  30. package/src/deposit/components/token-selector-v3.tsx +122 -0
  31. package/src/deposit/index.ts +4 -0
  32. package/src/index.ts +9 -0
  33. package/src/opportunity-actions/OpportunityActions.tsx +182 -0
  34. package/src/opportunity-actions/index.ts +1 -0
  35. package/src/opportunity-table/components/opportunities-table.tsx +6 -5
  36. package/src/route-details/index.ts +6 -0
  37. package/src/route-details/route-details-v2.tsx +137 -0
  38. package/src/route-details/route-details.tsx +5 -4
  39. package/src/route-details/types.ts +7 -0
  40. package/src/transaction-status/hooks/useTransactionQueue.ts +1 -1
  41. package/src/withdraw/NativeWithdrawSection.tsx +45 -0
  42. package/src/withdraw/index.ts +1 -0
@@ -0,0 +1,41 @@
1
+ import { CoverAsset } from "@nexusmutual/sdk";
2
+ import type { Opportunity } from "@turtleclub/hooks";
3
+ import type { Asset } from "@turtleclub/ui";
4
+
5
+ // The Nexus SDK doesn't export the NexusProduct type, so we need to define it ourselves.
6
+ // The product has more attributes than just id, name, and coverAssets, but this are the only ones we need for now.
7
+ export type NexusProduct = {
8
+ id: number;
9
+ name: string;
10
+ coverAssets: string[];
11
+ };
12
+
13
+ export interface CoverOfferCardProps {
14
+ productId: number;
15
+ opportunity: Opportunity;
16
+ buyerAddress: string;
17
+ protocolName: string;
18
+ coverProductName: string;
19
+ coverAvailableAssets: string[];
20
+ onSuccess?: (message: string) => void;
21
+ onError?: (message: string) => void;
22
+ startExpanded?: boolean;
23
+ }
24
+
25
+ export interface NexusCoverSectionProps {
26
+ opportunity: Opportunity;
27
+ buyerAddress: string;
28
+ onSuccess?: (message: string) => void;
29
+ onError?: (message: string) => void;
30
+ className?: string;
31
+ startExpanded?: boolean;
32
+ }
33
+
34
+ export interface CoverRequestFormProps {
35
+ protocolName: string;
36
+ baseApyLabel?: string;
37
+ onDismiss?: () => void;
38
+ onSuccess?: (message: string) => void;
39
+ onError?: (message: string) => void;
40
+ startExpanded?: boolean;
41
+ }
@@ -0,0 +1,90 @@
1
+ import { CoverAsset } from "@nexusmutual/sdk";
2
+ import type { Opportunity } from "@turtleclub/hooks";
3
+ import { BaseError, UserRejectedRequestError } from "viem";
4
+
5
+ export function getOpportunityAPY(opportunity: Opportunity): number {
6
+ if (!opportunity.incentives || opportunity.incentives.length === 0) {
7
+ return 0;
8
+ }
9
+
10
+ return opportunity.incentives.reduce((total, incentive) => {
11
+ return total + (incentive.yield ?? 0);
12
+ }, 0);
13
+ }
14
+
15
+ /**
16
+ * Calculate the adjusted APY after subtracting the cover cost
17
+ * @param opportunityAPY - The original APY as a percentage (e.g., 5 for 5%)
18
+ * @param yearlyCostPerc - The yearly cost as a decimal (e.g., 0.025 for 2.5%)
19
+ * @returns The adjusted APY as a percentage
20
+ */
21
+ export function calculateAdjustedAPY(
22
+ opportunityAPY: number,
23
+ yearlyCostPerc: number
24
+ ): number {
25
+ const coverCostPercentage = yearlyCostPerc * 100;
26
+ return Math.max(0, opportunityAPY - coverCostPercentage);
27
+ }
28
+
29
+
30
+ export function formatAPY(apy: number): string {
31
+ return `${apy.toFixed(2)}%`;
32
+ }
33
+
34
+ export function formatEthAmount(amount: string, decimals: number = 6): string {
35
+ const num = parseFloat(amount);
36
+ if (isNaN(num)) return "0";
37
+ return num.toFixed(decimals);
38
+ }
39
+
40
+
41
+ export const formatPurchaseError = (err: unknown): string => {
42
+ console.error("Purchase error:", err, typeof err);
43
+ if (err instanceof UserRejectedRequestError) {
44
+ return "Transaction cancelled";
45
+ }
46
+
47
+ if (err instanceof BaseError) {
48
+ const userRejected =
49
+ typeof err.walk === "function"
50
+ ? err.walk((error) => error instanceof UserRejectedRequestError)
51
+ : false;
52
+ if (userRejected) {
53
+ return "Transaction cancelled";
54
+ }
55
+
56
+ return err.shortMessage || err.message;
57
+ }
58
+
59
+ if (err instanceof Error) {
60
+ return err.message;
61
+ }
62
+
63
+ return "Failed to purchase cover";
64
+ };
65
+
66
+
67
+
68
+ const SECS_IN_DAY = 24 * 60 * 60;
69
+
70
+ export function parseCoverTiming(startSec: number, periodSec: number, gracePeriodSec: number = 0) {
71
+ const expiresSec = startSec + periodSec;
72
+ const graceEndsSec = expiresSec + gracePeriodSec;
73
+
74
+ return {
75
+ startDate: new Date(startSec * 1000),
76
+ expiresDate: new Date(expiresSec * 1000),
77
+ graceEndsDate: new Date(graceEndsSec * 1000),
78
+ periodDays: periodSec / SECS_IN_DAY,
79
+ graceDays: gracePeriodSec / SECS_IN_DAY,
80
+ expiresSec,
81
+ graceEndsSec,
82
+ };
83
+ }
84
+
85
+ export const TOKEN_SYMBOL_TO_COVER_ASSET: Record<string, CoverAsset> = {
86
+ ETH: CoverAsset.ETH,
87
+ DAI: CoverAsset.DAI,
88
+ USDC: CoverAsset.USDC,
89
+ cbBTC: CoverAsset.cbBTC,
90
+ };
@@ -0,0 +1,199 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button, cn, SegmentControl, SlippageSelector, TurtleTooltip } from "@turtleclub/ui";
5
+ import { InfoIcon } from "lucide-react";
6
+ import {
7
+ useDepositFlow,
8
+ type Opportunity,
9
+ type TokenBalance,
10
+ type TransactionRequest,
11
+ } from "@turtleclub/hooks";
12
+ import { GeoCheckBlocker, SwapInputV3 } from "./components";
13
+ import { RouteDetailsV2 } from "../route-details/route-details-v2";
14
+
15
+ const DEFAULT_SLIPPAGE = 0.005; // 0.5%
16
+ const SECONDS_IN_DAY = 86400;
17
+
18
+ export interface NativeDepositSectionProps {
19
+ opportunity: Opportunity;
20
+ address: string | undefined;
21
+ distributorId: string;
22
+ balances: TokenBalance[];
23
+ isBalancesLoading?: boolean;
24
+ refetchBalances?: () => void;
25
+ executeTransaction: (tx: TransactionRequest) => Promise<string | undefined>;
26
+ onDepositSuccess?: () => void;
27
+ depositMode?: "native" | "route";
28
+ onDepositModeChange?: (mode: "native" | "route") => void;
29
+ /** Current wallet chain ID */
30
+ chainId?: number;
31
+ /** Function to switch chain */
32
+ switchChain?: (chainId: number) => Promise<void>;
33
+ }
34
+
35
+ export function NativeDepositSection({
36
+ opportunity,
37
+ address,
38
+ distributorId,
39
+ balances,
40
+ isBalancesLoading = false,
41
+ refetchBalances,
42
+ executeTransaction,
43
+ onDepositSuccess,
44
+ depositMode = "native",
45
+ onDepositModeChange,
46
+ chainId,
47
+ switchChain,
48
+ }: NativeDepositSectionProps) {
49
+ const [slippage, setSlippage] = useState(DEFAULT_SLIPPAGE);
50
+
51
+ // Convert slippage percentage to basis points (0.005 -> 50 bps)
52
+ const slippageBps = Math.round(slippage * 10000);
53
+
54
+ const { selection, validation, deposit } = useDepositFlow({
55
+ opportunity,
56
+ userAddress: address,
57
+ distributorId,
58
+ balances,
59
+ executeTransaction,
60
+ onDepositSuccess,
61
+ refetchBalances,
62
+ slippageBps: depositMode === "route" ? slippageBps : undefined,
63
+ walletChainId: chainId,
64
+ });
65
+
66
+ const handleButtonClick = async () => {
67
+ if (validation.isWrongChain && validation.requiredChainId && switchChain) {
68
+ await switchChain(validation.requiredChainId);
69
+ return;
70
+ }
71
+ await deposit.execute();
72
+ };
73
+
74
+ // Only show loading state if we don't have balances yet (initial load)
75
+ // This prevents disabling the input during background refetches
76
+ const isInitialBalancesLoading = isBalancesLoading && balances.length === 0;
77
+
78
+ return (
79
+ <div className="space-y-3">
80
+ {onDepositModeChange ? (
81
+ <SegmentControl
82
+ value={depositMode === "native" ? "native" : "route"}
83
+ onChange={(value) => onDepositModeChange(value as "native" | "route")}
84
+ items={[
85
+ {
86
+ value: "native",
87
+ label: (
88
+ <span className="flex items-center gap-1.5">
89
+ Direct Deposit
90
+ <TurtleTooltip
91
+ trigger={<InfoIcon className="size-3 opacity-60" />}
92
+ content={
93
+ <span className="p-2 block">
94
+ Deposit directly using the vault's accepted tokens. Lower fees, faster
95
+ execution.
96
+ </span>
97
+ }
98
+ />
99
+ </span>
100
+ ),
101
+ },
102
+ {
103
+ value: "route",
104
+ label: (
105
+ <span className="flex items-center gap-1.5">
106
+ Swap
107
+ <TurtleTooltip
108
+ trigger={<InfoIcon className="size-3 opacity-60" />}
109
+ content={
110
+ <span className="p-2 block">
111
+ Deposit any token from the chain. Automatically swaps to the vault's deposit
112
+ token.
113
+ </span>
114
+ }
115
+ />
116
+ </span>
117
+ ),
118
+ },
119
+ ]}
120
+ variant="pill"
121
+ size="sm"
122
+ className="w-fit mt-4"
123
+ />
124
+ ) : (
125
+ <span className="flex items-center gap-1.5 text-sm">
126
+ Direct Deposit
127
+ <TurtleTooltip
128
+ trigger={<InfoIcon className="size-3 opacity-60" />}
129
+ content={
130
+ <span className="p-2 block">
131
+ Deposit directly using the vault's accepted tokens. Lower fees, faster execution.
132
+ </span>
133
+ }
134
+ />
135
+ </span>
136
+ )}
137
+
138
+ <SwapInputV3
139
+ value={selection.amount ?? ""}
140
+ balances={balances}
141
+ selectedTokenAddress={selection.selectedTokenAddress}
142
+ disabled={isInitialBalancesLoading}
143
+ isWalletConnected={!!address}
144
+ onChange={selection.setAmount}
145
+ onMaxClick={selection.handleMaxClick}
146
+ onTokenChange={selection.setSelectedTokenAddress}
147
+ showBalance={true}
148
+ className={cn(
149
+ "border border-border p-5",
150
+ validation.hasInsufficientBalance && "border-destructive/50",
151
+ )}
152
+ />
153
+
154
+ {depositMode === "route" && (
155
+ <SlippageSelector value={slippage} onChange={setSlippage} className="px-2" />
156
+ )}
157
+
158
+ {deposit.metadata && (
159
+ <RouteDetailsV2
160
+ metadata={deposit.metadata}
161
+ showApprove={deposit.hasApprove}
162
+ approveAmount={selection.amount}
163
+ />
164
+ )}
165
+
166
+ {deposit.error && <p className="text-destructive text-sm">{deposit.error.message}</p>}
167
+
168
+ <GeoCheckBlocker>
169
+ <Button
170
+ onClick={handleButtonClick}
171
+ disabled={!validation.canDeposit && !validation.isWrongChain}
172
+ className="w-full"
173
+ >
174
+ {validation.buttonText}
175
+ </Button>
176
+ </GeoCheckBlocker>
177
+
178
+ {validation.validationMessage && validation.hasInsufficientBalance && (
179
+ <p className="text-destructive text-xs">{validation.validationMessage}</p>
180
+ )}
181
+
182
+ {/* {(validation.depositFee !== null ||
183
+ validation.performanceFee !== null ||
184
+ validation.withdrawalCooldownSecs !== null) && (
185
+ <div className="text-muted-foreground text-xs space-y-1">
186
+ {validation.depositFee !== null && validation.depositFee > 0 && (
187
+ <p>Deposit fee: {validation.depositFee}%</p>
188
+ )}
189
+ {validation.performanceFee !== null && validation.performanceFee > 0 && (
190
+ <p>Performance fee: {validation.performanceFee}%</p>
191
+ )}
192
+ {validation.withdrawalCooldownSecs !== null && validation.withdrawalCooldownSecs > 0 && (
193
+ <p>Withdrawal cooldown: {Math.floor(validation.withdrawalCooldownSecs / SECONDS_IN_DAY)} days</p>
194
+ )}
195
+ </div>
196
+ )} */}
197
+ </div>
198
+ );
199
+ }
@@ -0,0 +1,155 @@
1
+ "use client";
2
+
3
+ import { useState, useMemo } from "react";
4
+ import {
5
+ useGetOnChainBalance,
6
+ useBalance,
7
+ filterExcludedTokens,
8
+ type Opportunity,
9
+ type TokenBalance,
10
+ type TransactionRequest,
11
+ } from "@turtleclub/hooks";
12
+ import { NativeDepositSection } from "./NativeDepositSection";
13
+
14
+ export type DepositMode = "native" | "route";
15
+
16
+ export interface TemporalWrapperProps {
17
+ opportunity: Opportunity;
18
+ address: string | undefined;
19
+ distributorId: string;
20
+ executeTransaction: (tx: TransactionRequest) => Promise<string | undefined>;
21
+ onDepositSuccess?: () => void;
22
+ /** Current wallet chain ID */
23
+ chainId?: number;
24
+ /** Function to switch chain */
25
+ switchChain?: (chainId: number) => Promise<void>;
26
+ /** Initial deposit mode */
27
+ initialDepositMode?: DepositMode;
28
+ /** Whether to show the deposit mode selector (native/swap) */
29
+ showDepositModeSelector?: boolean;
30
+ }
31
+
32
+ function sortByBalance(balances: TokenBalance[]): TokenBalance[] {
33
+ return [...balances].sort((a, b) => {
34
+ const aValue = BigInt(a.amount);
35
+ const bValue = BigInt(b.amount);
36
+ if (bValue > aValue) return 1;
37
+ if (bValue < aValue) return -1;
38
+ return 0;
39
+ });
40
+ }
41
+
42
+ /**
43
+ * TemporalWrapper - A standalone wrapper for NativeDepositSection
44
+ *
45
+ * Includes all balance fetching logic and deposit mode management.
46
+ * Use this when you only need deposit functionality without withdraw.
47
+ */
48
+ export function TemporalWrapper({
49
+ opportunity,
50
+ address,
51
+ distributorId,
52
+ executeTransaction,
53
+ onDepositSuccess,
54
+ chainId,
55
+ switchChain,
56
+ initialDepositMode = "native",
57
+ showDepositModeSelector = true,
58
+ }: TemporalWrapperProps) {
59
+ const [depositMode, setDepositMode] = useState<DepositMode>(initialDepositMode);
60
+
61
+ const opportunityChainId = Number(opportunity.receiptToken.chain.chainId);
62
+
63
+ // Use on-chain balances for native mode, portfolio for route mode
64
+ const useOnChainBalances = depositMode === "native";
65
+
66
+ // Fetch deposit token balances (on-chain)
67
+ const {
68
+ balances: depositTokenBalances,
69
+ isLoading: isDepositBalancesLoading,
70
+ refetch: refetchDepositBalances,
71
+ } = useGetOnChainBalance({
72
+ tokens: opportunity.depositTokens,
73
+ chainId: opportunityChainId,
74
+ address,
75
+ enabled: !!address,
76
+ });
77
+
78
+ // Fetch all balances for route/swap mode
79
+ const {
80
+ balances: allChainBalances,
81
+ isLoading: isAllChainBalancesLoading,
82
+ refetchAll: refetchAllBalances,
83
+ } = useBalance({
84
+ address,
85
+ chainIds: [opportunityChainId],
86
+ depositOpportunity: opportunity,
87
+ });
88
+
89
+ const rawBalances = useMemo(() => {
90
+ if (useOnChainBalances) {
91
+ if (depositTokenBalances.length > 0) {
92
+ return depositTokenBalances;
93
+ }
94
+ // Fallback: create TokenBalance entries from deposit tokens with 0 balance
95
+ return opportunity.depositTokens.map((token) => ({
96
+ token,
97
+ amount: "0",
98
+ source: "onchain" as const,
99
+ }));
100
+ }
101
+ return allChainBalances;
102
+ }, [useOnChainBalances, depositTokenBalances, allChainBalances, opportunity.depositTokens]);
103
+
104
+ const isBalancesLoading = useOnChainBalances
105
+ ? isDepositBalancesLoading
106
+ : isAllChainBalancesLoading;
107
+
108
+ const refetchBalances = useOnChainBalances
109
+ ? refetchDepositBalances
110
+ : refetchAllBalances;
111
+
112
+ const balances = useMemo(() => {
113
+ let filtered = rawBalances;
114
+
115
+ // Filter by opportunity chain (only for route mode)
116
+ if (!useOnChainBalances && opportunityChainId) {
117
+ filtered = filtered.filter((b) => {
118
+ const tokenChainId = Number(b.token.chain?.chainId);
119
+ return tokenChainId === opportunityChainId;
120
+ });
121
+ }
122
+
123
+ // Filter zero balances (only for route mode)
124
+ if (!useOnChainBalances) {
125
+ filtered = filtered.filter((b) => BigInt(b.amount) > 0n);
126
+ }
127
+
128
+ // Filter excluded tokens
129
+ filtered = filterExcludedTokens(filtered);
130
+
131
+ return sortByBalance(filtered);
132
+ }, [rawBalances, useOnChainBalances, opportunityChainId]);
133
+
134
+ // Determine if we should show the mode selector
135
+ const onDepositModeChange = showDepositModeSelector && opportunity.earnEnabled
136
+ ? setDepositMode
137
+ : undefined;
138
+
139
+ return (
140
+ <NativeDepositSection
141
+ opportunity={opportunity}
142
+ address={address}
143
+ distributorId={distributorId}
144
+ balances={balances}
145
+ isBalancesLoading={isBalancesLoading}
146
+ refetchBalances={refetchBalances}
147
+ executeTransaction={executeTransaction}
148
+ onDepositSuccess={onDepositSuccess}
149
+ depositMode={depositMode}
150
+ onDepositModeChange={onDepositModeChange}
151
+ chainId={chainId}
152
+ switchChain={switchChain}
153
+ />
154
+ );
155
+ }
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import { useMemo } from "react";
2
4
  import type { ColumnDef } from "@tanstack/react-table";
3
5
  import { DataTable } from "@turtleclub/ui";
@@ -9,6 +11,8 @@ export interface BalancesDataTableProps {
9
11
  isLoading?: boolean;
10
12
  onBalanceSelect?: (balance: TokenBalance) => void;
11
13
  emptyStateMessage?: string;
14
+ /** ScrollArea height - passed to DataTable size prop */
15
+ size?: string;
12
16
  }
13
17
 
14
18
  export function BalancesDataTable({
@@ -16,6 +20,7 @@ export function BalancesDataTable({
16
20
  isLoading = false,
17
21
  onBalanceSelect,
18
22
  emptyStateMessage = "No balances found",
23
+ size,
19
24
  }: BalancesDataTableProps) {
20
25
  const columns = useMemo<ColumnDef<TokenBalance>[]>(
21
26
  () => [
@@ -80,6 +85,7 @@ export function BalancesDataTable({
80
85
  onRowClick={onBalanceSelect}
81
86
  emptyState={emptyState}
82
87
  className="w-full"
88
+ size={size}
83
89
  />
84
90
  );
85
91
  }
@@ -1,2 +1,5 @@
1
+ export { BalancesDataTable, type BalancesDataTableProps } from "./balances-data-table";
1
2
  export { ConfirmButton } from "./confirm-button";
2
3
  export { GeoCheckBlocker } from "./geo-check-blocker";
4
+ export { SwapInputV3, type SwapInputV3Props } from "./swap-input-v3";
5
+ export { TokenSelectorV3 } from "./token-selector-v3";