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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,12 @@
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.61](https://github.com/turtle-dao/turtle-tools/compare/@turtleclub/opportunities@0.1.0-beta.60...@turtleclub/opportunities@0.1.0-beta.61) (2026-01-28)
7
+
8
+ ### Features
9
+
10
+ - deposit improvements ([#234](https://github.com/turtle-dao/turtle-tools/issues/234)) ([aef8505](https://github.com/turtle-dao/turtle-tools/commit/aef8505b0768f9aeea65b7499dcb090553dd69df))
11
+
6
12
  # [0.1.0-beta.60](https://github.com/turtle-dao/turtle-tools/compare/@turtleclub/opportunities@0.1.0-beta.59...@turtleclub/opportunities@0.1.0-beta.60) (2026-01-27)
7
13
 
8
14
  **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.60",
3
+ "version": "0.1.0-beta.61",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -13,8 +13,8 @@
13
13
  "@tanstack/react-form": "^1.27.6",
14
14
  "@tanstack/react-query": "^5.62.3",
15
15
  "@tanstack/react-table": "^8.21.3",
16
- "@turtleclub/hooks": "0.5.0-beta.43",
17
- "@turtleclub/multichain": "0.5.0-beta.0",
16
+ "@turtleclub/hooks": "0.5.0-beta.44",
17
+ "@turtleclub/multichain": "0.5.0-beta.1",
18
18
  "@turtleclub/ui": "0.7.0-beta.23",
19
19
  "@turtleclub/utils": "0.4.0-beta.0",
20
20
  "jotai": "^2.10.3",
@@ -31,5 +31,5 @@
31
31
  "@types/react-dom": "^18.3.5",
32
32
  "typescript": "^5.7.2"
33
33
  },
34
- "gitHead": "ae345f4c4390594e945a1d39c719324620f981bc"
34
+ "gitHead": "4a93ec9e58cff4424e003cabe74da0892dadcc95"
35
35
  }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useState, useEffect } from "react";
4
4
  import { Button, cn, SegmentControl, SlippageSelector, TurtleTooltip } from "@turtleclub/ui";
5
5
  import { InfoIcon } from "lucide-react";
