@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.
Files changed (30) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/package.json +28 -0
  3. package/src/components/balances-data-table.tsx +85 -0
  4. package/src/components/index.ts +1 -0
  5. package/src/constants.ts +1 -0
  6. package/src/deposit/components/confirm-button.tsx +137 -0
  7. package/src/deposit/components/geo-check-blocker.tsx +40 -0
  8. package/src/deposit/components/index.ts +2 -0
  9. package/src/deposit/index.ts +1 -0
  10. package/src/images/enso.png +0 -0
  11. package/src/index.ts +17 -0
  12. package/src/opportunity-table/components/apr-breakdown-tooltip.tsx +103 -0
  13. package/src/opportunity-table/components/chain-list.tsx +28 -0
  14. package/src/opportunity-table/components/incentives-breakdown.tsx +263 -0
  15. package/src/opportunity-table/components/index.ts +4 -0
  16. package/src/opportunity-table/components/opportunities-table.tsx +218 -0
  17. package/src/opportunity-table/hooks/index.ts +2 -0
  18. package/src/opportunity-table/hooks/useNetAPR.ts +15 -0
  19. package/src/opportunity-table/hooks/useTotalYield.ts +12 -0
  20. package/src/opportunity-table/utils/calculateNetAPR.ts +37 -0
  21. package/src/opportunity-table/utils/index.ts +1 -0
  22. package/src/route-details/index.ts +2 -0
  23. package/src/route-details/route-details.tsx +100 -0
  24. package/src/transaction-status/components/TransactionStatusSection.tsx +81 -0
  25. package/src/transaction-status/hooks/useTransactionQueue.ts +322 -0
  26. package/src/transaction-status/index.ts +8 -0
  27. package/src/transaction-status/types/index.ts +22 -0
  28. package/src/transaction-status/utils/index.ts +80 -0
  29. package/src/transaction-status/utils/selectors.ts +66 -0
  30. package/tsconfig.json +22 -0
@@ -0,0 +1,81 @@
1
+ import { TxStatus } from "@turtleclub/ui";
2
+ import { useMemo } from "react";
3
+ import type { TransactionStep } from "../types";
4
+ import { formatNumber } from "@turtleclub/utils";
5
+
6
+ interface TransactionStatusSectionProps {
7
+ steps: TransactionStep[];
8
+ currentStep: TransactionStep;
9
+ allCompleted: boolean;
10
+ cancelled: boolean;
11
+ tokenSymbol: string;
12
+ amount: string;
13
+ resetQueue: () => void;
14
+ resetForm: () => void;
15
+ refetchBalances?: () => void;
16
+ }
17
+
18
+ // Transaction Status Section - Shows TxStatus in the info area
19
+ export function TransactionStatusSection({
20
+ steps,
21
+ currentStep,
22
+ allCompleted,
23
+ cancelled,
24
+ tokenSymbol,
25
+ amount,
26
+ resetQueue,
27
+ resetForm,
28
+ refetchBalances,
29
+ }: TransactionStatusSectionProps) {
30
+ // For completed transactions, use the last completed transaction hash
31
+ // For ongoing transactions, use the current step hash
32
+ const lastCompletedStep = steps
33
+ .filter((step) => step.status === "completed" && step.txHash)
34
+ .pop();
35
+ const currentStepWithHash = currentStep?.txHash ? currentStep : null;
36
+ const displayTxHash = allCompleted
37
+ ? lastCompletedStep?.txHash
38
+ : currentStepWithHash?.txHash || lastCompletedStep?.txHash;
39
+
40
+ const txStatusProps = useMemo(
41
+ () => ({
42
+ title: cancelled
43
+ ? "Transaction Cancelled"
44
+ : allCompleted
45
+ ? "Transaction Completed!"
46
+ : "Processing Transaction",
47
+ description: cancelled
48
+ ? "Transaction was cancelled by user"
49
+ : allCompleted
50
+ ? "Your deposit has been successfully processed."
51
+ : "Please wait while your transaction is being confirmed...",
52
+ txHash: displayTxHash,
53
+ explorerUrl: "https://etherscan.io", // TODO: Get from config
54
+ estimatedTime: undefined,
55
+ amount: `${amount ? formatNumber(amount, 5, false, false) : "0"}`,
56
+ token: tokenSymbol,
57
+ protocol: "Turtle Protocol",
58
+ completed: allCompleted,
59
+ cancelled,
60
+ onViewDetails: undefined, // Don't show View Details button for now
61
+ onClose: () => {
62
+ resetQueue();
63
+ resetForm();
64
+ refetchBalances?.();
65
+ },
66
+ steps: steps.map((step) => ({
67
+ label: step.label,
68
+ completed: step.status === "completed",
69
+ current: step.status === "executing",
70
+ txHash: step.txHash,
71
+ })),
72
+ }),
73
+ [steps, allCompleted, cancelled, tokenSymbol, amount, resetQueue, resetForm, refetchBalances, displayTxHash]
74
+ );
75
+
76
+ return (
77
+ <div className="h-full overflow-hidden">
78
+ <TxStatus {...txStatusProps} className="bg-background border-border text-foreground h-full" />
79
+ </div>
80
+ );
81
+ }
@@ -0,0 +1,322 @@
1
+ import { useCallback, useEffect, useMemo, useReducer } from "react";
2
+ import type { EarnRouteResponse, RouterStep, StepTx } from "@turtleclub/hooks";
3
+ import {
4
+ TransactionStep,
5
+ TransactionQueueState,
6
+ TransactionStepStatus,
7
+ QueueStatus,
8
+ } from "../types";
9
+ import { getStepLabel, getStepDescription, isUserRejection, shouldAutoExecuteNextStep } from "../utils";
10
+
11
+ /**
12
+ * useTransactionQueue Hook
13
+ *
14
+ * Manages the execution of multiple blockchain transactions in sequence.
15
+ * Key features:
16
+ * - Maintains a queue of transaction steps from an EarnRoute
17
+ * - Executes transactions one by one with proper state management
18
+ * - Auto-triggers the next transaction after the previous one completes
19
+ * - Handles errors, user cancellations, and retry logic
20
+ *
21
+ * Auto-trigger logic:
22
+ * 1. After a transaction completes, the hook updates currentIndex to point to the next step
23
+ * 2. A useEffect monitors state changes and checks if conditions are met for auto-execution
24
+ * 3. If conditions are met, it automatically triggers the next transaction after a 1s delay
25
+ * 4. This continues until all transactions in the queue are completed
26
+ */
27
+
28
+ // Constants
29
+ const AUTO_EXECUTE_DELAY = 1000; // ms
30
+
31
+ interface UseTransactionQueueProps {
32
+ earnRoute: EarnRouteResponse | null;
33
+ userAddress?: string;
34
+ chainId?: number;
35
+ sendTransaction: (transaction: StepTx) => Promise<`0x${string}` | undefined>;
36
+ onError?: (error: Error) => void;
37
+ onSuccess?: () => void;
38
+ }
39
+
40
+ // Reducer action types
41
+ type QueueAction =
42
+ | { type: "INITIALIZE_QUEUE"; steps: TransactionStep[] }
43
+ | { type: "START_EXECUTION"; index: number }
44
+ | { type: "COMPLETE_STEP"; index: number; txHash: string }
45
+ | { type: "FAIL_STEP"; index: number; error: string; isCancellation: boolean }
46
+ | { type: "RESET_QUEUE"; steps: TransactionStep[] };
47
+
48
+ // Helper functions
49
+ function createTransactionStep(step: RouterStep, index: number): TransactionStep | null {
50
+ // Only process steps that have transaction data
51
+ if (!step.tx) {
52
+ return null;
53
+ }
54
+
55
+ const newStep: TransactionStep = {
56
+ id: index,
57
+ label: getStepLabel(step),
58
+ description: getStepDescription(step, index),
59
+ txData: step.tx,
60
+ status: "pending",
61
+ };
62
+ return newStep;
63
+ }
64
+
65
+ // Reducer function
66
+ function queueReducer(state: TransactionQueueState, action: QueueAction): TransactionQueueState {
67
+ switch (action.type) {
68
+ case "INITIALIZE_QUEUE":
69
+ return {
70
+ steps: action.steps,
71
+ currentIndex: 0,
72
+ queueStatus: "idle" satisfies QueueStatus,
73
+ lastError: undefined,
74
+ };
75
+
76
+ case "START_EXECUTION":
77
+ return {
78
+ ...state,
79
+ steps: state.steps.map((step, idx) => {
80
+ if (idx === action.index) {
81
+ const updatedStep: TransactionStep = { ...step, status: "executing" };
82
+ return updatedStep;
83
+ }
84
+ return step;
85
+ }),
86
+ queueStatus: "executing" satisfies QueueStatus,
87
+ lastError: undefined,
88
+ };
89
+
90
+ case "COMPLETE_STEP": {
91
+ const updatedSteps = state.steps.map((step, idx) => {
92
+ if (idx === action.index) {
93
+ const completedStep: TransactionStep = { ...step, status: "completed", txHash: action.txHash };
94
+ return completedStep;
95
+ }
96
+ return step;
97
+ });
98
+ const nextIndex = action.index + 1;
99
+ const allCompleted = nextIndex >= updatedSteps.length;
100
+
101
+ return {
102
+ ...state,
103
+ steps: updatedSteps,
104
+ currentIndex: allCompleted ? state.currentIndex : nextIndex,
105
+ queueStatus: (allCompleted ? "completed" : "idle") satisfies QueueStatus,
106
+ };
107
+ }
108
+
109
+ case "FAIL_STEP":
110
+ return {
111
+ ...state,
112
+ steps: state.steps.map((step, idx) => {
113
+ if (idx === action.index) {
114
+ const failedStep: TransactionStep = { ...step, status: "failed", error: action.error };
115
+ return failedStep;
116
+ }
117
+ return step;
118
+ }),
119
+ queueStatus: (action.isCancellation ? "cancelled" : "error") satisfies QueueStatus,
120
+ lastError: action.error,
121
+ };
122
+
123
+ case "RESET_QUEUE":
124
+ return {
125
+ steps: action.steps,
126
+ currentIndex: 0,
127
+ queueStatus: "idle" satisfies QueueStatus,
128
+ lastError: undefined,
129
+ };
130
+
131
+ default:
132
+ return state;
133
+ }
134
+ }
135
+
136
+ export function useTransactionQueue({
137
+ earnRoute,
138
+ userAddress,
139
+ chainId,
140
+ sendTransaction,
141
+ onError = () => {},
142
+ onSuccess = () => {},
143
+ }: UseTransactionQueueProps) {
144
+ // Initialize state with reducer
145
+ const initialState: TransactionQueueState = {
146
+ steps: [],
147
+ currentIndex: 0,
148
+ queueStatus: "idle",
149
+ };
150
+ const [state, dispatch] = useReducer(queueReducer, initialState);
151
+
152
+ // Initialize queue when earnRoute changes
153
+ useEffect(() => {
154
+ if (!earnRoute?.steps?.length) {
155
+ dispatch({ type: "INITIALIZE_QUEUE", steps: [] });
156
+ return;
157
+ }
158
+
159
+ const steps = earnRoute.steps
160
+ .map((step: RouterStep, index: number) => createTransactionStep(step, index))
161
+ .filter((step): step is TransactionStep => step !== null);
162
+
163
+ dispatch({ type: "INITIALIZE_QUEUE", steps });
164
+ }, [earnRoute]);
165
+
166
+ // Execute current transaction
167
+ const executeCurrentTransaction = useCallback(async () => {
168
+ // Validation checks
169
+ if (!userAddress || !chainId || !sendTransaction || state.queueStatus === "executing") {
170
+ return;
171
+ }
172
+
173
+ const currentStep = state.steps[state.currentIndex];
174
+ if (!currentStep || (currentStep.status !== "pending" && currentStep.status !== "failed")) {
175
+ return;
176
+ }
177
+
178
+ // Security validation: verify transaction 'from' matches connected wallet
179
+ const txFrom = currentStep.txData.from as `0x${string}`;
180
+ if (txFrom.toLowerCase() !== userAddress.toLowerCase()) {
181
+ const error = new Error("Transaction 'from' address doesn't match connected wallet");
182
+ dispatch({
183
+ type: "FAIL_STEP",
184
+ index: state.currentIndex,
185
+ error: error.message,
186
+ isCancellation: false,
187
+ });
188
+ if (onError) {
189
+ onError(error);
190
+ }
191
+ return;
192
+ }
193
+
194
+ // Start execution
195
+ dispatch({ type: "START_EXECUTION", index: state.currentIndex });
196
+
197
+ try {
198
+ // Send transaction with chainId
199
+ const txHash = await sendTransaction({
200
+ ...currentStep.txData,
201
+ chainId,
202
+ });
203
+
204
+ // Transaction is already confirmed at this point because executeTransaction waits for confirmation
205
+ // Mark as completed
206
+ dispatch({ type: "COMPLETE_STEP", index: state.currentIndex, txHash: txHash ?? "" });
207
+
208
+ // Check if all completed and trigger success callback
209
+ const isLastStep = state.currentIndex + 1 >= state.steps.length;
210
+ if (isLastStep && onSuccess) {
211
+ onSuccess();
212
+ }
213
+ } catch (error) {
214
+ // Handle error
215
+ const errorMessage = error instanceof Error ? error.message : "Transaction failed";
216
+ const isCancellation = isUserRejection(error);
217
+
218
+ dispatch({
219
+ type: "FAIL_STEP",
220
+ index: state.currentIndex,
221
+ error: isCancellation ? "Transaction cancelled by user" : errorMessage,
222
+ isCancellation,
223
+ });
224
+
225
+ // Call error callback if not a cancellation
226
+ if (!isCancellation && onError) {
227
+ onError(error instanceof Error ? error : new Error(errorMessage));
228
+ }
229
+ }
230
+ }, [
231
+ userAddress,
232
+ chainId,
233
+ sendTransaction,
234
+ state.queueStatus,
235
+ state.steps,
236
+ state.currentIndex,
237
+ onSuccess,
238
+ onError,
239
+ ]);
240
+
241
+ // Reset queue to initial state
242
+ const resetQueue = useCallback(() => {
243
+ if (!earnRoute?.steps?.length) {
244
+ return;
245
+ }
246
+
247
+ const steps = earnRoute.steps
248
+ .map((step: RouterStep, index: number) => createTransactionStep(step, index))
249
+ .filter((step): step is TransactionStep => step !== null);
250
+
251
+ dispatch({ type: "RESET_QUEUE", steps });
252
+ }, [earnRoute]);
253
+
254
+ // Computed values
255
+ const currentStep = state.steps[state.currentIndex] || null;
256
+ const isExecuting = state.queueStatus === "executing";
257
+ const allCompleted = state.queueStatus === "completed";
258
+ const hasError = state.queueStatus === "error";
259
+ const cancelled = state.queueStatus === "cancelled";
260
+ const completedSteps = state.steps.filter((step) => step.status === "completed").length;
261
+ const totalSteps = state.steps.length;
262
+ const progress = totalSteps > 0 ? (completedSteps / totalSteps) * 100 : 0;
263
+
264
+ const showTxStatus = useMemo(() => {
265
+ if (!currentStep) return false;
266
+ if (allCompleted) return true;
267
+ if (isExecuting && currentStep.id === 0) return true;
268
+ if (currentStep.id > 0) return true;
269
+ return false;
270
+ }, [currentStep, allCompleted, isExecuting]);
271
+
272
+ // Check if ready to execute
273
+ const canExecute = !!(
274
+ userAddress &&
275
+ chainId &&
276
+ state.steps.length > 0 &&
277
+ state.queueStatus !== "executing" &&
278
+ state.queueStatus !== "completed"
279
+ );
280
+
281
+ // Auto-execute next transaction when current one completes (only for multi-step transactions)
282
+ useEffect(() => {
283
+ if (shouldAutoExecuteNextStep(state)) {
284
+ // Delay execution to allow UI to update
285
+ const timer = setTimeout(() => {
286
+ executeCurrentTransaction();
287
+ }, AUTO_EXECUTE_DELAY);
288
+
289
+ // Cleanup timeout on unmount or dependency change
290
+ return () => clearTimeout(timer);
291
+ }
292
+ }, [state, executeCurrentTransaction]);
293
+
294
+ // Return value maintaining the same signature
295
+ return {
296
+ steps: state.steps.map((step, index) => ({
297
+ ...step,
298
+ completed: step.status === "completed",
299
+ current: index === state.currentIndex,
300
+ })),
301
+ currentIndex: state.currentIndex,
302
+ isExecuting,
303
+ allCompleted,
304
+ hasError,
305
+ cancelled,
306
+ error: state.lastError,
307
+ currentStep: currentStep
308
+ ? {
309
+ ...currentStep,
310
+ completed: currentStep.status === "completed",
311
+ current: true,
312
+ }
313
+ : null,
314
+ canExecute,
315
+ executeCurrentTransaction,
316
+ resetQueue,
317
+ totalSteps,
318
+ completedSteps,
319
+ progress,
320
+ showTxStatus,
321
+ };
322
+ }
@@ -0,0 +1,8 @@
1
+ export { TransactionStatusSection } from "./components/TransactionStatusSection";
2
+ export { useTransactionQueue } from "./hooks/useTransactionQueue";
3
+ export type {
4
+ TransactionStep,
5
+ TransactionQueueState,
6
+ TransactionStepStatus,
7
+ QueueStatus,
8
+ } from "./types";
@@ -0,0 +1,22 @@
1
+ import type { StepTx } from "@turtleclub/hooks";
2
+
3
+ export type TransactionStepStatus = "pending" | "executing" | "completed" | "failed";
4
+
5
+ export interface TransactionStep {
6
+ id: number;
7
+ label: string;
8
+ description: string;
9
+ txData: StepTx;
10
+ status: TransactionStepStatus;
11
+ txHash?: string;
12
+ error?: string;
13
+ }
14
+
15
+ export type QueueStatus = "idle" | "executing" | "completed" | "cancelled" | "error";
16
+
17
+ export interface TransactionQueueState {
18
+ steps: TransactionStep[];
19
+ currentIndex: number;
20
+ queueStatus: QueueStatus;
21
+ lastError?: string;
22
+ }
@@ -0,0 +1,80 @@
1
+ import type { RouterStep } from "@turtleclub/hooks";
2
+ import type { TransactionQueueState } from "../types";
3
+
4
+ /**
5
+ * Generate a user-friendly label for a transaction step
6
+ * @param step - The router step from the API
7
+ * @returns A formatted label string
8
+ */
9
+ export function getStepLabel(step: RouterStep): string {
10
+ if (step.kind === "approve" && step.token) {
11
+ return `Approve ${step.token.symbol}`;
12
+ }
13
+ if (step.kind === "enso") {
14
+ return `Execute Deposit`;
15
+ }
16
+ return `Execute ${step.kind}`;
17
+ }
18
+
19
+ /**
20
+ * Generate a user-friendly description for a transaction step
21
+ * @param step - The router step from the API
22
+ * @param index - The step index in the queue
23
+ * @returns A formatted description string
24
+ */
25
+ export function getStepDescription(step: RouterStep, index: number): string {
26
+ if (step.kind === "approve" && step.token) {
27
+ return `Allow contract to spend ${step.token.symbol}`;
28
+ }
29
+ return `Transaction step ${index + 1}`;
30
+ }
31
+
32
+ /**
33
+ * Check if an error is a user rejection (e.g., cancelled transaction in wallet)
34
+ * @param error - The error to check
35
+ * @returns True if the error indicates user rejection
36
+ */
37
+ export function isUserRejection(error: Error | unknown): boolean {
38
+ const errorMessage = error instanceof Error ? error.message : String(error);
39
+ const rejectionPatterns = [
40
+ "user rejected",
41
+ "user denied",
42
+ "user cancelled",
43
+ "cancelled by user",
44
+ "transaction rejected",
45
+ "4001", // MetaMask user rejection code
46
+ ];
47
+
48
+ return rejectionPatterns.some((pattern) =>
49
+ errorMessage.toLowerCase().includes(pattern.toLowerCase())
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Determine if the next transaction step should be auto-executed
55
+ * This is used for multi-step transaction flows where steps need to be executed sequentially
56
+ *
57
+ * @param state - The current transaction queue state
58
+ * @returns True if the next step should be automatically executed
59
+ */
60
+ export function shouldAutoExecuteNextStep(state: TransactionQueueState): boolean {
61
+ // No auto-execute for single step transactions
62
+ if (state.steps.length <= 1 || state.queueStatus !== "idle") {
63
+ return false;
64
+ }
65
+
66
+ const previousStep = state.steps[state.currentIndex - 1];
67
+ const currentStep = state.steps[state.currentIndex];
68
+
69
+ // Auto-execute if:
70
+ // - We've already executed at least one transaction
71
+ // - Previous step is completed
72
+ // - Current step is pending
73
+ // - There are more steps to execute
74
+ return (
75
+ state.currentIndex > 0 &&
76
+ state.currentIndex < state.steps.length &&
77
+ previousStep?.status === "completed" &&
78
+ currentStep?.status === "pending"
79
+ );
80
+ }
@@ -0,0 +1,66 @@
1
+ import type { TransactionQueueState } from "../types";
2
+
3
+ /**
4
+ * Selectors for computed values from transaction queue state
5
+ * These are pure functions that derive values from state
6
+ */
7
+
8
+ export interface QueueMetrics {
9
+ completedSteps: number;
10
+ totalSteps: number;
11
+ progress: number;
12
+ isExecuting: boolean;
13
+ allCompleted: boolean;
14
+ hasError: boolean;
15
+ cancelled: boolean;
16
+ canExecute: boolean;
17
+ }
18
+
19
+ export function selectQueueMetrics(
20
+ state: TransactionQueueState,
21
+ hasRequiredProps: { userAddress?: string; chainId?: number }
22
+ ): QueueMetrics {
23
+ const completedSteps = state.steps.filter((step) => step.status === "completed").length;
24
+ const totalSteps = state.steps.length;
25
+ const progress = totalSteps > 0 ? (completedSteps / totalSteps) * 100 : 0;
26
+
27
+ const isExecuting = state.queueStatus === "executing";
28
+ const allCompleted = state.queueStatus === "completed";
29
+ const hasError = state.queueStatus === "error";
30
+ const cancelled = state.queueStatus === "cancelled";
31
+
32
+ const canExecute = !!(
33
+ hasRequiredProps.userAddress &&
34
+ hasRequiredProps.chainId &&
35
+ state.steps.length > 0 &&
36
+ state.queueStatus !== "executing" &&
37
+ state.queueStatus !== "completed"
38
+ );
39
+
40
+ return {
41
+ completedSteps,
42
+ totalSteps,
43
+ progress,
44
+ isExecuting,
45
+ allCompleted,
46
+ hasError,
47
+ cancelled,
48
+ canExecute,
49
+ };
50
+ }
51
+
52
+ export function selectCurrentStep(state: TransactionQueueState) {
53
+ return state.steps[state.currentIndex] || null;
54
+ }
55
+
56
+ export function selectShouldShowTxStatus(state: TransactionQueueState): boolean {
57
+ const currentStep = selectCurrentStep(state);
58
+ const isExecuting = state.queueStatus === "executing";
59
+ const allCompleted = state.queueStatus === "completed";
60
+
61
+ if (!currentStep) return false;
62
+ if (allCompleted) return true;
63
+ if (isExecuting && currentStep.id === 0) return true;
64
+ if (currentStep.id > 0) return true;
65
+ return false;
66
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "jsx": "react-jsx",
7
+ "moduleResolution": "bundler",
8
+ "resolveJsonModule": true,
9
+ "allowImportingTsExtensions": true,
10
+ "strict": true,
11
+ "noEmit": true,
12
+ "skipLibCheck": true,
13
+ "esModuleInterop": true,
14
+ "allowSyntheticDefaultImports": true,
15
+ "forceConsistentCasingInFileNames": true,
16
+ "isolatedModules": true,
17
+ "outDir": "dist",
18
+ "rootDir": "src"
19
+ },
20
+ "include": ["src"],
21
+ "exclude": ["node_modules", "dist"]
22
+ }