6
6
  import {
@@ -15,6 +15,19 @@ import { RouteDetailsV2 } from "../route-details/route-details-v2";
15
15
  const DEFAULT_SLIPPAGE = 0.005; // 0.5%
16
16
  const SECONDS_IN_DAY = 86400;
17
17
 
18
+ /** Returns true if error is expected user behavior (not a real error to show) */
19
+ function isUserRejectedError(error: Error | null): boolean {
20
+ if (!error) return false;
21
+ const message = error.message.toLowerCase();
22
+ return (
23
+ message.includes("user rejected") ||
24
+ message.includes("user denied") ||
25
+ message.includes("rejected the request") ||
26
+ message.includes("user cancelled") ||
27
+ message.includes("user canceled")
28
+ );
29
+ }
30
+
18
31
  export interface NativeDepositSectionProps {
19
32
  opportunity: Opportunity;
20
33
  address: string | undefined;
@@ -22,7 +35,12 @@ export interface NativeDepositSectionProps {
22
35
  balances: TokenBalance[];
23
36
  isBalancesLoading?: boolean;
24
37
  refetchBalances?: () => void;
25
- executeTransaction: (tx: TransactionRequest) => Promise<string | undefined>;
38
+ /**
39
+ * Execute a transaction and wait for receipt confirmation.
40
+ * IMPORTANT: This function MUST wait for the transaction receipt before returning.
41
+ * The returned hash should only be provided after the transaction is confirmed on-chain.
42
+ */
43
+ executeTransactionAndWait: (tx: TransactionRequest) => Promise<string | undefined>;
26
44
  onDepositSuccess?: () => void;
27
45
  depositMode?: "native" | "route";
28
46
  onDepositModeChange?: (mode: "native" | "route") => void;
@@ -39,7 +57,7 @@ export function NativeDepositSection({
39
57
  balances,
40
58
  isBalancesLoading = false,
41
59
  refetchBalances,
42
- executeTransaction,
60
+ executeTransactionAndWait,
43
61
  onDepositSuccess,
44
62
  depositMode = "native",
45
63
  onDepositModeChange,
@@ -48,6 +66,11 @@ export function NativeDepositSection({
48
66
  }: NativeDepositSectionProps) {
49
67
  const [slippage, setSlippage] = useState(DEFAULT_SLIPPAGE);
50
68
 
69
+ // Secondary market always uses swap mode and hides the mode selector
70
+ const isSecondaryOnly = opportunity.vaultConfig?.secondaryOnly === true;
71
+ const effectiveDepositMode = isSecondaryOnly ? "route" : depositMode;
72
+ const effectiveOnDepositModeChange = isSecondaryOnly ? undefined : onDepositModeChange;
73
+
51
74
  // Convert slippage percentage to basis points (0.005 -> 50 bps)
52
75
  const slippageBps = Math.round(slippage * 10000);
53
76
 
@@ -56,13 +79,20 @@ export function NativeDepositSection({
56
79
  userAddress: address,
57
80
  distributorId,
58
81
  balances,
59
- executeTransaction,
82
+ executeTransactionAndWait,
60
83
  onDepositSuccess,
61
84
  refetchBalances,
62
- slippageBps: depositMode === "route" ? slippageBps : undefined,
85
+ slippageBps: effectiveDepositMode === "route" ? slippageBps : undefined,
63
86
  walletChainId: chainId,
64
87
  });
65
88
 
89
+ // Log deposit errors (moved from render to avoid side-effects)
90
+ useEffect(() => {
91
+ if (deposit.error && !isUserRejectedError(deposit.error)) {
92
+ console.error("[NativeDepositSection] Deposit error:", deposit.error);
93
+ }
94
+ }, [deposit.error]);
95
+
66
96
  const handleButtonClick = async () => {
67
97
  if (validation.isWrongChain && validation.requiredChainId && switchChain) {
68
98
  await switchChain(validation.requiredChainId);
@@ -77,10 +107,11 @@ export function NativeDepositSection({
77
107
 
78
108
  return (
79
109
  <div className="space-y-3">
80
- {onDepositModeChange ? (
110
+ {/* Mode selector: only show when mode can be changed */}
111
+ {effectiveOnDepositModeChange && (
81
112
  <SegmentControl
82
- value={depositMode === "native" ? "native" : "route"}
83
- onChange={(value) => onDepositModeChange(value as "native" | "route")}
113
+ value={effectiveDepositMode === "native" ? "native" : "route"}
114
+ onChange={(value) => effectiveOnDepositModeChange(value as "native" | "route")}
84
115
  items={[
85
116
  {
86
117
  value: "native",
@@ -121,18 +152,6 @@ export function NativeDepositSection({
121
152
  size="sm"
122
153
  className="w-fit mt-4"
123
154
  />
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
155
  )}
137
156
 
138
157
  <SwapInputV3
@@ -147,11 +166,11 @@ export function NativeDepositSection({
147
166
  showBalance={true}
148
167
  className={cn(
149
168
  "border border-border p-5",
150
- validation.hasInsufficientBalance && "border-destructive/50",
169
+ validation.hasInsufficientBalance && "border-destructive/50"
151
170
  )}
152
171
  />
153
172
 
154
- {depositMode === "route" && (
173
+ {effectiveDepositMode === "route" && (
155
174
  <SlippageSelector value={slippage} onChange={setSlippage} className="px-2" />
156
175
  )}
157
176
 
@@ -162,8 +181,14 @@ export function NativeDepositSection({
162
181
  approveAmount={selection.amount}
163
182
  />
164
183
  )}
184
+ {/* Error messages */}
185
+ {deposit.error && !isUserRejectedError(deposit.error) && (
186
+ <p className="text-destructive text-sm">Something went wrong. Please try again.</p>
187
+ )}
165
188
 
166
- {deposit.error && <p className="text-destructive text-sm">{deposit.error.message}</p>}
189
+ {validation.validationMessage && validation.hasInsufficientBalance && (
190
+ <p className="text-destructive text-xs">{validation.validationMessage}</p>
191
+ )}
167
192
 
168
193
  <GeoCheckBlocker>
169
194
  <Button
@@ -175,10 +200,6 @@ export function NativeDepositSection({
175
200
  </Button>
176
201
  </GeoCheckBlocker>
177
202
 
178
- {validation.validationMessage && validation.hasInsufficientBalance && (
179
- <p className="text-destructive text-xs">{validation.validationMessage}</p>
180
- )}
181
-
182
203
  {/* {(validation.depositFee !== null ||
183
204
  validation.performanceFee !== null ||
184
205
  validation.withdrawalCooldownSecs !== null) && (
@@ -1,12 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { useState, useMemo } from "react";
3
+ import { useState } from "react";
4
4
  import {
5
- useGetOnChainBalance,
6
- useBalance,
7
- filterExcludedTokens,
5
+ useActionsDefaultParams,
8
6
  type Opportunity,
9
- type TokenBalance,
10
7
  type TransactionRequest,
11
8
  } from "@turtleclub/hooks";
12
9
  import { NativeDepositSection } from "./NativeDepositSection";
@@ -17,7 +14,12 @@ export interface TemporalWrapperProps {
17
14
  opportunity: Opportunity;
18
15
  address: string | undefined;
19
16
  distributorId: string;
20
- executeTransaction: (tx: TransactionRequest) => Promise<string | undefined>;
17
+ /**
18
+ * Execute a transaction and wait for receipt confirmation.
19
+ * IMPORTANT: This function MUST wait for the transaction receipt before returning.
20
+ * The returned hash should only be provided after the transaction is confirmed on-chain.
21
+ */
22
+ executeTransactionAndWait: (tx: TransactionRequest) => Promise<string | undefined>;
21
23
  onDepositSuccess?: () => void;
22
24
  /** Current wallet chain ID */
23
25
  chainId?: number;
@@ -29,16 +31,6 @@ export interface TemporalWrapperProps {
29
31
  showDepositModeSelector?: boolean;
30
32
  }
31
33
 
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
34
  /**
43
35
  * TemporalWrapper - A standalone wrapper for NativeDepositSection
44
36
  *
@@ -49,7 +41,7 @@ export function TemporalWrapper({
49
41
  opportunity,
50
42
  address,
51
43
  distributorId,
52
- executeTransaction,
44
+ executeTransactionAndWait,
53
45
  onDepositSuccess,
54
46
  chainId,
55
47
  switchChain,
@@ -58,80 +50,15 @@ export function TemporalWrapper({
58
50
  }: TemporalWrapperProps) {
59
51
  const [depositMode, setDepositMode] = useState<DepositMode>(initialDepositMode);
60
52
 
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({
53
+ // Hook handles isSecondaryOnly internally for balance fetching
54
+ const { balances, isBalancesLoading, refetchBalances } = useActionsDefaultParams({
55
+ opportunity,
84
56
  address,
85
- chainIds: [opportunityChainId],
86
- depositOpportunity: opportunity,
57
+ depositMode,
87
58
  });
88
59
 
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
60
  // Determine if we should show the mode selector
61
+ // NativeDepositSection handles isSecondaryOnly internally for UI
135
62
  const onDepositModeChange = showDepositModeSelector && opportunity.earnEnabled
136
63
  ? setDepositMode
137
64
  : undefined;
@@ -144,7 +71,7 @@ export function TemporalWrapper({
144
71
  balances={balances}
145
72
  isBalancesLoading={isBalancesLoading}
146
73
  refetchBalances={refetchBalances}
147
- executeTransaction={executeTransaction}
74
+ executeTransactionAndWait={executeTransactionAndWait}
148
75
  onDepositSuccess={onDepositSuccess}
149
76
  depositMode={depositMode}
150
77
  onDepositModeChange={onDepositModeChange}
@@ -17,7 +17,12 @@ export interface OpportunityActionsProps {
17
17
  opportunity: Opportunity;
18
18
  address: string | undefined;
19
19
  distributorId: string;
20
- executeTransaction: (tx: TransactionRequest) => Promise<string | undefined>;
20
+ /**
21
+ * Execute a transaction and wait for receipt confirmation.
22
+ * IMPORTANT: This function MUST wait for the transaction receipt before returning.
23
+ * The returned hash should only be provided after the transaction is confirmed on-chain.
24
+ */
25
+ executeTransactionAndWait: (tx: TransactionRequest) => Promise<string | undefined>;
21
26
  onDepositSuccess?: () => void;
22
27
  /** Current wallet chain ID */
23
28
  chainId?: number;
@@ -39,7 +44,7 @@ export function OpportunityActions({
39
44
  opportunity,
40
45
  address,
41
46
  distributorId,
42
- executeTransaction,
47
+ executeTransactionAndWait,
43
48
  onDepositSuccess,
44
49
  chainId,
45
50
  switchChain,
@@ -47,10 +52,14 @@ export function OpportunityActions({
47
52
  const [actionMode, setActionMode] = useState<"deposit" | "withdraw">("deposit");
48
53
  const [depositMode, setDepositMode] = useState<"native" | "route">("native");
49
54
 
55
+ const isSecondaryOnly = opportunity.vaultConfig?.secondaryOnly === true;
56
+
57
+ // For secondary market, always use swap mode
58
+ const effectiveDepositMode = isSecondaryOnly ? "route" : depositMode;
50
59
  const opportunityChainId = Number(opportunity.receiptToken.chain.chainId)
51
60
 
52
61
  // Use on-chain balances when: direct deposit OR withdraw
53
- const useOnChainBalances = depositMode === "native" || actionMode === "withdraw";
62
+ const useOnChainBalances = effectiveDepositMode === "native" || actionMode === "withdraw";
54
63
 
55
64
  // Always fetch both balance sources to avoid loading states when switching modes
56
65
  const {
@@ -129,11 +138,11 @@ export function OpportunityActions({
129
138
  value: "deposit",
130
139
  label: opportunity.depositDisabled ? (
131
140
  <TurtleTooltip
132
- trigger={<span>Deposit</span>}
133
- content={<span className="block p-2">Deposits are disabled for this opportunity</span>}
141
+ trigger={<span>{isSecondaryOnly ? "Buy" : "Deposit"}</span>}
142
+ content={<span className="block p-2">{isSecondaryOnly ? "Purchases are" : "Deposits are"} disabled for this opportunity</span>}
134
143
  />
135
144
  ) : (
136
- "Deposit"
145
+ isSecondaryOnly ? "Buy" : "Deposit"
137
146
  ),
138
147
  disabled: opportunity.depositDisabled,
139
148
  },
@@ -141,11 +150,11 @@ export function OpportunityActions({
141
150
  value: "withdraw",
142
151
  label: opportunity.withdrawalDisabled ? (
143
152
  <TurtleTooltip
144
- trigger={<span>Withdraw</span>}
145
- content={<span className="block p-2">Withdrawals are disabled for this opportunity</span>}
153
+ trigger={<span>{isSecondaryOnly ? "Sell" : "Withdraw"}</span>}
154
+ content={<span className="block p-2">{isSecondaryOnly ? "Sales are" : "Withdrawals are"} disabled for this opportunity</span>}
146
155
  />
147
156
  ) : (
148
- "Withdraw"
157
+ isSecondaryOnly ? "Sell" : "Withdraw"
149
158
  ),
150
159
  disabled: opportunity.withdrawalDisabled,
151
160
  },
@@ -162,10 +171,10 @@ export function OpportunityActions({
162
171
  balances={balances}
163
172
  isBalancesLoading={isBalancesLoading}
164
173
  refetchBalances={refetchBalances}
165
- executeTransaction={executeTransaction}
174
+ executeTransactionAndWait={executeTransactionAndWait}
166
175
  onDepositSuccess={onDepositSuccess}
167
- depositMode={depositMode}
168
- onDepositModeChange={opportunity.earnEnabled ? setDepositMode : undefined}
176
+ depositMode={effectiveDepositMode}
177
+ onDepositModeChange={opportunity.earnEnabled && !isSecondaryOnly ? setDepositMode : undefined}
169
178
  chainId={chainId}
170
179
  switchChain={switchChain}
171
180
  />