diff --git a/apps/megaeth/components.json b/apps/megaeth/components.json index da48ccb..5ff4afb 100644 --- a/apps/megaeth/components.json +++ b/apps/megaeth/components.json @@ -19,6 +19,7 @@ "hooks": "@/hooks" }, "registries": { - "@aceternity": "https://ui.aceternity.com/registry/{name}.json" + "@aceternity": "https://ui.aceternity.com/registry/{name}.json", + "@nexus-elements": "https://elements.nexus.availproject.org/r/{name}.json" } } diff --git a/apps/megaeth/package.json b/apps/megaeth/package.json index f3961f1..dd6f9d7 100644 --- a/apps/megaeth/package.json +++ b/apps/megaeth/package.json @@ -11,12 +11,14 @@ "preview": "vite preview --port 5173" }, "dependencies": { - "@avail-project/nexus-core": "github:availproject/nexus-sdk#cca99c1a570a8598334e3e89453d39a27d70ede7", + "@avail-project/nexus-core": "^1.1.1", "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@rainbow-me/rainbowkit": "^2.2.8", "@tailwindcss/vite": "^4.1.16", @@ -28,16 +30,18 @@ "decimal.js": "^10.6.0", "lucide-react": "^0.544.0", "motion": "^12.29.2", + "next": "^16.1.6", "next-themes": "^0.4.6", "posthog-js": "^1.336.4", "react": "19.2.2", "react-dom": "19.2.2", + "react-router-dom": "^7.13.0", "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1", + "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.13", - "viem": "^2.37.9", + "viem": "^2.45.1", "vite-plugin-node-polyfills": "^0.24.0", - "wagmi": "^2.17.5" + "wagmi": "^2.19.5" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/apps/megaeth/src/App.tsx b/apps/megaeth/src/App.tsx index d34f14c..39f3fdc 100644 --- a/apps/megaeth/src/App.tsx +++ b/apps/megaeth/src/App.tsx @@ -1,3 +1,10 @@ +import { + BrowserRouter, + Routes, + Route, + useNavigate, + useLocation, +} from "react-router-dom"; import Web3Provider from "@/providers/web3Provider"; import { Toaster } from "@/components/ui/sonner"; import Navbar from "@/components/navbar"; @@ -6,72 +13,102 @@ import FastBridgeShowcase from "@/components/fast-bridge-showcase"; import NexusProvider from "@/components/nexus/NexusProvider"; import config from "../config"; import { motion } from "motion/react"; +import { NexusInitializer } from "@/components/NexusInitializer"; +import { AnimatedTabs } from "@/components/ui/animated-tabs"; +import Opportunities from "@/pages/Opportunities"; +import { type NexusNetwork } from "@avail-project/nexus-core"; -// @ts-expect-error - Environment is not exported from @avail-project/nexus-core -enum Environment { - FOLLY, // Dev with test-net tokens - CERISE, // Dev with main-net tokens - CORAL, // Test-net with main-net tokens - JADE, // Main-net with main-net tokens -} +function AppContent() { + const navigate = useNavigate(); + const location = useLocation(); + + const tabs = [ + { id: "fastbridge", label: "Fast Bridge" }, + { id: "opportunities", label: "Opportunities" }, + ]; + + const activeTab = location.pathname.startsWith("/opportunities") + ? "opportunities" + : "fastbridge"; + + const handleTabChange = (id: string) => { + if (id === "fastbridge") navigate("/"); + else if (id === "opportunities") navigate("/opportunities"); + }; -export default function App() { return ( - - -
- -
-
+ +
+
+
+
+ -
- - -
-
- -
- - - Reach out to us if
you face any issues -
-
-
- - + + + + + + + } + /> + } /> + + +
+
+ ); +} + +export default function App() { + return ( + + + + + + +
+ + + Reach out to us if
you face any issues +
+
+
+
+
+
+
); } diff --git a/apps/megaeth/src/components/NexusInitializer.tsx b/apps/megaeth/src/components/NexusInitializer.tsx new file mode 100644 index 0000000..fe64245 --- /dev/null +++ b/apps/megaeth/src/components/NexusInitializer.tsx @@ -0,0 +1,102 @@ +"use client"; +import * as React from "react"; +import type { EthereumProvider } from "@avail-project/nexus-core"; +import { useAccount } from "wagmi"; +import { useNexus } from "./nexus/NexusProvider"; +import { toast } from "sonner"; + +/** + * NexusInitializer - Lives at App level to initialize Nexus once. + * This component handles Nexus SDK initialization/deinitialization + * based on wallet connection status. Since it's mounted at the App level, + * it won't re-initialize when routes change. + */ +export function NexusInitializer({ children }: { children: React.ReactNode }) { + const [loading, setLoading] = React.useState(false); + const [initError, setInitError] = React.useState(null); + const { status, connector, address } = useAccount(); + const { nexusSDK, handleInit, deinitializeNexus } = useNexus(); + const prevAddressRef = React.useRef(undefined); + const initializingRef = React.useRef(false); + + const initializeNexus = React.useCallback(async () => { + // Prevent multiple simultaneous initialization attempts + if (loading || nexusSDK || initializingRef.current) return; + + initializingRef.current = true; + setLoading(true); + setInitError(null); + + try { + const provider = (await connector?.getProvider()) as EthereumProvider; + if (!provider) { + throw new Error("No provider available"); + } + + await handleInit(provider); + } catch (error) { + console.error("Nexus initialization failed:", error); + const errorMessage = (error as Error)?.message || "Unknown error"; + setInitError(errorMessage); + toast.error(`Failed to initialize Nexus: ${errorMessage}`); + } finally { + setLoading(false); + initializingRef.current = false; + } + }, [connector, handleInit, loading, nexusSDK]); + + // Handle wallet disconnection - clear Nexus state + React.useEffect(() => { + if (status === "disconnected" && nexusSDK) { + deinitializeNexus(); + prevAddressRef.current = undefined; + location.reload(); + } // reload page to free the resources + }, [status, nexusSDK, deinitializeNexus]); + + // Handle account change - reinitialize Nexus when account address changes + React.useEffect(() => { + if ( + status === "connected" && + address && + address !== prevAddressRef.current + ) { + const previousAddress = prevAddressRef.current; + prevAddressRef.current = address; + + // If account changed and Nexus is initialized, reinitialize with new account + if (nexusSDK && previousAddress !== undefined) { + deinitializeNexus().then(() => { + setTimeout(() => { + if ( + address === prevAddressRef.current && + !initializingRef.current + ) { + initializeNexus(); + } + }, 100); + }); + } + } else if (status === "connected" && address && !prevAddressRef.current) { + prevAddressRef.current = address; + } + }, [status, nexusSDK, address, initializeNexus, deinitializeNexus]); + + // Auto-initialize Nexus when wallet is connected (first time) + React.useEffect(() => { + if ( + status === "connected" && + !nexusSDK && + !loading && + !initError && + address && + !initializingRef.current + ) { + initializeNexus(); + } + }, [status, nexusSDK, initError, address, initializeNexus, loading]); + + // Expose initialization state via context or just render children + // The actual loading UI is handled by PreviewPanel + return <>{children}; +} diff --git a/apps/megaeth/src/components/common/components/ErrorBoundary.tsx b/apps/megaeth/src/components/common/components/ErrorBoundary.tsx new file mode 100644 index 0000000..9808b2e --- /dev/null +++ b/apps/megaeth/src/components/common/components/ErrorBoundary.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { Component, type ErrorInfo, type ReactNode } from "react"; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +/** + * Error boundary component that catches JavaScript errors in child components. + * Displays a fallback UI instead of crashing the entire widget. + * + * @example + * Something went wrong
} + * onError={(error) => console.error(error)} + * > + * + * + */ +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error("ErrorBoundary caught an error:", error, errorInfo); + this.props.onError?.(error, errorInfo); + } + + render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+ Something went wrong +
+

+ An unexpected error occurred. Please try again. +

+ +
+ ); + } + + return this.props.children; + } +} + +/** + * A more specific error boundary for widget containers with reset capability. + */ +interface WidgetErrorBoundaryProps extends ErrorBoundaryProps { + widgetName?: string; + onReset?: () => void; +} + +export class WidgetErrorBoundary extends Component< + WidgetErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: WidgetErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error( + `WidgetErrorBoundary [${this.props.widgetName ?? "Unknown"}]:`, + error, + errorInfo + ); + this.props.onError?.(error, errorInfo); + } + + handleReset = (): void => { + this.props.onReset?.(); + this.setState({ hasError: false, error: null }); + }; + + render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+ {this.props.widgetName + ? `${this.props.widgetName} encountered an error` + : "Widget error"} +
+

+ {this.state.error?.message || "An unexpected error occurred."} +

+ +
+ ); + } + + return this.props.children; + } +} diff --git a/apps/megaeth/src/components/common/hooks/useLatest.ts b/apps/megaeth/src/components/common/hooks/useLatest.ts new file mode 100644 index 0000000..4f30294 --- /dev/null +++ b/apps/megaeth/src/components/common/hooks/useLatest.ts @@ -0,0 +1,22 @@ +import { useRef, useLayoutEffect } from "react"; + +/** + * Returns a ref that always contains the latest value. + * Useful for accessing current values in callbacks without causing re-renders. + * + * @example + * const countRef = useLatest(count); + * const handleClick = useCallback(() => { + * console.log(countRef.current); // Always the latest count + * }, []); // No dependency needed! + */ +export function useLatest(value: T): React.MutableRefObject { + const ref = useRef(value); + + // Use useLayoutEffect to update synchronously before any effects run + useLayoutEffect(() => { + ref.current = value; + }); + + return ref; +} diff --git a/apps/megaeth/src/components/common/hooks/useNexusError.ts b/apps/megaeth/src/components/common/hooks/useNexusError.ts index 3276d17..f28c3d9 100644 --- a/apps/megaeth/src/components/common/hooks/useNexusError.ts +++ b/apps/megaeth/src/components/common/hooks/useNexusError.ts @@ -1,24 +1,171 @@ -import { NexusError } from "@avail-project/nexus-core"; +import { ERROR_CODES, NexusError } from "@avail-project/nexus-core"; -function handler(err: unknown) { - console.log("NEXUS-ERROR-OBJECT", err) +const DEFAULT_ERROR_MESSAGE = "Oops! Something went wrong. Please try again."; +const USER_REJECTED_MESSAGE = "Transaction was rejected in your wallet."; +const EMPTY_ERROR_MESSAGE = + "Unable to determine transaction state. Please refresh and try again."; + +const ERROR_MESSAGE_BY_CODE: Partial> = { + [ERROR_CODES.INVALID_VALUES_ALLOWANCE_HOOK]: + "Invalid allowance selection. Please review allowance values and try again.", + [ERROR_CODES.SDK_NOT_INITIALIZED]: + "Nexus SDK is not initialized. Reconnect your wallet and try again.", + [ERROR_CODES.SDK_INIT_STATE_NOT_EXPECTED]: + "Nexus is still initializing. Please wait a few seconds and retry.", + [ERROR_CODES.CHAIN_NOT_FOUND]: + "Selected chain is not supported for this route.", + [ERROR_CODES.CHAIN_DATA_NOT_FOUND]: + "Chain metadata is unavailable for this route. Please try another chain.", + [ERROR_CODES.ASSET_NOT_FOUND]: + "Requested asset was not found in your balances.", + [ERROR_CODES.COSMOS_ERROR]: + "Cosmos-side operation failed. Please retry in a moment.", + [ERROR_CODES.TOKEN_NOT_SUPPORTED]: + "Selected token is not supported for this route.", + [ERROR_CODES.UNIVERSE_NOT_SUPPORTED]: + "Selected chain universe is not supported yet.", + [ERROR_CODES.ENVIRONMENT_NOT_SUPPORTED]: + "Selected environment is not supported yet.", + [ERROR_CODES.ENVIRONMENT_NOT_KNOWN]: + "Selected environment is not recognized.", + [ERROR_CODES.UNKNOWN_SIGNATURE]: + "Unsupported signature type for this transaction.", + [ERROR_CODES.TRON_DEPOSIT_FAIL]: + "TRON deposit transaction failed. Please retry.", + [ERROR_CODES.TRON_APPROVAL_FAIL]: + "TRON approval transaction failed. Please retry.", + [ERROR_CODES.LIQUIDITY_TIMEOUT]: + "Timed out waiting for liquidity. Please retry.", + [ERROR_CODES.USER_DENIED_INTENT]: USER_REJECTED_MESSAGE, + [ERROR_CODES.USER_DENIED_ALLOWANCE]: USER_REJECTED_MESSAGE, + [ERROR_CODES.USER_DENIED_INTENT_SIGNATURE]: USER_REJECTED_MESSAGE, + [ERROR_CODES.USER_DENIED_SIWE_SIGNATURE]: USER_REJECTED_MESSAGE, + [ERROR_CODES.INSUFFICIENT_BALANCE]: "Insufficient balance to proceed.", + [ERROR_CODES.WALLET_NOT_CONNECTED]: + "Wallet is not connected. Connect your wallet and try again.", + [ERROR_CODES.FETCH_GAS_PRICE_FAILED]: + "Unable to estimate gas right now. Please retry.", + [ERROR_CODES.SIMULATION_FAILED]: + "Simulation failed. Please review your inputs and try again.", + [ERROR_CODES.QUOTE_FAILED]: + "Unable to fetch a quote right now. Please retry.", + [ERROR_CODES.SWAP_FAILED]: "Swap execution failed. Please retry.", + [ERROR_CODES.VAULT_CONTRACT_NOT_FOUND]: + "Required vault contract is unavailable on this chain.", + [ERROR_CODES.SLIPPAGE_EXCEEDED_ALLOWANCE]: + "Slippage exceeded tolerance. Refresh quote and retry.", + [ERROR_CODES.RATES_CHANGED_BEYOND_TOLERANCE]: + "Rates changed beyond tolerance. Review and retry.", + [ERROR_CODES.RFF_FEE_EXPIRED]: + "Quote expired. Refresh and try again.", + [ERROR_CODES.INVALID_INPUT]: + "Some transaction inputs are invalid. Please review and try again.", + [ERROR_CODES.INVALID_ADDRESS_LENGTH]: + "Address format is invalid for the selected chain.", + [ERROR_CODES.NO_BALANCE_FOR_ADDRESS]: + "No balance found for this wallet on supported source chains.", + [ERROR_CODES.TRANSACTION_TIMEOUT]: + "Transaction is taking longer than expected. Check your wallet and explorer.", + [ERROR_CODES.TRANSACTION_REVERTED]: + "Transaction reverted on-chain. Please verify inputs and retry.", + [ERROR_CODES.DESTINATION_REQUEST_HASH_NOT_FOUND]: + "Could not finalize destination request. Please retry.", +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getErrorMessage(value: unknown): string | undefined { + if (!isRecord(value)) return undefined; + const message = value.message; + return typeof message === "string" ? message : undefined; +} + +function getErrorCode(value: unknown): string | number | undefined { + if (!isRecord(value)) return undefined; + const code = value.code; + if (typeof code === "string" || typeof code === "number") { + return code; + } + return undefined; +} + +function looksLikeUserRejection(err: unknown): boolean { if (err instanceof NexusError) { + return ( + err.code === ERROR_CODES.USER_DENIED_ALLOWANCE || + err.code === ERROR_CODES.USER_DENIED_INTENT || + err.code === ERROR_CODES.USER_DENIED_INTENT_SIGNATURE || + err.code === ERROR_CODES.USER_DENIED_SIWE_SIGNATURE + ); + } + + const code = getErrorCode(err); + if (code === 4001 || code === "ACTION_REJECTED") { + return true; + } + + const message = getErrorMessage(err)?.toLowerCase(); + if (!message) return false; + return ( + message.includes("user denied") || + message.includes("user rejected") || + message.includes("rejected request") || + message.includes("denied transaction signature") + ); +} + +function sanitizeMessage(message?: string): string { + if (!message) return DEFAULT_ERROR_MESSAGE; + const cleaned = message + .replace(/^Internal error:\s*/i, "") + .replace(/^COSMOS:\s*/i, "") + .trim(); + return cleaned || DEFAULT_ERROR_MESSAGE; +} + +function handler(err: unknown) { + if (err === null || err === undefined) { + console.error("Unexpected empty error from Nexus SDK:", err); return { - code: err?.code, - message: err?.message, - context: err?.data?.context, - details: err?.data?.details, + code: "unexpected_error", + message: EMPTY_ERROR_MESSAGE, + context: undefined, + details: undefined, }; - } else { - console.error("Unexpected error:", err); + } + + if (looksLikeUserRejection(err)) { return { - code: "unexpected_error", - message: "Oops! Something went wrong. Please try again.", + code: ERROR_CODES.USER_DENIED_INTENT, + message: USER_REJECTED_MESSAGE, context: undefined, details: undefined, }; } + + if (err instanceof NexusError) { + const mappedMessage = + ERROR_MESSAGE_BY_CODE[err.code] ?? sanitizeMessage(err.message); + return { + code: err.code, + message: mappedMessage, + context: err?.data?.context, + details: err?.data?.details, + }; + } + + const unknownMessage = sanitizeMessage(getErrorMessage(err)); + console.error("Unexpected error:", err); + return { + code: String(getErrorCode(err) ?? "unexpected_error"), + message: unknownMessage || DEFAULT_ERROR_MESSAGE, + context: undefined, + details: undefined, + }; } + export function useNexusError() { return handler; } diff --git a/apps/megaeth/src/components/common/hooks/useTransactionExecution.ts b/apps/megaeth/src/components/common/hooks/useTransactionExecution.ts new file mode 100644 index 0000000..f272b8e --- /dev/null +++ b/apps/megaeth/src/components/common/hooks/useTransactionExecution.ts @@ -0,0 +1,383 @@ +import { + type BridgeStepType, + NEXUS_EVENTS, + type NexusSDK, + type OnAllowanceHookData, + type OnIntentHookData, +} from "@avail-project/nexus-core"; +import { + type Dispatch, + type RefObject, + type SetStateAction, + useCallback, + useRef, +} from "react"; +import { type TransactionStatus } from "../tx/types"; +import { + type SourceSelectionValidation, + type TransactionFlowEvent, + type TransactionFlowExecutor, + type TransactionFlowInputs, +} from "../types/transaction-flow"; + +interface NexusErrorInfo { + code: string; + message: string; + context?: unknown; + details?: unknown; +} + +type NexusErrorHandler = (error: unknown) => NexusErrorInfo; + +interface UseTransactionExecutionProps { + operationName: "bridge" | "transfer"; + nexusSDK: NexusSDK | null; + intent: RefObject; + allowance: RefObject; + inputs: TransactionFlowInputs; + configuredMaxAmount?: string; + allAvailableSourceChainIds: number[]; + sourceChainsForSdk?: number[]; + sourceSelectionKey: string; + sourceSelection: SourceSelectionValidation; + loading: boolean; + txError: string | null; + areInputsValid: boolean; + executeTransaction: TransactionFlowExecutor; + getMaxForCurrentSelection: () => Promise; + onStepsList: (steps: BridgeStepType[]) => void; + onStepComplete: (step: BridgeStepType) => void; + resetSteps: () => void; + setStatus: (status: TransactionStatus) => void; + resetInputs: () => void; + setRefreshing: Dispatch>; + setIsDialogOpen: Dispatch>; + setTxError: Dispatch>; + setLastExplorerUrl: Dispatch>; + setSelectedSourceChains: Dispatch>; + setAppliedSourceSelectionKey: Dispatch>; + stopwatch: { + start: () => void; + stop: () => void; + reset: () => void; + }; + handleNexusError: NexusErrorHandler; + onStart?: () => void; + onComplete?: (explorerUrl?: string) => void; + onError?: (message: string) => void; + fetchBalance: () => Promise; + notifyHistoryRefresh?: () => void; +} + +export function useTransactionExecution({ + operationName, + nexusSDK, + intent, + allowance, + inputs, + configuredMaxAmount, + allAvailableSourceChainIds, + sourceChainsForSdk, + sourceSelectionKey, + sourceSelection, + loading, + txError, + areInputsValid, + executeTransaction, + getMaxForCurrentSelection, + onStepsList, + onStepComplete, + resetSteps, + setStatus, + resetInputs, + setRefreshing, + setIsDialogOpen, + setTxError, + setLastExplorerUrl, + setSelectedSourceChains, + setAppliedSourceSelectionKey, + stopwatch, + handleNexusError, + onStart, + onComplete, + onError, + fetchBalance, + notifyHistoryRefresh, +}: UseTransactionExecutionProps) { + const commitLockRef = useRef(false); + const runIdRef = useRef(0); + + const refreshIntent = async (options?: { reportError?: boolean }) => { + if (!intent.current) return false; + const activeRunId = runIdRef.current; + setRefreshing(true); + try { + const updated = await intent.current.refresh(sourceChainsForSdk); + if (activeRunId !== runIdRef.current) return false; + if (updated) { + intent.current.intent = updated; + } + setAppliedSourceSelectionKey(sourceSelectionKey); + return true; + } catch (error) { + if (activeRunId !== runIdRef.current) return false; + console.error("Transaction failed:", error); + if (options?.reportError) { + const message = "Unable to refresh source selection. Please try again."; + setTxError(message); + onError?.(message); + } + return false; + } finally { + if (activeRunId === runIdRef.current) { + setRefreshing(false); + } + } + }; + + const onSuccess = async (explorerUrl?: string) => { + stopwatch.stop(); + setStatus("success"); + onComplete?.(explorerUrl); + intent.current = null; + allowance.current = null; + resetInputs(); + setRefreshing(false); + setSelectedSourceChains(null); + setAppliedSourceSelectionKey("ALL"); + await fetchBalance(); + notifyHistoryRefresh?.(); + }; + + const handleTransaction = async () => { + if (commitLockRef.current) return; + commitLockRef.current = true; + const currentRunId = ++runIdRef.current; + let didEnterExecutingState = false; + const cleanupSupersededExecution = () => { + if (!didEnterExecutingState) return; + setRefreshing(false); + setIsDialogOpen(false); + setLastExplorerUrl(""); + stopwatch.stop(); + stopwatch.reset(); + resetSteps(); + setStatus("idle"); + }; + + try { + if ( + !inputs?.amount || + !inputs?.recipient || + !inputs?.chain || + !inputs?.token + ) { + console.error("Missing required inputs"); + return; + } + if (!nexusSDK) { + const message = "Nexus SDK not initialized"; + setTxError(message); + onError?.(message); + return; + } + if (allAvailableSourceChainIds.length === 0) { + const message = + "No eligible source chains available for the selected token and destination."; + setTxError(message); + onError?.(message); + setStatus("error"); + return; + } + + const parsedAmount = Number(inputs.amount); + if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { + const message = "Enter a valid amount greater than 0."; + setTxError(message); + onError?.(message); + setStatus("error"); + return; + } + + const amountBigInt = nexusSDK.convertTokenReadableAmountToBigInt( + inputs.amount, + inputs.token, + inputs.chain, + ); + + if (configuredMaxAmount) { + const configuredMaxRaw = nexusSDK.convertTokenReadableAmountToBigInt( + configuredMaxAmount, + inputs.token, + inputs.chain, + ); + if (amountBigInt > configuredMaxRaw) { + const message = `Amount exceeds maximum limit of ${configuredMaxAmount} ${inputs.token}.`; + setTxError(message); + onError?.(message); + setStatus("error"); + return; + } + } + + const maxForCurrentSelection = await getMaxForCurrentSelection(); + if (currentRunId !== runIdRef.current) return; + if (!maxForCurrentSelection) { + const message = `Unable to determine max ${operationName} amount for selected sources. Please try again.`; + setTxError(message); + onError?.(message); + setStatus("error"); + return; + } + const maxForSelectionRaw = nexusSDK.convertTokenReadableAmountToBigInt( + maxForCurrentSelection, + inputs.token, + inputs.chain, + ); + if (amountBigInt > maxForSelectionRaw) { + const message = `Selected sources can provide up to ${maxForCurrentSelection} ${inputs.token}. Reduce amount or enable more sources.`; + setTxError(message); + onError?.(message); + setStatus("error"); + return; + } + + setStatus("executing"); + didEnterExecutingState = true; + setTxError(null); + onStart?.(); + setLastExplorerUrl(""); + setAppliedSourceSelectionKey(sourceSelectionKey); + + const onEvent = (event: TransactionFlowEvent) => { + if (currentRunId !== runIdRef.current) return; + if (event.name === NEXUS_EVENTS.STEPS_LIST) { + const list = Array.isArray(event.args) ? event.args : []; + onStepsList(list as BridgeStepType[]); + } + if (event.name === NEXUS_EVENTS.STEP_COMPLETE) { + if ( + !Array.isArray(event.args) && + "type" in event.args && + event.args.type === "INTENT_HASH_SIGNED" + ) { + stopwatch.start(); + } + if (!Array.isArray(event.args)) { + onStepComplete(event.args as BridgeStepType); + } + } + }; + + const transactionResult = await executeTransaction({ + token: inputs.token, + amount: amountBigInt, + toChainId: inputs.chain, + recipient: inputs.recipient, + sourceChains: sourceChainsForSdk, + onEvent, + }); + + if (currentRunId !== runIdRef.current) { + cleanupSupersededExecution(); + return; + } + if (!transactionResult) { + throw new Error("Transaction rejected by user"); + } + setLastExplorerUrl(transactionResult.explorerUrl); + await onSuccess(transactionResult.explorerUrl); + } catch (error) { + if (currentRunId !== runIdRef.current) { + cleanupSupersededExecution(); + return; + } + const { message, code, context, details } = handleNexusError(error); + console.error(`Fast ${operationName} transaction failed:`, { + code, + message, + context, + details, + }); + intent.current?.deny(); + intent.current = null; + allowance.current = null; + setTxError(message); + onError?.(message); + setIsDialogOpen(false); + setSelectedSourceChains(null); + setRefreshing(false); + stopwatch.stop(); + stopwatch.reset(); + resetSteps(); + void fetchBalance(); + setStatus("error"); + } finally { + commitLockRef.current = false; + } + }; + + const reset = () => { + runIdRef.current += 1; + intent.current?.deny(); + intent.current = null; + allowance.current = null; + resetInputs(); + setStatus("idle"); + setRefreshing(false); + setSelectedSourceChains(null); + setAppliedSourceSelectionKey("ALL"); + setLastExplorerUrl(""); + stopwatch.stop(); + stopwatch.reset(); + resetSteps(); + }; + + const startTransaction = () => { + if (!intent.current) return; + if (allAvailableSourceChainIds.length === 0) { + const message = + "No eligible source chains available for the selected token and destination."; + setTxError(message); + onError?.(message); + return; + } + if (sourceSelection.isBelowRequired && inputs?.token) { + const message = `Selected sources are not enough. Add ${sourceSelection.missingToProceed} ${inputs.token} more to make this transaction.`; + setTxError(message); + onError?.(message); + return; + } + void (async () => { + const refreshed = await refreshIntent({ reportError: true }); + if (!refreshed || !intent.current) return; + intent.current.allow(); + setIsDialogOpen(true); + setTxError(null); + })(); + }; + + const commitAmount = async () => { + if (intent.current || loading || txError || !areInputsValid) return; + await handleTransaction(); + }; + + const invalidatePendingExecution = useCallback(() => { + runIdRef.current += 1; + if (intent.current) { + intent.current.deny(); + intent.current = null; + } + setRefreshing(false); + setAppliedSourceSelectionKey("ALL"); + }, [intent, setAppliedSourceSelectionKey, setRefreshing]); + + return { + refreshIntent, + handleTransaction, + startTransaction, + commitAmount, + reset, + invalidatePendingExecution, + }; +} diff --git a/apps/megaeth/src/components/common/hooks/useTransactionFlow.ts b/apps/megaeth/src/components/common/hooks/useTransactionFlow.ts new file mode 100644 index 0000000..2cfc9e7 --- /dev/null +++ b/apps/megaeth/src/components/common/hooks/useTransactionFlow.ts @@ -0,0 +1,577 @@ +import { + type BridgeStepType, + type NexusNetwork, + NexusSDK, + type OnAllowanceHookData, + type OnIntentHookData, + parseUnits, + type UserAsset, +} from "@avail-project/nexus-core"; +import { + useEffect, + useMemo, + useCallback, + useRef, + useState, + useReducer, + type RefObject, +} from "react"; +import { type Address, isAddress } from "viem"; +import { useNexusError } from "./useNexusError"; +import { useTransactionExecution } from "./useTransactionExecution"; +import { usePolling } from "./usePolling"; +import { useStopwatch } from "./useStopwatch"; +import { useDebouncedCallback } from "./useDebouncedCallback"; +import { type TransactionStatus } from "../tx/types"; +import { useTransactionSteps } from "../tx/useTransactionSteps"; +import { + type SourceCoverageState, + type TransactionFlowExecutor, + type TransactionFlowInputs, + type TransactionFlowPrefill, + type TransactionFlowType, +} from "../types/transaction-flow"; +import { + MAX_AMOUNT_DEBOUNCE_MS, + buildInitialInputs, + clampAmountToMax, + formatAmountForDisplay, + getCoverageDecimals, + normalizeMaxAmount, +} from "../utils/transaction-flow"; + +interface BaseTransactionFlowProps { + type: TransactionFlowType; + network: NexusNetwork; + nexusSDK: NexusSDK | null; + intent: RefObject; + allowance: RefObject; + bridgableBalance: UserAsset[] | null; + prefill?: TransactionFlowPrefill; + onComplete?: (explorerUrl?: string) => void; + onStart?: () => void; + onError?: (message: string) => void; + fetchBalance: () => Promise; + maxAmount?: string | number; + isSourceMenuOpen?: boolean; + notifyHistoryRefresh?: () => void; + executeTransaction: TransactionFlowExecutor; +} + +export interface UseTransactionFlowProps extends BaseTransactionFlowProps { + connectedAddress?: Address; +} + +type State = { + inputs: TransactionFlowInputs; + status: TransactionStatus; +}; + +type Action = + | { type: "setInputs"; payload: Partial } + | { type: "resetInputs" } + | { type: "setStatus"; payload: TransactionStatus }; + +export function useTransactionFlow(props: UseTransactionFlowProps) { + const { + type, + network, + nexusSDK, + intent, + bridgableBalance, + prefill, + onComplete, + onStart, + onError, + fetchBalance, + allowance, + maxAmount, + isSourceMenuOpen = false, + notifyHistoryRefresh, + executeTransaction, + } = props; + + const connectedAddress = props.connectedAddress; + const operationName = type === "bridge" ? "bridge" : "transfer"; + const handleNexusError = useNexusError(); + const initialState: State = { + inputs: buildInitialInputs({ type, network, connectedAddress, prefill }), + status: "idle", + }; + + function reducer(state: State, action: Action): State { + switch (action.type) { + case "setInputs": + return { ...state, inputs: { ...state.inputs, ...action.payload } }; + case "resetInputs": + return { + ...state, + inputs: buildInitialInputs({ + type, + network, + connectedAddress, + prefill, + }), + }; + case "setStatus": + return { ...state, status: action.payload }; + default: + return state; + } + } + + const [state, dispatch] = useReducer(reducer, initialState); + const inputs = state.inputs; + const setInputs = ( + next: TransactionFlowInputs | Partial, + ) => { + dispatch({ + type: "setInputs", + payload: next as Partial, + }); + }; + + const loading = state.status === "executing"; + const [refreshing, setRefreshing] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [txError, setTxError] = useState(null); + const [lastExplorerUrl, setLastExplorerUrl] = useState(""); + const previousConnectedAddressRef = useRef
( + connectedAddress, + ); + const maxAmountRequestIdRef = useRef(0); + const [selectedSourceChains, setSelectedSourceChains] = useState< + number[] | null + >(null); + const [selectedSourcesMaxAmount, setSelectedSourcesMaxAmount] = useState< + string | null + >(null); + const [appliedSourceSelectionKey, setAppliedSourceSelectionKey] = + useState("ALL"); + const { + steps, + onStepsList, + onStepComplete, + reset: resetSteps, + } = useTransactionSteps(); + const configuredMaxAmount = useMemo( + () => normalizeMaxAmount(maxAmount), + [maxAmount], + ); + + const areInputsValid = useMemo(() => { + const hasToken = inputs?.token !== undefined && inputs?.token !== null; + const hasChain = inputs?.chain !== undefined && inputs?.chain !== null; + const hasAmount = Boolean(inputs?.amount) && Number(inputs?.amount) > 0; + const hasValidRecipient = + Boolean(inputs?.recipient) && isAddress(inputs.recipient as string); + return hasToken && hasChain && hasAmount && hasValidRecipient; + }, [inputs]); + + const filteredBridgableBalance = useMemo(() => { + return bridgableBalance?.find((bal) => + inputs?.token === "USDM" + ? bal?.symbol === "USDC" + : bal?.symbol === inputs?.token, + ); + }, [bridgableBalance, inputs?.token]); + + const availableSources = useMemo(() => { + const breakdown = filteredBridgableBalance?.breakdown ?? []; + const destinationChainId = inputs?.chain; + const nonZero = breakdown.filter((source) => { + if (Number.parseFloat(source.balance ?? "0") <= 0) return false; + if (typeof destinationChainId === "number") { + return source.chain.id !== destinationChainId; + } + return true; + }); + const decimals = filteredBridgableBalance?.decimals; + if (!nexusSDK || typeof decimals !== "number") { + return nonZero.sort( + (a, b) => Number.parseFloat(b.balance) - Number.parseFloat(a.balance), + ); + } + return nonZero.sort((a, b) => { + try { + const aRaw = parseUnits(a.balance ?? "0", decimals); + const bRaw = parseUnits(b.balance ?? "0", decimals); + if (aRaw === bRaw) return 0; + return aRaw > bRaw ? -1 : 1; + } catch { + return Number.parseFloat(b.balance) - Number.parseFloat(a.balance); + } + }); + }, [ + inputs?.chain, + filteredBridgableBalance?.breakdown, + filteredBridgableBalance?.decimals, + nexusSDK, + ]); + + const allAvailableSourceChainIds = useMemo( + () => availableSources.map((source) => source.chain.id), + [availableSources], + ); + + const effectiveSelectedSourceChains = useMemo(() => { + if (selectedSourceChains && selectedSourceChains.length > 0) { + const availableSet = new Set(allAvailableSourceChainIds); + const filteredSelection = selectedSourceChains.filter((id) => + availableSet.has(id), + ); + if (filteredSelection.length > 0) { + return filteredSelection; + } + } + return allAvailableSourceChainIds; + }, [selectedSourceChains, allAvailableSourceChainIds]); + + const sourceChainsForSdk = + effectiveSelectedSourceChains.length > 0 + ? effectiveSelectedSourceChains + : undefined; + + const sourceSelectionKey = useMemo(() => { + if (allAvailableSourceChainIds.length === 0) return "NONE"; + if (!selectedSourceChains || selectedSourceChains.length === 0) { + return "ALL"; + } + return [...effectiveSelectedSourceChains].sort((a, b) => a - b).join("|"); + }, [ + allAvailableSourceChainIds.length, + effectiveSelectedSourceChains, + selectedSourceChains, + ]); + const hasPendingSourceSelectionChanges = + sourceSelectionKey !== appliedSourceSelectionKey; + const intentSourceSpendAmount = intent.current?.intent?.sourcesTotal; + + const getMaxForCurrentSelection = useCallback(async () => { + if (!nexusSDK || !inputs?.token || !inputs?.chain) return undefined; + const maxBalAvailable = await nexusSDK.calculateMaxForBridge({ + token: inputs.token, + toChainId: inputs.chain, + recipient: inputs.recipient, + sourceChains: sourceChainsForSdk, + }); + if (!maxBalAvailable?.amount) return "0"; + return clampAmountToMax({ + amount: maxBalAvailable.amount, + maxAmount: configuredMaxAmount, + nexusSDK, + token: inputs.token, + chainId: inputs.chain, + }); + }, [ + configuredMaxAmount, + inputs?.chain, + inputs?.recipient, + inputs?.token, + nexusSDK, + sourceChainsForSdk, + ]); + + const toggleSourceChain = useCallback( + (chainId: number) => { + setSelectedSourceChains((prev) => { + if (allAvailableSourceChainIds.length === 0) return prev; + const current = + prev && prev.length > 0 ? prev : allAvailableSourceChainIds; + const next = current.includes(chainId) + ? current.filter((id) => id !== chainId) + : [...current, chainId]; + if (next.length === 0) { + return current; + } + const isAllSelected = + next.length === allAvailableSourceChainIds.length && + allAvailableSourceChainIds.every((id) => next.includes(id)); + return isAllSelected ? null : next; + }); + }, + [allAvailableSourceChainIds], + ); + + const sourceSelection = useMemo(() => { + const amount = + intentSourceSpendAmount?.trim() ?? inputs?.amount?.trim() ?? ""; + const decimals = getCoverageDecimals({ + type, + token: inputs?.token, + chainId: inputs?.chain, + fallback: filteredBridgableBalance?.decimals, + }); + const selectedChainSet = new Set(effectiveSelectedSourceChains); + const selectedTotalRaw = + !nexusSDK || typeof decimals !== "number" + ? BigInt(0) + : availableSources.reduce((sum, source) => { + if (!selectedChainSet.has(source.chain.id)) return sum; + try { + return sum + parseUnits(source.balance ?? "0", decimals); + } catch { + return sum; + } + }, BigInt(0)); + const selectedTotal = + !nexusSDK || typeof decimals !== "number" + ? "0" + : formatAmountForDisplay(selectedTotalRaw, decimals, nexusSDK); + const baseSelection = { + selectedTotal, + requiredTotal: amount || "0", + requiredSafetyTotal: amount || "0", + missingToProceed: "0", + missingToSafety: "0", + coverageState: "healthy" as SourceCoverageState, + coverageToSafetyPercent: 100, + isBelowRequired: false, + isBelowSafetyBuffer: false, + }; + + if (!nexusSDK || !inputs?.token || !inputs?.chain || !amount) { + return baseSelection; + } + + try { + const requiredRaw = nexusSDK.convertTokenReadableAmountToBigInt( + amount, + inputs.token, + inputs.chain, + ); + if (requiredRaw <= BigInt(0)) { + return baseSelection; + } + + const missingToProceedRaw = + selectedTotalRaw >= requiredRaw + ? BigInt(0) + : requiredRaw - selectedTotalRaw; + const missingToSafetyRaw = missingToProceedRaw; + + const coverageState: SourceCoverageState = + selectedTotalRaw < requiredRaw ? "error" : "healthy"; + + const coverageBasisPoints = + requiredRaw === BigInt(0) + ? 10_000 + : selectedTotalRaw >= requiredRaw + ? 10_000 + : Number((selectedTotalRaw * BigInt(10_000)) / requiredRaw); + + return { + selectedTotal, + requiredTotal: amount, + requiredSafetyTotal: amount, + missingToProceed: formatAmountForDisplay( + missingToProceedRaw, + decimals, + nexusSDK, + ), + missingToSafety: formatAmountForDisplay( + missingToSafetyRaw, + decimals, + nexusSDK, + ), + coverageState, + coverageToSafetyPercent: coverageBasisPoints / 100, + isBelowRequired: coverageState === "error", + isBelowSafetyBuffer: coverageState === "error", + }; + } catch { + return baseSelection; + } + }, [ + type, + filteredBridgableBalance?.decimals, + nexusSDK, + inputs?.chain, + inputs?.amount, + inputs?.token, + intentSourceSpendAmount, + availableSources, + effectiveSelectedSourceChains, + ]); + + const stopwatch = useStopwatch({ intervalMs: 100 }); + const setStatus = useCallback( + (status: TransactionStatus) => + dispatch({ type: "setStatus", payload: status }), + [], + ); + + const resetInputs = useCallback(() => { + dispatch({ type: "resetInputs" }); + }, []); + + const { + refreshIntent, + handleTransaction, + startTransaction, + commitAmount, + reset, + invalidatePendingExecution, + } = useTransactionExecution({ + operationName, + nexusSDK, + intent, + allowance, + inputs, + configuredMaxAmount, + allAvailableSourceChainIds, + sourceChainsForSdk, + sourceSelectionKey, + sourceSelection, + loading, + txError, + areInputsValid, + executeTransaction, + getMaxForCurrentSelection, + onStepsList, + onStepComplete, + resetSteps, + setStatus, + resetInputs, + setRefreshing, + setIsDialogOpen, + setTxError, + setLastExplorerUrl, + setSelectedSourceChains, + setAppliedSourceSelectionKey, + stopwatch, + handleNexusError, + onStart, + onComplete, + onError, + fetchBalance, + notifyHistoryRefresh, + }); + + usePolling( + Boolean(intent.current) && + !isDialogOpen && + !isSourceMenuOpen && + !hasPendingSourceSelectionChanges, + async () => { + await refreshIntent(); + }, + 15000, + ); + + const debouncedRefreshMaxForSelection = useDebouncedCallback( + async (requestId: number) => { + try { + const maxForCurrentSelection = await getMaxForCurrentSelection(); + if (requestId !== maxAmountRequestIdRef.current) return; + setSelectedSourcesMaxAmount(maxForCurrentSelection ?? "0"); + } catch (error) { + if (requestId !== maxAmountRequestIdRef.current) return; + console.error("Unable to calculate max for selected sources:", error); + setSelectedSourcesMaxAmount("0"); + } + }, + MAX_AMOUNT_DEBOUNCE_MS, + ); + + useEffect(() => { + debouncedRefreshMaxForSelection.cancel(); + if (!nexusSDK || !inputs?.token || !inputs?.chain) { + maxAmountRequestIdRef.current += 1; + setSelectedSourcesMaxAmount(null); + return; + } + if (allAvailableSourceChainIds.length === 0) { + maxAmountRequestIdRef.current += 1; + setSelectedSourcesMaxAmount("0"); + return; + } + const requestId = ++maxAmountRequestIdRef.current; + debouncedRefreshMaxForSelection(requestId); + }, [ + allAvailableSourceChainIds.length, + configuredMaxAmount, + debouncedRefreshMaxForSelection, + inputs?.recipient, + sourceSelectionKey, + inputs?.chain, + inputs?.token, + nexusSDK, + ]); + + useEffect(() => { + if (type !== "bridge" || !connectedAddress) return; + const previousConnectedAddress = previousConnectedAddressRef.current; + if (!previousConnectedAddress) { + previousConnectedAddressRef.current = connectedAddress; + return; + } + if (connectedAddress === previousConnectedAddress) return; + previousConnectedAddressRef.current = connectedAddress; + if (prefill?.recipient) return; + if (!inputs?.recipient || inputs.recipient === previousConnectedAddress) { + dispatch({ type: "setInputs", payload: { recipient: connectedAddress } }); + } + }, [type, connectedAddress, inputs?.recipient, prefill?.recipient]); + + useEffect(() => { + invalidatePendingExecution(); + }, [inputs, invalidatePendingExecution]); + + useEffect(() => { + setSelectedSourceChains(null); + }, [inputs?.token]); + + useEffect(() => { + if (isDialogOpen) return; + stopwatch.stop(); + stopwatch.reset(); + if (state.status === "success" || state.status === "error") { + resetSteps(); + setLastExplorerUrl(""); + setStatus("idle"); + } + }, [isDialogOpen, resetSteps, setStatus, state.status, stopwatch]); + + useEffect(() => { + if (txError) { + setTxError(null); + } + }, [inputs, txError]); + + return { + inputs, + setInputs, + timer: stopwatch.seconds, + setIsDialogOpen, + setTxError, + loading, + refreshing, + isDialogOpen, + txError, + handleTransaction, + reset, + filteredBridgableBalance, + startTransaction, + commitAmount, + lastExplorerUrl, + steps, + status: state.status, + availableSources, + selectedSourceChains: effectiveSelectedSourceChains, + toggleSourceChain, + isSourceSelectionInsufficient: sourceSelection.isBelowRequired, + isSourceSelectionBelowSafetyBuffer: sourceSelection.isBelowSafetyBuffer, + isSourceSelectionReadyForAccept: + sourceSelection.coverageState === "healthy", + sourceCoverageState: sourceSelection.coverageState, + sourceCoveragePercent: sourceSelection.coverageToSafetyPercent, + missingToProceed: sourceSelection.missingToProceed, + missingToSafety: sourceSelection.missingToSafety, + selectedTotal: sourceSelection.selectedTotal, + requiredTotal: sourceSelection.requiredTotal, + requiredSafetyTotal: sourceSelection.requiredSafetyTotal, + maxAvailableAmount: selectedSourcesMaxAmount ?? undefined, + isInputsValid: areInputsValid, + }; +} diff --git a/apps/megaeth/src/components/common/index.ts b/apps/megaeth/src/components/common/index.ts index f5a855e..46626d7 100644 --- a/apps/megaeth/src/components/common/index.ts +++ b/apps/megaeth/src/components/common/index.ts @@ -2,10 +2,15 @@ export * from "./hooks/useStopwatch"; export * from "./hooks/usePolling"; export * from "./hooks/useInterval"; export * from "./hooks/useStableCallback"; +export * from "./hooks/useLatest"; export * from "./hooks/useDebouncedValue"; export * from "./hooks/useDebouncedCallback"; export * from "./hooks/useNexusError"; +export * from "./hooks/useTransactionFlow"; +export * from "./types/transaction-flow"; export * from "./tx/types"; export * from "./tx/steps"; export * from "./tx/useTransactionSteps"; export * from "./utils/constant"; +export * from "./utils/token-pricing"; +export * from "./components/ErrorBoundary"; diff --git a/apps/megaeth/src/components/common/types/transaction-flow.ts b/apps/megaeth/src/components/common/types/transaction-flow.ts new file mode 100644 index 0000000..27e81bc --- /dev/null +++ b/apps/megaeth/src/components/common/types/transaction-flow.ts @@ -0,0 +1,53 @@ +import { + type NexusSDK, + type SUPPORTED_CHAINS_IDS, + type SUPPORTED_TOKENS, +} from "@avail-project/nexus-core"; +import { type Address } from "viem"; + +export type TransactionFlowType = "bridge" | "transfer"; + +export interface TransactionFlowInputs { + chain: SUPPORTED_CHAINS_IDS; + token: SUPPORTED_TOKENS; + amount?: string; + recipient?: `0x${string}`; +} + +export interface TransactionFlowPrefill { + token: string; + chainId: number; + amount?: string; + recipient?: Address; +} + +type BridgeOptions = NonNullable[1]>; + +export type TransactionFlowEvent = + NonNullable extends (event: infer E) => void + ? E + : never; + +export type TransactionFlowOnEvent = NonNullable; + +export interface TransactionFlowExecuteParams { + token: SUPPORTED_TOKENS; + amount: bigint; + toChainId: SUPPORTED_CHAINS_IDS; + recipient: `0x${string}`; + sourceChains?: number[]; + onEvent: TransactionFlowOnEvent; +} + +export type TransactionFlowExecutor = ( + params: TransactionFlowExecuteParams, +) => Promise<{ explorerUrl: string } | null>; + +export type SourceCoverageState = "healthy" | "warning" | "error"; + +export interface SourceSelectionValidation { + coverageState: SourceCoverageState; + isBelowRequired: boolean; + missingToProceed: string; + missingToSafety: string; +} diff --git a/apps/megaeth/src/components/common/utils/constant.ts b/apps/megaeth/src/components/common/utils/constant.ts index b0acea9..a300ae3 100644 --- a/apps/megaeth/src/components/common/utils/constant.ts +++ b/apps/megaeth/src/components/common/utils/constant.ts @@ -9,52 +9,22 @@ export const SHORT_CHAIN_NAME: Record = { [SUPPORTED_CHAINS.POLYGON]: "Polygon", [SUPPORTED_CHAINS.AVALANCHE]: "Avalanche", [SUPPORTED_CHAINS.SCROLL]: "Scroll", + [SUPPORTED_CHAINS.MEGAETH]: "MegaETH", [SUPPORTED_CHAINS.KAIA]: "Kaia", [SUPPORTED_CHAINS.BNB]: "BNB", [SUPPORTED_CHAINS.MONAD]: "Monad", [SUPPORTED_CHAINS.HYPEREVM]: "HyperEVM", - [SUPPORTED_CHAINS.MEGAETH]: "MegaETH", - 4114: "Citrea", - + [SUPPORTED_CHAINS.CITREA]: "Citrea", + // [SUPPORTED_CHAINS.TRON]: "Tron", [SUPPORTED_CHAINS.SEPOLIA]: "Sepolia", [SUPPORTED_CHAINS.BASE_SEPOLIA]: "Base Sepolia", [SUPPORTED_CHAINS.ARBITRUM_SEPOLIA]: "Arbitrum Sepolia", [SUPPORTED_CHAINS.OPTIMISM_SEPOLIA]: "Optimism Sepolia", [SUPPORTED_CHAINS.POLYGON_AMOY]: "Polygon Amoy", [SUPPORTED_CHAINS.MONAD_TESTNET]: "Monad Testnet", + // [SUPPORTED_CHAINS.TRON_SHASTA]: "Tron Shasta", } as const; -export const TOKEN_IMAGES: Record = { - USDC: "https://coin-images.coingecko.com/coins/images/6319/large/usdc.png", - USDT: "https://coin-images.coingecko.com/coins/images/35023/large/USDT.png", - "USDâ‚®0": - "https://coin-images.coingecko.com/coins/images/35023/large/USDT.png", - USDM: "https://raw.githubusercontent.com/availproject/nexus-assets/refs/heads/main/tokens/usdm/logo.png", - WETH: "https://assets.coingecko.com/coins/images/279/large/ethereum.png?1595348880", - USDS: "https://assets.coingecko.com/coins/images/39926/standard/usds.webp?1726666683", - SOPH: "https://assets.coingecko.com/coins/images/38680/large/sophon_logo_200.png", - KAIA: "https://assets.coingecko.com/asset_platforms/images/9672/large/kaia.png", - BNB: "https://assets.coingecko.com/coins/images/825/large/bnb-icon2_2x.png", - // Add ETH as fallback for any ETH-related tokens - ETH: "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628", - // Add common token fallbacks - POL: "https://coin-images.coingecko.com/coins/images/32440/standard/polygon.png", - AVAX: "https://assets.coingecko.com/coins/images/12559/standard/Avalanche_Circle_RedWhite_Trans.png", - FUEL: "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png", - HYPE: "https://assets.coingecko.com/asset_platforms/images/243/large/hyperliquid.png", - // Popular swap tokens - DAI: "https://coin-images.coingecko.com/coins/images/9956/large/Badge_Dai.png?1696509996", - UNI: "https://coin-images.coingecko.com/coins/images/12504/large/uni.jpg?1696512319", - AAVE: "https://coin-images.coingecko.com/coins/images/12645/large/AAVE.png?1696512452", - LDO: "https://coin-images.coingecko.com/coins/images/13573/large/Lido_DAO.png?1696513326", - PEPE: "https://coin-images.coingecko.com/coins/images/29850/large/pepe-token.jpeg?1696528776", - OP: "https://coin-images.coingecko.com/coins/images/25244/large/Optimism.png?1696524385", - ZRO: "https://coin-images.coingecko.com/coins/images/28206/large/ftxG9_TJ_400x400.jpeg?1696527208", - OM: "https://assets.coingecko.com/coins/images/12151/standard/OM_Token.png?1696511991", - KAITO: - "https://assets.coingecko.com/coins/images/54411/standard/Qm4DW488_400x400.jpg", -}; - const DEFAULT_SAFETY_MARGIN = 0.01; // 1% /** diff --git a/apps/megaeth/src/components/common/utils/token-pricing.ts b/apps/megaeth/src/components/common/utils/token-pricing.ts new file mode 100644 index 0000000..3b5eeaa --- /dev/null +++ b/apps/megaeth/src/components/common/utils/token-pricing.ts @@ -0,0 +1,144 @@ +import type { SupportedChainsAndTokensResult } from "@avail-project/nexus-core"; + +const COINBASE_SPOT_API_BASE = "https://api.coinbase.com/v2/prices"; +const COINBASE_EXCHANGE_RATES_API_BASE = + "https://api.coinbase.com/v2/exchange-rates"; + +export const DEFAULT_COINBASE_PRICE_REQUEST_TIMEOUT_MS = 4_000; +export const USD_PEGGED_FALLBACK_RATE = 1; +export const DEFAULT_USD_PEGGED_TOKEN_SYMBOLS = [ + "USDT", + "USDC", + "USDS", + "DAI", + "USDM", + "FDUSD", + "BUSD", + "TUSD", + "PYUSD", + "GUSD", + "LUSD", + "USDE", + "USDP", +] as const; + +type CoinbaseSpotPriceResponse = { + data?: { + amount?: string | number; + }; +}; + +type CoinbaseExchangeRatesResponse = { + data?: { + rates?: Record; + }; +}; + +type SupportedTokenMetadata = { + symbol?: string; + equivalentCurrency?: string; +}; + +type SupportedChainMetadata = { + tokens?: SupportedTokenMetadata[]; +}; + +export function normalizeTokenSymbol(tokenSymbol: string): string { + return tokenSymbol.trim().toUpperCase(); +} + +export function toFinitePositiveNumber(value: unknown): number | null { + const parsed = Number.parseFloat(String(value)); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +} + +export function getCoinbaseSymbolCandidates(tokenSymbol: string): string[] { + const normalized = normalizeTokenSymbol(tokenSymbol); + if (!normalized) return []; + + const baseSymbol = normalized.split(/[._-]/)[0] ?? normalized; + const wrappedBase = + baseSymbol.startsWith("W") && baseSymbol.length > 3 + ? baseSymbol.slice(1) + : null; + + return Array.from( + new Set( + [normalized, baseSymbol, wrappedBase].filter( + (symbol): symbol is string => Boolean(symbol), + ), + ), + ); +} + +export function buildUsdPeggedSymbolSet( + supportedChains: SupportedChainsAndTokensResult | null, + baseSymbols: Iterable = DEFAULT_USD_PEGGED_TOKEN_SYMBOLS, +): Set { + const symbolSet = new Set(baseSymbols); + + for (const chain of (supportedChains ?? []) as SupportedChainMetadata[]) { + for (const token of chain.tokens ?? []) { + const symbol = normalizeTokenSymbol(token.symbol ?? ""); + const equivalent = normalizeTokenSymbol(token.equivalentCurrency ?? ""); + if (!symbol) continue; + + if (equivalent && symbolSet.has(equivalent)) { + symbolSet.add(symbol); + } + } + } + + return symbolSet; +} + +async function fetchJsonWithTimeout( + url: string, + requestTimeoutMs: number, +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), requestTimeoutMs); + try { + const response = await fetch(url, { + signal: controller.signal, + }); + if (!response.ok) return null; + return (await response.json()) as T; + } catch { + return null; + } finally { + clearTimeout(timeoutId); + } +} + +export async function fetchCoinbaseUsdRate( + tokenSymbol: string, + requestTimeoutMs = DEFAULT_COINBASE_PRICE_REQUEST_TIMEOUT_MS, +): Promise { + const normalized = normalizeTokenSymbol(tokenSymbol); + if (!normalized) return null; + + for (const candidate of getCoinbaseSymbolCandidates(normalized)) { + const spotBody = await fetchJsonWithTimeout( + `${COINBASE_SPOT_API_BASE}/${encodeURIComponent(candidate)}-USD/spot`, + requestTimeoutMs, + ); + const spotAmount = toFinitePositiveNumber(spotBody?.data?.amount); + if (spotAmount) return spotAmount; + + const exchangeRatesBody = + await fetchJsonWithTimeout( + `${COINBASE_EXCHANGE_RATES_API_BASE}?currency=${encodeURIComponent(candidate)}`, + requestTimeoutMs, + ); + const exchangeRatesAmount = toFinitePositiveNumber( + exchangeRatesBody?.data?.rates?.USD, + ); + if (exchangeRatesAmount) return exchangeRatesAmount; + } + + return null; +} diff --git a/apps/megaeth/src/components/common/utils/transaction-flow.ts b/apps/megaeth/src/components/common/utils/transaction-flow.ts new file mode 100644 index 0000000..11ada46 --- /dev/null +++ b/apps/megaeth/src/components/common/utils/transaction-flow.ts @@ -0,0 +1,125 @@ +import { + formatUnits, + type NexusNetwork, + NexusSDK, + SUPPORTED_CHAINS, + type SUPPORTED_CHAINS_IDS, + type SUPPORTED_TOKENS, +} from "@avail-project/nexus-core"; +import { type Address } from "viem"; + +const MAX_AMOUNT_REGEX = /^\d*\.?\d+$/; + +export const MAX_AMOUNT_DEBOUNCE_MS = 300; + +export const normalizeMaxAmount = ( + maxAmount?: string | number, +): string | undefined => { + if (maxAmount === undefined || maxAmount === null) return undefined; + const value = String(maxAmount).trim(); + if (!value || value === "." || !MAX_AMOUNT_REGEX.test(value)) { + return undefined; + } + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed) || parsed <= 0) return undefined; + return value; +}; + +export const clampAmountToMax = ({ + amount, + maxAmount, + nexusSDK, + token, + chainId, +}: { + amount: string; + maxAmount?: string; + nexusSDK: NexusSDK; + token: SUPPORTED_TOKENS; + chainId: SUPPORTED_CHAINS_IDS; +}): string => { + if (!maxAmount) return amount; + try { + const amountRaw = nexusSDK.convertTokenReadableAmountToBigInt( + amount, + token, + chainId, + ); + const maxRaw = nexusSDK.convertTokenReadableAmountToBigInt( + maxAmount, + token, + chainId, + ); + return amountRaw > maxRaw ? maxAmount : amount; + } catch { + return amount; + } +}; + +export const formatAmountForDisplay = ( + amount: bigint, + decimals: number | undefined, + nexusSDK: NexusSDK, +): string => { + if (typeof decimals !== "number") return amount.toString(); + const formatted = formatUnits(amount, decimals); + if (!formatted.includes(".")) return formatted; + const [whole, fraction] = formatted.split("."); + const trimmedFraction = fraction.slice(0, 6).replace(/0+$/, ""); + if (!trimmedFraction && whole === "0" && amount > BigInt(0)) { + return "0.000001"; + } + return trimmedFraction ? `${whole}.${trimmedFraction}` : whole; +}; + +export const buildInitialInputs = ({ + type, + network, + connectedAddress, + prefill, +}: { + type: "bridge" | "transfer"; + network: NexusNetwork; + connectedAddress?: Address; + prefill?: { + token: string; + chainId: number; + amount?: string; + recipient?: Address; + }; +}) => { + return { + chain: + (prefill?.chainId as SUPPORTED_CHAINS_IDS) ?? + (network === "testnet" + ? SUPPORTED_CHAINS.SEPOLIA + : SUPPORTED_CHAINS.ETHEREUM), + token: (prefill?.token as SUPPORTED_TOKENS) ?? "USDC", + amount: prefill?.amount ?? undefined, + recipient: + (prefill?.recipient as `0x${string}`) ?? + (type === "bridge" ? connectedAddress : undefined), + }; +}; + +export const getCoverageDecimals = ({ + type, + token, + chainId, + fallback, +}: { + type: "bridge" | "transfer"; + token?: SUPPORTED_TOKENS; + chainId?: SUPPORTED_CHAINS_IDS; + fallback: number | undefined; +}) => { + if (token === "USDM") return 18; + if ( + type === "bridge" && + token === "USDC" && + chainId === SUPPORTED_CHAINS.BNB + ) { + return 18; + } + return fallback; +}; diff --git a/apps/megaeth/src/components/deposit/components/amount-card.tsx b/apps/megaeth/src/components/deposit/components/amount-card.tsx new file mode 100644 index 0000000..ccf5747 --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/amount-card.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { useCallback, useRef, useEffect, useState, useMemo } from "react"; +import { TokenIcon } from "./token-icon"; +import { ErrorBanner } from "./error-banner"; +import { PercentageSelector } from "./percentage-selector"; +import { parseCurrencyInput } from "../utils"; +import { UpDownArrows } from "./icons"; +import { usdFormatter } from "../../common"; +import { type DestinationConfig } from "../types"; +import { + BALANCE_SAFETY_MARGIN, + CHARACTER_ANIMATION_DURATION_MS, + SHINE_ANIMATION_DURATION_MS, + MAX_INPUT_WIDTH_PX, +} from "../constants/widget"; +import { TOKEN_IMAGES } from "../constants/assets"; + +// Hoisted RegExp to avoid recreation on every render (js-hoist-regexp) +const NUMERIC_INPUT_REGEX = /^\d*\.?\d*$/; + +interface AmountCardProps { + amount?: string; + onAmountChange?: (amount: string) => void; + selectedTokenAmount?: number; + onErrorStateChange?: (hasError: boolean) => void; + totalSelectedBalance: number; + totalBalance: { + balance: number; + usdBalance: number; + }; + destinationConfig: DestinationConfig; +} + +function AmountCard({ + amount: externalAmount, + onAmountChange, + selectedTokenAmount = 0, + onErrorStateChange, + totalSelectedBalance, + totalBalance, + destinationConfig, +}: AmountCardProps) { + const [internalAmount, setInternalAmount] = useState(""); + const amount = externalAmount ?? internalAmount; + const setAmount = onAmountChange ?? setInternalAmount; + + const [inputWidth, setInputWidth] = useState(0); + const [isShining, setIsShining] = useState(false); + const [animatingIndices, setAnimatingIndices] = useState>( + new Set(), + ); + const prevAmountRef = useRef(""); + const prevLengthRef = useRef(0); + const measureRef = useRef(null); + const inputRef = useRef(null); + + const displayValue = amount || ""; + const measureText = displayValue || "0"; + + // Split display value into characters for animation + const displayChars = displayValue.split(""); + + // Track which characters should animate (newly added ones) + useEffect(() => { + const currentLength = displayValue.length; + const prevLength = prevLengthRef.current; + + // Only animate when characters are added (not removed) + if (currentLength > prevLength) { + const newIndices = new Set(); + for (let i = prevLength; i < currentLength; i++) { + newIndices.add(i); + } + setAnimatingIndices(newIndices); + + // Clear animation after it completes + const timer = setTimeout(() => { + setAnimatingIndices(new Set()); + }, CHARACTER_ANIMATION_DURATION_MS); + + prevLengthRef.current = currentLength; + return () => clearTimeout(timer); + } + + prevLengthRef.current = currentLength; + }, [displayValue]); + + // Calculate numeric amount for USD equivalent + const numericAmount = useMemo(() => { + if (!amount) return 0; + const parsed = parseFloat(amount.replace(/,/g, "")); + return isNaN(parsed) ? 0 : parsed; + }, [amount]); + + // Check if amount exceeds wallet balance + const exceedsBalance = useMemo(() => { + if (!amount) return false; + const numericAmount = parseFloat(amount.replace(/,/g, "")); + return !isNaN(numericAmount) && numericAmount > totalBalance?.usdBalance; + }, [amount, totalBalance?.usdBalance]); + + // Check if amount exceeds selected token amount but is within wallet balance + const exceedsSelectedTokens = useMemo(() => { + if (!amount || selectedTokenAmount === 0) return false; + const numericAmount = parseFloat(amount.replace(/,/g, "")); + return ( + !isNaN(numericAmount) && + numericAmount > selectedTokenAmount && + numericAmount <= totalBalance?.usdBalance + ); + }, [amount, selectedTokenAmount, totalBalance?.usdBalance]); + + useEffect(() => { + if (measureRef.current) { + setInputWidth(measureRef.current.offsetWidth); + } + }, [measureText]); + + // Trigger shine effect when USD amount changes + useEffect(() => { + if (amount && amount !== prevAmountRef.current && numericAmount > 0) { + setIsShining(true); + const timer = setTimeout(() => { + setIsShining(false); + }, SHINE_ANIMATION_DURATION_MS); + prevAmountRef.current = amount; + return () => clearTimeout(timer); + } + prevAmountRef.current = amount; + }, [amount, numericAmount]); + + // Notify parent of error state changes + useEffect(() => { + const hasError = exceedsBalance || exceedsSelectedTokens; + onErrorStateChange?.(hasError); + }, [exceedsBalance, exceedsSelectedTokens, onErrorStateChange]); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const rawValue = parseCurrencyInput(e.target.value); + + // Validate numeric input (allow unlimited decimals) + if (rawValue === "" || NUMERIC_INPUT_REGEX.test(rawValue)) { + setAmount(rawValue); + } + }, + [setAmount], + ); + + const handlePercentageClick = useCallback( + (percentage: number) => { + const safeBalance = totalBalance?.usdBalance * BALANCE_SAFETY_MARGIN; + const calculatedAmount = safeBalance * percentage; + const newAmount = usdFormatter.format(calculatedAmount).replace("$", ""); + setAmount(newAmount); + }, + [setAmount, totalBalance?.usdBalance], + ); + + const handleDoubleClick = useCallback(() => { + // Select all text on double click + if (inputRef.current) { + inputRef.current.select(); + } + }, []); + + // Handle keyboard shortcuts like Ctrl+A + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.ctrlKey && e.key === "a") { + e.preventDefault(); + if (inputRef.current) { + inputRef.current.select(); + } + } + }, + [], + ); + + return ( +
+ {/* Hidden span to measure text width */} + + + {/* Amount Input Section */} +
0 ? "-mt-0.5" : "mt-1.5" + }`} + > + +
+ {/* Animated digits layer (behind input) */} + + + {/* Real input overlaid with transparent text (for cursor positioning) */} + 0 + ? Math.min(inputWidth + 4, MAX_INPUT_WIDTH_PX) + : undefined, + maxWidth: "calc(100vw - 100px)", + }} + className="absolute inset-0 font-display text-[32px] font-medium tracking-[0.8px] tabular-nums bg-transparent border-none outline-none min-w-[22px] text-transparent caret-card-foreground placeholder:text-transparent" + /> +
+
+ + {/* USD Equivalent - animated height reveal */} +
0 + ? "grid-rows-[1fr] opacity-100" + : "grid-rows-[0fr] opacity-0 mt-2" + }`} + > +
+
+ + ~ {usdFormatter.format(numericAmount)} + + +
+
+
+ + {/* Percentage Selector */} +
0 ? "-mt-px" : ""}> + +
+ + {/* Balance Display */} +
+ Balance: {usdFormatter.format(totalSelectedBalance)} +
+ + {/* Error Banner */} + {exceedsBalance && ( +
+ +
+ )} + {exceedsSelectedTokens && ( +
+ +
+ )} +
+ ); +} + +export default AmountCard; diff --git a/apps/megaeth/src/components/deposit/components/amount-container.tsx b/apps/megaeth/src/components/deposit/components/amount-container.tsx new file mode 100644 index 0000000..dfb5ead --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/amount-container.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import WidgetHeader from "./widget-header"; +import type { DepositWidgetContextValue } from "../types"; +import AmountCard from "./amount-card"; +import PayUsing from "./pay-using"; +import { ErrorBanner } from "./error-banner"; +import { EmptyBalanceState } from "./empty-balance-state"; +import { Button } from "../../ui/button"; +import { CardContent } from "../../ui/card"; +import { Skeleton } from "../../ui/skeleton"; + +interface AmountContainerProps { + widget: DepositWidgetContextValue; + heading?: string; + onClose?: () => void; +} + +const AmountContainer = ({ + widget, + heading, + onClose, +}: AmountContainerProps) => { + const [hasAmountError, setHasAmountError] = useState(false); + const isSwapBalanceLoaded = widget.swapBalance !== null; + const hasAnySwapAsset = (widget.swapBalance?.length ?? 0) > 0; + const hasPositiveSwapBalance = useMemo( + () => + (widget.swapBalance ?? []).some((asset) => + (asset.breakdown ?? []).some((chain) => { + const amount = Number.parseFloat(chain.balance ?? "0"); + return Number.isFinite(amount) && amount > 0; + }), + ), + [widget.swapBalance], + ); + const shouldShowEmptyState = isSwapBalanceLoaded && !hasPositiveSwapBalance; + const selectedTokenAmount = useMemo( + () => widget.totalSelectedBalance, + [widget.totalSelectedBalance], + ); + + const handleAmountChange = useCallback( + (amount: string) => { + widget.setInputs({ amount }); + }, + [widget], + ); + + const handleErrorStateChange = useCallback((hasError: boolean) => { + setHasAmountError(hasError); + }, []); + + return ( + <> + + +
+ {!isSwapBalanceLoaded ? ( + + ) : shouldShowEmptyState ? ( + { + void widget.reset(); + }} + /> + ) : ( + + )} + + {widget.txError && widget.status === "error" && ( + + )} + {!shouldShowEmptyState && ( +
+ widget.goToStep("asset-selection")} + selectedChainIds={widget.assetSelection.selectedChainIds} + amount={widget.inputs.amount} + swapBalance={widget.swapBalance} + /> + +
+ )} +
+
+ + ); +}; + +export default AmountContainer; diff --git a/apps/megaeth/src/components/deposit/components/amount-display.tsx b/apps/megaeth/src/components/deposit/components/amount-display.tsx new file mode 100644 index 0000000..0fdc107 --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/amount-display.tsx @@ -0,0 +1,58 @@ +interface AmountDisplayProps { + amount: string; + suffix: string; + label: string; + align?: "left" | "center" | "right"; + size?: "default" | "compact"; +} + +export function AmountDisplay({ + amount, + suffix, + label, + align = "center", + size = "default", +}: AmountDisplayProps) { + const alignmentClasses = { + left: "items-start text-left", + center: "items-center text-center", + right: "items-end text-right", + }; + + const amountSizeClasses = { + default: "text-[23px] tracking-[0.52px]", + compact: "text-xl tracking-[0.4px]", + }; + + const suffixSizeClasses = { + default: "text-sm", + compact: "text-xs", + }; + + const labelSizeClasses = { + default: "text-sm", + compact: "text-xs", + }; + + return ( +
+
+ + {amount} + + + {suffix} + +
+
+ {label} +
+
+ ); +} diff --git a/apps/megaeth/src/components/deposit/components/animated-amount.tsx b/apps/megaeth/src/components/deposit/components/animated-amount.tsx new file mode 100644 index 0000000..d130863 --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/animated-amount.tsx @@ -0,0 +1,145 @@ +"use client"; + +type AnimationDirection = "up" | "down" | "none"; + +/** + * Determine animation direction for a single character + */ +function getCharDirection( + prevChar: string | undefined, + currChar: string +): AnimationDirection { + // Non-digit characters (like $, comma, period) don't animate + if (!/\d/.test(currChar)) return "none"; + + // New digit that didn't exist before - animate up + if (prevChar === undefined) return "up"; + + // Previous wasn't a digit - animate based on new value + if (!/\d/.test(prevChar)) return "up"; + + const prev = parseInt(prevChar, 10); + const curr = parseInt(currChar, 10); + + if (curr > prev) return "up"; + if (curr < prev) return "down"; + return "none"; +} + +/** + * Align strings from decimal point for proper digit comparison + */ +function alignStringsForComparison( + prev: string, + curr: string +): { prevAligned: string[]; currAligned: string[] } { + // Extract parts before and after decimal + const [prevInt, prevDec = ""] = prev.replace(/[^0-9.]/g, "").split("."); + const [currInt, currDec = ""] = curr.replace(/[^0-9.]/g, "").split("."); + + // Pad integer parts from the left + const maxIntLen = Math.max(prevInt.length, currInt.length); + const prevIntPadded = prevInt.padStart(maxIntLen, " "); + const currIntPadded = currInt.padStart(maxIntLen, " "); + + // Pad decimal parts from the right + const maxDecLen = Math.max(prevDec.length, currDec.length); + const prevDecPadded = prevDec.padEnd(maxDecLen, " "); + const currDecPadded = currDec.padEnd(maxDecLen, " "); + + // Reconstruct with formatting characters from current value + const currChars = curr.split(""); + const prevAligned: string[] = []; + + let intIdx = 0; + let decIdx = 0; + let inDecimal = false; + + for (const char of currChars) { + if (char === ".") { + inDecimal = true; + prevAligned.push("."); + } else if (/\d/.test(char)) { + if (inDecimal) { + prevAligned.push(prevDecPadded[decIdx] || " "); + decIdx++; + } else { + prevAligned.push(prevIntPadded[intIdx] || " "); + intIdx++; + } + } else { + // Non-digit, non-decimal (like $ or ,) + prevAligned.push(char); + } + } + + return { prevAligned, currAligned: currChars }; +} + +interface AnimatedDigitProps { + char: string; + direction: AnimationDirection; + delay: number; +} + +function AnimatedDigit({ char, direction, delay }: AnimatedDigitProps) { + const animationClass = + direction === "up" + ? "animate-digit-up" + : direction === "down" + ? "animate-digit-down" + : ""; + + return ( + + {char} + + ); +} + +export interface AnimatedAmountProps { + value: string; + previousValue: string; + className?: string; +} + +export function AnimatedAmount({ + value, + previousValue, + className, +}: AnimatedAmountProps) { + const { prevAligned, currAligned } = alignStringsForComparison( + previousValue, + value + ); + + let animatingIndex = 0; + + return ( + + {currAligned.map((char, index) => { + const prevChar = prevAligned[index]; + const direction = getCharDirection(prevChar, char); + const delay = direction !== "none" ? animatingIndex * 20 : 0; + if (direction !== "none") animatingIndex++; + + return ( + + ); + })} + + ); +} + diff --git a/apps/megaeth/src/components/deposit/components/asset-selection-container.tsx b/apps/megaeth/src/components/deposit/components/asset-selection-container.tsx new file mode 100644 index 0000000..f05f413 --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/asset-selection-container.tsx @@ -0,0 +1,503 @@ +"use client"; + +import { + useMemo, + useCallback, + useState, + useEffect, + useRef, + startTransition, + useDeferredValue, +} from "react"; +import { ChevronDownIcon } from "./icons"; +import WidgetHeader from "./widget-header"; +import type { + DepositWidgetContextValue, + Token, + TokenCategory, + ChainItem, +} from "../types"; +import { Tabs, TabsList, TabsTrigger } from "../../ui/tabs"; +import { CardContent } from "../../ui/card"; +import { Button } from "../../ui/button"; +import TokenRow from "./token-row"; +import { + CHAIN_METADATA, + formatTokenBalance, + type UserAsset, +} from "@avail-project/nexus-core"; +import { usdFormatter } from "../../common"; +import { + isStablecoin, + checkIfMatchesPreset, + isNative, +} from "../utils/asset-helpers"; +import { X } from "lucide-react"; +import { + SCROLL_THRESHOLD_PX, + PROGRESS_BAR_ANIMATION_DELAY_MS, + PROGRESS_BAR_EXIT_DURATION_MS, +} from "../constants/widget"; + +interface AssetSelectionContainerProps { + widget: DepositWidgetContextValue; + heading?: string; + onClose?: () => void; +} + +function transformSwapBalanceToTokens( + swapBalance: UserAsset[] | null, +): Token[] { + if (!swapBalance) return []; + return swapBalance + .filter((asset) => asset.breakdown && asset.breakdown.length > 0) + .map((asset) => { + const chains: ChainItem[] = (asset.breakdown || []) + .filter((b) => b.chain && b.balance) + .map((b) => { + const balanceNum = parseFloat(b.balance); + return { + id: `${b.contractAddress}-${b.chain.id}`, + tokenAddress: b.contractAddress as `0x${string}`, + chainId: b.chain.id, + name: b.chain.name, + usdValue: b.balanceInFiat, + amount: balanceNum, + }; + }) + .sort((a, b) => { + const aVal = a.usdValue; + const bVal = b.usdValue; + return bVal - aVal; + }); + + const totalUsdValue = chains.reduce((sum, c) => sum + c.usdValue, 0); + + const totalAmount = chains.reduce((sum, c) => sum + c.amount, 0); + + let category: TokenCategory; + if (isStablecoin(asset.symbol)) { + category = "stablecoin"; + } else if (isNative(asset.symbol)) { + category = "native"; + } else { + category = "memecoin"; + } + + return { + id: asset.symbol, + symbol: asset.symbol, + chainsLabel: + chains.length > 1 + ? `${chains.length} Chain${chains.length !== 1 ? "s" : ""}` + : chains[0].name, + usdValue: usdFormatter.format(totalUsdValue), + amount: formatTokenBalance(totalAmount, { + decimals: asset.decimals, + symbol: asset.symbol, + }), + decimals: asset.decimals, + logo: asset.icon || "", + category, + chains, + }; + }); +} + +const AssetSelectionContainer = ({ + widget, + heading, + onClose, +}: AssetSelectionContainerProps) => { + const { assetSelection, setAssetSelection, swapBalance } = widget; + + const [isProgressBarVisible, setIsProgressBarVisible] = useState(false); + const [isProgressBarEntering, setIsProgressBarEntering] = useState(false); + const [isProgressBarExiting, setIsProgressBarExiting] = useState(false); + const [showStickyPopular, setShowStickyPopular] = useState(false); + const scrollContainerRef = useRef(null); + const popularSectionRef = useRef(null); + + const selectedChainIds = assetSelection.selectedChainIds; + const filter = assetSelection.filter; + const expandedTokens = assetSelection.expandedTokens; + + // Defer expensive token transformation to avoid blocking UI + const deferredSwapBalance = useDeferredValue(swapBalance); + + const tokens = useMemo( + () => transformSwapBalanceToTokens(deferredSwapBalance), + [deferredSwapBalance], + ); + + // Build index Map for O(1) token lookups (js-index-maps) + const tokensById = useMemo( + () => new Map(tokens.map((t) => [t.id, t])), + [tokens], + ); + + const mainTokens = useMemo( + () => + tokens.filter( + (t) => t.category === "stablecoin" || t.category === "native", + ), + [tokens], + ); + + const otherTokens = useMemo( + () => tokens.filter((t) => t.category === "memecoin"), + [tokens], + ); + + const selectedAmount = useMemo(() => { + let total = 0; + tokens.forEach((token) => { + token.chains.forEach((chain) => { + if (selectedChainIds.has(chain.id)) { + total += chain.usdValue; + } + }); + }); + return total; + }, [tokens, selectedChainIds]); + + const requiredAmount = widget.inputs.amount + ? parseFloat(widget.inputs.amount.replace(/,/g, "")) + : 0; + + const showProgressBar = requiredAmount > 0 && requiredAmount > selectedAmount; + const progressPercent = + requiredAmount > 0 + ? Math.min((selectedAmount / requiredAmount) * 100, 100) + : 0; + + useEffect(() => { + if (showProgressBar) { + setIsProgressBarVisible(true); + setIsProgressBarExiting(false); + setIsProgressBarEntering(true); + const timer = setTimeout(() => { + setIsProgressBarEntering(false); + }, PROGRESS_BAR_ANIMATION_DELAY_MS); + return () => clearTimeout(timer); + } else if (isProgressBarVisible) { + setIsProgressBarExiting(true); + const timer = setTimeout(() => { + setIsProgressBarVisible(false); + setIsProgressBarExiting(false); + }, PROGRESS_BAR_EXIT_DURATION_MS); + return () => clearTimeout(timer); + } + }, [showProgressBar, isProgressBarVisible]); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + // Use startTransition for non-urgent scroll updates (rerender-transitions) + const handleScroll = () => { + const scrollTop = container.scrollTop; + startTransition(() => { + setShowStickyPopular(scrollTop > SCROLL_THRESHOLD_PX); + }); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + return () => container.removeEventListener("scroll", handleScroll); + }, []); + + const scrollToPopular = useCallback(() => { + scrollContainerRef.current?.scrollTo({ + top: 0, + behavior: "smooth", + }); + }, []); + + const handlePresetClick = useCallback( + (preset: "all" | "stablecoins" | "native") => { + const newChainIds = new Set(); + tokens.forEach((token) => { + const shouldInclude = + preset === "all" || + (preset === "stablecoins" && token.category === "stablecoin") || + (preset === "native" && token.category === "native"); + + if (shouldInclude) { + token.chains.forEach((chain) => newChainIds.add(chain.id)); + } + }); + setAssetSelection({ + selectedChainIds: newChainIds, + filter: preset, + }); + }, + [tokens, setAssetSelection], + ); + + const toggleTokenSelection = useCallback( + (tokenId: string) => { + const token = tokensById.get(tokenId); // O(1) lookup instead of O(n) + if (!token) return; + + const allChainsSelected = token.chains.every((c) => + selectedChainIds.has(c.id), + ); + const newChainIds = new Set(selectedChainIds); + + if (allChainsSelected) { + token.chains.forEach((chain) => newChainIds.delete(chain.id)); + } else { + token.chains.forEach((chain) => newChainIds.add(chain.id)); + } + + const newFilter = checkIfMatchesPreset(tokens, newChainIds); + setAssetSelection({ + selectedChainIds: newChainIds, + filter: newFilter, + }); + }, + [tokens, tokensById, selectedChainIds, setAssetSelection], + ); + + const toggleChainSelection = useCallback( + (chainId: string) => { + const newChainIds = new Set(selectedChainIds); + if (newChainIds.has(chainId)) { + newChainIds.delete(chainId); + } else { + newChainIds.add(chainId); + } + + const newFilter = checkIfMatchesPreset(tokens, newChainIds); + setAssetSelection({ + selectedChainIds: newChainIds, + filter: newFilter, + }); + }, + [tokens, selectedChainIds, setAssetSelection], + ); + + const toggleExpanded = useCallback( + (tokenId: string) => { + let newExpanded = new Set(expandedTokens); + if (tokenId === "others-section") { + if (newExpanded.has("others-section")) { + newExpanded.delete("others-section"); + } else { + newExpanded = new Set(newExpanded); + newExpanded.add("others-section"); + setTimeout(() => { + if (scrollContainerRef.current) { + const currentScrollTop = scrollContainerRef.current.scrollTop; + scrollContainerRef.current.scrollTo({ + top: currentScrollTop + 70, + behavior: "smooth", + }); + } + }, 100); + } + } else { + const othersExpanded = newExpanded.has("others-section"); + if (newExpanded.has(tokenId)) { + newExpanded = othersExpanded + ? new Set(["others-section"]) + : new Set(); + } else { + newExpanded = othersExpanded + ? new Set(["others-section", tokenId]) + : new Set([tokenId]); + } + } + setAssetSelection({ expandedTokens: newExpanded }); + }, + [expandedTokens, setAssetSelection], + ); + + const handleDeselectAll = useCallback(() => { + setAssetSelection({ + selectedChainIds: new Set(), + filter: "custom", + }); + }, [setAssetSelection]); + + const handleDone = useCallback(() => { + widget.goToStep("amount"); + }, [widget]); + + return ( + <> + + +
+
+ { + if (value !== "custom") { + handlePresetClick(value as "all" | "stablecoins" | "native"); + } + }} + > + + Any token + Stablecoins + Native + {filter === "custom" && ( + Custom + )} + + + +
+ +
+
+ {showStickyPopular && mainTokens.length > 0 && ( + + )} +
+ {mainTokens.length > 0 && ( +
+
+ + Popular + +
+ {mainTokens.map((token, index) => ( + toggleExpanded(token.id)} + onToggleToken={() => toggleTokenSelection(token.id)} + onToggleChain={toggleChainSelection} + isFirst={false} + isLast={index === mainTokens.length - 1} + /> + ))} +
+ )} + + {otherTokens.length > 0 && ( +
+
toggleExpanded("others-section")} + > + + Others ({otherTokens.length}) + + +
+ + {expandedTokens.has("others-section") && ( +
+ {otherTokens.map((token, index) => ( + toggleExpanded(token.id)} + onToggleToken={() => toggleTokenSelection(token.id)} + onToggleChain={toggleChainSelection} + isFirst={index === 0} + isLast={index === otherTokens.length - 1} + /> + ))} +
+ )} +
+ )} +
+ + {!showProgressBar && ( +
+ )} + {!showProgressBar && ( +
+ )} +
+ + +
+
+ + + {isProgressBarVisible && ( +
+
+ + Selected / Required + + + + ${selectedAmount.toLocaleString()} + + + {" "} + / ${requiredAmount.toLocaleString()} + + +
+
+
+
+
+ )} + + ); +}; + +export default AssetSelectionContainer; diff --git a/apps/megaeth/src/components/deposit/components/button-card.tsx b/apps/megaeth/src/components/deposit/components/button-card.tsx new file mode 100644 index 0000000..0fe6dcc --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/button-card.tsx @@ -0,0 +1,70 @@ +import { cn } from "@/lib/utils"; + +interface ButtonCardProps { + title: React.ReactNode; + subtitle?: React.ReactNode; + icon: React.ReactNode; + rightIcon?: React.ReactNode; + rightIconClassName?: string; + onClick?: () => void; + disabled?: boolean; + roundedBottom?: boolean; +} + +function ButtonCard({ + title, + subtitle, + icon, + rightIcon, + rightIconClassName, + onClick, + disabled = false, + roundedBottom = true, +}: ButtonCardProps) { + return ( +
+
+ {/* Icon */} +
{icon}
+ + {/* Text Content */} +
+ {typeof title === "string" ? ( + + {title} + + ) : ( + title + )} + {subtitle && + (typeof subtitle === "string" ? ( + + {subtitle} + + ) : ( + subtitle + ))} +
+
+ + {rightIcon && ( +
+ {rightIcon} +
+ )} +
+ ); +} + +export default ButtonCard; diff --git a/apps/megaeth/src/components/deposit/components/confirmation-container.tsx b/apps/megaeth/src/components/deposit/components/confirmation-container.tsx new file mode 100644 index 0000000..49f4eba --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/confirmation-container.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState, useMemo } from "react"; +import Image from "next/image"; +import SummaryCard from "./summary-card"; +import { GasPumpIcon, CoinIcon } from "./icons"; +import WidgetHeader from "./widget-header"; +import { ReceiveAmountDisplay } from "./receive-amount-display"; +import { ErrorBanner } from "./error-banner"; +import type { DepositWidgetContextValue } from "../types"; +import { Button } from "../../ui/button"; +import { CardContent } from "../../ui/card"; +import { usdFormatter } from "../../common"; +import { formatTokenBalance } from "@avail-project/nexus-core"; +import { useNexus } from "../../nexus/NexusProvider"; + +interface ConfirmationContainerProps { + widget: DepositWidgetContextValue; + heading?: string; + onClose?: () => void; +} + +const ConfirmationContainer = ({ + widget, + heading, + onClose, +}: ConfirmationContainerProps) => { + const [showSpendDetails, setShowSpendDetails] = useState(false); + const [showFeeDetails, setShowFeeDetails] = useState(false); + const { getFiatValue } = useNexus(); + + const { + confirmationDetails, + feeBreakdown, + handleConfirmOrder, + isProcessing, + txError, + activeIntent, + simulationLoading, + } = widget; + + const isLoading = simulationLoading || !activeIntent; + + const receiveAmount = + confirmationDetails?.receiveAmountAfterSwapUsd?.toFixed(2) ?? "0"; + const timeLabel = confirmationDetails?.estimatedTime ?? "~30s"; + // This is in USD + const amountSpent = confirmationDetails?.amountSpent; + // TODO: Ensure unique names are displayed + const tokenNames = confirmationDetails?.sources + .filter((s) => s) + .map((s) => s?.symbol) + .slice(0, 2) + .join(", "); + const moreCount = + (confirmationDetails?.sources.filter((s) => s).length ?? 0) - 2; + const tokenNamesSummary = + moreCount > 0 ? `${tokenNames} + ${moreCount} more` : tokenNames; + + // Combined filter + map into single iteration (js-combine-iterations) + const sourceDetails = useMemo(() => { + if (!confirmationDetails?.sources) return []; + const result: Array<{ + chainName: string; + chainLogo: string | undefined; + tokenSymbol: string; + tokenDecimals: number; + amount: string; + isDestinationBalance: boolean; + }> = []; + for (const source of confirmationDetails.sources) { + if (!source) continue; + result.push({ + chainName: source.chainName ?? "", + chainLogo: source.chainLogo, + tokenSymbol: source.symbol ?? "", + tokenDecimals: source.decimals ?? 6, + amount: source.balance ?? "0", + isDestinationBalance: source.isDestinationBalance ?? false, + }); + } + return result; + }, [confirmationDetails]); + + return ( + <> + + +
+
+ +
+ } + title="You spend" + subtitle={ + isLoading + ? "Calculating..." + : tokenNamesSummary || "Selected assets" + } + value={String(amountSpent)} + valueSuffix="USD" + showBreakdown={!isLoading && sourceDetails.length > 0} + loading={isLoading} + expanded={showSpendDetails} + onToggleExpand={() => setShowSpendDetails(!showSpendDetails)} + > +
+ {sourceDetails.map((source, index) => { + const amountUsd = getFiatValue( + parseFloat(source.amount), + source.tokenSymbol, + ); + return ( +
+
+ {source.chainLogo && ( + {source.chainName} + )} +
+ + {source.tokenSymbol} + + + {source.chainName} + +
+
+
+ + {usdFormatter.format(amountUsd)} USD + + + {formatTokenBalance(parseFloat(source.amount), { + decimals: source.tokenDecimals, + symbol: source.tokenSymbol, + })} + +
+
+ ); + })} +
+
+ + } + title="Total fees" + value={(confirmationDetails?.totalFeeUsd ?? 0).toFixed(2)} + valueSuffix="USD" + showBreakdown={false} + loading={isLoading} + expanded={false} + /> +
+
+ {txError && widget.status === "error" && ( + + )} + +
+
+ + ); +}; + +export default ConfirmationContainer; diff --git a/apps/megaeth/src/components/deposit/components/confirmation-loading.tsx b/apps/megaeth/src/components/deposit/components/confirmation-loading.tsx new file mode 100644 index 0000000..40f76b3 --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/confirmation-loading.tsx @@ -0,0 +1,40 @@ +"use client"; + +import WidgetHeader from "./widget-header"; +import { CardContent } from "../../ui/card"; +import { Skeleton } from "../../ui/skeleton"; + +interface ConfirmationLoadingProps { + onClose?: () => void; +} + +const ConfirmationLoading = ({ onClose }: ConfirmationLoadingProps) => { + return ( + <> + + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + ); +}; + +export default ConfirmationLoading; diff --git a/apps/megaeth/src/components/deposit/components/empty-balance-state.tsx b/apps/megaeth/src/components/deposit/components/empty-balance-state.tsx new file mode 100644 index 0000000..f60f463 --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/empty-balance-state.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Button } from "../../ui/button"; +import { InfoIcon } from "./icons"; + +type EmptyBalanceStateMode = "no-swap-assets" | "zero-balance"; + +interface EmptyBalanceStateProps { + mode: EmptyBalanceStateMode; + onRefresh?: () => void; +} + +const CONTENT: Record< + EmptyBalanceStateMode, + { title: string; description: string; hint: string } +> = { + "no-swap-assets": { + title: "No Supported Assets Found", + description: + "Your wallet doesn’t hold any assets supported for this deposit. Certain assets on chains such as Monad or MegaETH may be temporarily unavailable for use.", + hint: "Add a supported asset, then refresh balances to continue.", + }, + "zero-balance": { + title: "No available balance to deposit", + description: + "We found swap-supported assets for this wallet, but every available balance is currently zero.", + hint: "Fund one of the supported assets, then refresh balances to continue.", + }, +}; + +export function EmptyBalanceState({ mode, onRefresh }: EmptyBalanceStateProps) { + const content = CONTENT[mode]; + + return ( +
+
+
+ +
+
+

+ {content.title} +

+

+ {content.description} +

+

+ {content.hint} +

+
+ +
+
+ ); +} diff --git a/apps/megaeth/src/components/deposit/components/error-banner.tsx b/apps/megaeth/src/components/deposit/components/error-banner.tsx new file mode 100644 index 0000000..868615c --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/error-banner.tsx @@ -0,0 +1,17 @@ +import { InfoIcon } from "./icons"; + +interface ErrorBannerProps { + message: string; + icon?: boolean; +} + +export function ErrorBanner({ message, icon = true }: ErrorBannerProps) { + return ( +
+ {icon && } + + {message} + +
+ ); +} diff --git a/apps/megaeth/src/components/deposit/components/icons.tsx b/apps/megaeth/src/components/deposit/components/icons.tsx new file mode 100644 index 0000000..14450c4 --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/icons.tsx @@ -0,0 +1,505 @@ +import React from "react"; +import { cn } from "../utils"; + +interface IconProps { + className?: string; + size?: number; + onClick?: () => void; +} + +export function EthereumIcon({ className, size = 32 }: IconProps) { + return ( + + + + + + + + + ); +} + +export function SolanaIcon({ className, size = 32 }: IconProps) { + return ( + + + + + + ); +} + +export function UnplugIcon({ className, size = 21 }: IconProps) { + return ( + + + + ); +} + +export function PlugIcon({ className, size = 21 }: IconProps) { + return ( + + + + ); +} + +export function CloseIcon({ className, size = 24 }: IconProps) { + return ( + + + + ); +} + +export function LeftChevronIcon({ className, size = 24, onClick }: IconProps) { + return ( + + + + ); +} + +export function MetaMaskIcon({ className, size = 32 }: IconProps) { + return ( + + + + + + + + + + + + ); +} + +export function PhantomIcon({ className, size = 32 }: IconProps) { + return ( + + + + ); +} + +export function WalletIcon({ className, size = 32 }: IconProps) { + return ( + + + + ); +} + +export function CoinIcon({ className, size = 24 }: IconProps) { + return ( + + + + ); +} + +export function UpDownArrows({ className, size = 16 }: IconProps) { + return ( + + + + ); +} + +export function RightChevronIcon({ className, size = 24 }: IconProps) { + return ( + + + + ); +} + +export function ClockIcon({ className, size = 20 }: IconProps) { + return ( + + + + ); +} + +export function ArrowBoxUpRightIcon({ className, size = 16 }: IconProps) { + return ( + + + + ); +} + +export function ChevronDownIcon({ className, size = 16 }: IconProps) { + return ( + + + + ); +} + +export function ChevronUpIcon({ className, size = 16 }: IconProps) { + return ( + + + + ); +} + +export function GasPumpIcon({ className, size = 24 }: IconProps) { + return ( + + + + ); +} + +export function CheckIcon({ className, size = 24 }: IconProps) { + return ( + + + + ); +} + +export function AvailLogo({ className }: IconProps) { + return ( + + + + + + + + + + + + + + + + + + ); +} + +export function QrIcon({ className, size = 32 }: IconProps) { + return ( + + + + ); +} + +export function FiatIcon({ className, size = 32 }: IconProps) { + return ( + + + + ); +} + +export function InfoIcon({ className, size = 20 }: IconProps) { + return ( + + + + ); +} diff --git a/apps/megaeth/src/components/deposit/components/index.ts b/apps/megaeth/src/components/deposit/components/index.ts new file mode 100644 index 0000000..1c9581c --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/index.ts @@ -0,0 +1,13 @@ +export { default as AmountContainer } from "./amount-container"; +export { default as ConfirmationContainer } from "./confirmation-container"; +export { default as ConfirmationLoading } from "./confirmation-loading"; +export { default as TransactionStatusContainer } from "./transaction-status-container"; +export { default as TransactionCompleteContainer } from "./transaction-complete-container"; +export { default as TransactionFailedContainer } from "./transaction-failed-container"; +export { default as AssetSelectionContainer } from "./asset-selection-container"; + +// Shared components +export { default as WidgetHeader } from "./widget-header"; +export { default as TokenRow } from "./token-row"; +export { ReceiveAmountDisplay } from "./receive-amount-display"; +export { AmountDisplay } from "./amount-display"; diff --git a/apps/megaeth/src/components/deposit/components/pay-using.tsx b/apps/megaeth/src/components/deposit/components/pay-using.tsx new file mode 100644 index 0000000..a284d43 --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/pay-using.tsx @@ -0,0 +1,136 @@ +import { useMemo, useState, useEffect, useRef } from "react"; +import ButtonCard from "./button-card"; +import { RightChevronIcon, CoinIcon } from "./icons"; +import { Skeleton } from "../../ui/skeleton"; +import { LOADING_SKELETON_DELAY_MS } from "../constants/widget"; + +interface PayUsingProps { + onClick?: () => void; + selectedChainIds: Set; + amount?: string; + swapBalance: Array<{ + symbol: string; + decimals: number; + icon?: string; + breakdown?: Array<{ + chain: { id: number; name: string; logo?: string }; + balance: string; + balanceInFiat?: number; + contractAddress?: `0x${string}`; + }>; + }> | null; +} + +function PayUsing({ + onClick, + selectedChainIds, + amount, + swapBalance, +}: PayUsingProps) { + const [isLoading, setIsLoading] = useState(false); + const previousAmountRef = useRef(undefined); + const hasAmount = Boolean(amount && amount.trim() !== "" && amount !== "0"); + + useEffect(() => { + const hadAmount = Boolean( + previousAmountRef.current && previousAmountRef.current.trim() !== "", + ); + + if (hasAmount && !hadAmount) { + setIsLoading(true); + const timer = setTimeout(() => { + setIsLoading(false); + }, LOADING_SKELETON_DELAY_MS); + return () => clearTimeout(timer); + } + + previousAmountRef.current = amount; + }, [amount, hasAmount]); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { subtitle, selectedCount, totalUsdValue } = useMemo(() => { + const tokenCounts: Record = {}; + let total = 0; + + if (swapBalance) { + swapBalance.forEach((asset) => { + const selectedChains = + asset.breakdown?.filter((c) => + selectedChainIds.has(`${c.contractAddress}-${c.chain.id}`), + ) ?? []; + if (selectedChains.length > 0) { + tokenCounts[asset.symbol] = selectedChains.length; + selectedChains.forEach((c) => { + total += c.balanceInFiat ?? 0; + }); + } + }); + } + + const symbols = Object.keys(tokenCounts); + const count = Object.values(tokenCounts).reduce((a, b) => a + b, 0); + + let text: string; + if (count === 0) { + text = "No tokens selected"; + } else if (symbols.length <= 2) { + text = symbols.join(", "); + } else { + text = `${symbols.slice(0, 2).join(", ")} +${symbols.length - 2} more`; + } + + return { + subtitle: text, + selectedCount: count, + totalUsdValue: total, + }; + }, [selectedChainIds, swapBalance]); + + const renderSubtitle = () => { + if (!hasAmount) { + return ( + + Auto-selected based on amount + + ); + } + + if (isLoading) { + return ; + } + + return ( + + {subtitle} + + ); + }; + + const showEditControls = hasAmount && !isLoading; + + return ( + } + rightIcon={ + showEditControls ? ( +
+ + Edit + + +
+ ) : undefined + } + onClick={showEditControls ? onClick : undefined} + disabled={!showEditControls} + roundedBottom={false} + /> + ); +} + +export default PayUsing; diff --git a/apps/megaeth/src/components/deposit/components/percentage-selector.tsx b/apps/megaeth/src/components/deposit/components/percentage-selector.tsx new file mode 100644 index 0000000..935bac2 --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/percentage-selector.tsx @@ -0,0 +1,59 @@ +"use client"; + +const PERCENTAGE_OPTIONS = [ + { label: "25%", value: 0.25 }, + { label: "50%", value: 0.5 }, + { label: "75%", value: 0.75 }, + { label: "MAX", value: 1 }, +] as const; + +interface PercentageButtonProps { + label: string; + onClick: () => void; + isFirst?: boolean; + isLast?: boolean; +} + +function PercentageButton({ + label, + onClick, + isFirst, + isLast, +}: PercentageButtonProps) { + return ( + + ); +} + +export interface PercentageSelectorProps { + onPercentageClick: (percentage: number) => void; +} + +export function PercentageSelector({ + onPercentageClick, +}: PercentageSelectorProps) { + return ( +
+
+
+ {PERCENTAGE_OPTIONS.map((option, index) => ( + onPercentageClick(option.value)} + isFirst={index === 0} + isLast={index === PERCENTAGE_OPTIONS.length - 1} + /> + ))} +
+
+ ); +} diff --git a/apps/megaeth/src/components/deposit/components/receive-amount-display.tsx b/apps/megaeth/src/components/deposit/components/receive-amount-display.tsx new file mode 100644 index 0000000..7b57662 --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/receive-amount-display.tsx @@ -0,0 +1,68 @@ +import { TokenIcon } from "./token-icon"; +import { ClockIcon } from "./icons"; +import { DEPOSIT_WIDGET_ASSETS, TOKEN_IMAGES } from "../constants/assets"; +import { Skeleton } from "../../ui/skeleton"; +import { usdFormatter } from "../../common"; + +interface ReceiveAmountDisplayProps { + label?: string; + amount: string; + timeLabel?: string; + showUsdValue?: boolean; + showClockIcon?: boolean; + loading?: boolean; + destinationTokenLogo?: string; + depositTargetLogo?: string; +} + +export function ReceiveAmountDisplay({ + label = "You receive", + amount, + timeLabel, + showUsdValue = true, + showClockIcon = true, + loading = false, + destinationTokenLogo, + depositTargetLogo, +}: ReceiveAmountDisplayProps) { + return ( +
+ + {label} + +
+ + {loading ? ( + + ) : ( +

+ {amount} +

+ )} +
+ {(showUsdValue || showClockIcon) && ( +
+ {loading ? ( + + ) : ( +
+ {showUsdValue && `${usdFormatter.format(parseFloat(amount))} in`} + {showClockIcon && ( + + {timeLabel} + + + )} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/megaeth/src/components/deposit/components/summary-card.tsx b/apps/megaeth/src/components/deposit/components/summary-card.tsx new file mode 100644 index 0000000..ab5244a --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/summary-card.tsx @@ -0,0 +1,89 @@ +import { ChevronDownIcon, ChevronUpIcon } from "./icons"; +import { Skeleton } from "../../ui/skeleton"; +import { usdFormatter } from "../../common"; + +interface SummaryCardProps { + icon: React.ReactNode; + title: string; + subtitle?: string; + value: string; + valueSuffix?: string; + showBreakdown?: boolean; + loading?: boolean; + expanded?: boolean; + onToggleExpand?: () => void; + children?: React.ReactNode; +} + +function SummaryCard({ + icon, + title, + subtitle, + value, + valueSuffix, + showBreakdown, + loading = false, + expanded = false, + onToggleExpand, + children, +}: SummaryCardProps) { + return ( +
+
+
+ {icon} +
+ + {title} + + {subtitle && ( + + {subtitle} + + )} +
+
+
+
+ {loading ? ( + + ) : ( + <> + + {valueSuffix === "USD" + ? usdFormatter.format(parseFloat(value)) + : value} + + {valueSuffix && ( + + {valueSuffix} + + )} + + )} +
+ {showBreakdown && ( + + )} +
+
+ {expanded && children && ( +
{children}
+ )} +
+ ); +} + +export default SummaryCard; diff --git a/apps/megaeth/src/components/deposit/components/token-icon.tsx b/apps/megaeth/src/components/deposit/components/token-icon.tsx new file mode 100644 index 0000000..fdce584 --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/token-icon.tsx @@ -0,0 +1,51 @@ +import Image from "next/image"; +import { cn } from "../utils"; + +type TokenIconSize = "sm" | "md" | "lg"; + +const SIZE_MAP: Record = { + sm: { token: 24, protocol: 16 }, + md: { token: 32, protocol: 16 }, + lg: { token: 40, protocol: 20 }, +}; + +interface TokenIconProps { + tokenSrc: string; + protocolSrc?: string; + tokenAlt?: string; + protocolAlt?: string; + size?: TokenIconSize; + className?: string; +} + +export function TokenIcon({ + tokenSrc, + protocolSrc, + tokenAlt = "Token", + protocolAlt = "Protocol", + size = "sm", + className, +}: TokenIconProps) { + const dimensions = SIZE_MAP[size]; + + return ( +
+ {tokenAlt} + {protocolSrc && ( + {protocolAlt} + )} +
+ ); +} diff --git a/apps/megaeth/src/components/deposit/components/token-row.tsx b/apps/megaeth/src/components/deposit/components/token-row.tsx new file mode 100644 index 0000000..f8f629b --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/token-row.tsx @@ -0,0 +1,157 @@ +"use client"; + +import Image from "next/image"; +import { ChevronDownIcon } from "./icons"; +import type { Token } from "../types"; +import { getTokenCheckState } from "../utils/asset-helpers"; +import { Checkbox } from "../../ui/checkbox"; +import { usdFormatter } from "../../common"; +import { formatTokenBalance } from "@avail-project/nexus-core"; +import { TOKEN_IMAGES } from "../constants/assets"; +import { + CHAIN_ITEM_HEIGHT_PX, + VERTICAL_LINE_TOP_OFFSET_PX, +} from "../constants/widget"; + +interface TokenRowProps { + token: Token; + selectedChainIds: Set; + isExpanded: boolean; + onToggleExpand: () => void; + onToggleToken: () => void; + onToggleChain: (chainId: string) => void; + isFirst?: boolean; + isLast?: boolean; +} + +export function TokenRow({ + token, + selectedChainIds, + isExpanded, + onToggleExpand, + onToggleToken, + onToggleChain, + isFirst = false, + isLast = false, +}: TokenRowProps) { + const hasMultipleChains = token.chains.length > 1; + const tokenCheckState = getTokenCheckState(token, selectedChainIds); + + return ( +
+ {/* Main token row */} +
+
+ e.stopPropagation()} + /> +
+ {token.symbol} +
+ + {token.symbol} + + + {token.chainsLabel} + +
+
+
+
+
+ + {token.usdValue} + + + {token.amount} + +
+ {hasMultipleChains ? ( + + ) : ( +
+ )} +
+
+ + {/* Expanded chain list */} + {isExpanded && hasMultipleChains && ( +
+
+ {/* Vertical line */} +
+ {/* Chain items */} +
+ {token.chains.map((chain) => ( +
+ {/* Horizontal line */} +
+ {/* Chain content */} +
+
+ onToggleChain(chain.id)} + /> + + {chain.name} + +
+
+ + {usdFormatter.format(chain.usdValue)} + + + {formatTokenBalance(chain.amount, { + decimals: token.decimals, + symbol: token.symbol, + })} + +
+
+
+ ))} +
+
+
+ )} +
+ ); +} + +export default TokenRow; diff --git a/apps/megaeth/src/components/deposit/components/transaction-complete-container.tsx b/apps/megaeth/src/components/deposit/components/transaction-complete-container.tsx new file mode 100644 index 0000000..6d757cc --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/transaction-complete-container.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useState } from "react"; +import WidgetHeader from "./widget-header"; +import { ReceiveAmountDisplay } from "./receive-amount-display"; +import type { DepositWidgetContextValue } from "../types"; +import { ArrowBoxUpRightIcon, ChevronDownIcon, ChevronUpIcon } from "./icons"; +import { CardContent, CardFooter } from "../../ui/card"; +import { Button } from "../../ui/button"; +import { usdFormatter } from "../../common"; +import { TOKEN_IMAGES } from "../constants/assets"; + +function formatTimer(seconds: number): string { + const secs = Math.round(seconds); + return `${secs}s`; +} + +function truncateHash(hash: string): string { + if (hash.length <= 13) return hash; + return `${hash.slice(0, 6)}...${hash.slice(-4)}`; +} + +interface TransactionCompleteContainerProps { + widget: DepositWidgetContextValue; + heading?: string; + onClose?: () => void; +} + +const TransactionCompleteContainer = ({ + widget, + heading, + onClose, +}: TransactionCompleteContainerProps) => { + const [showSourceDetails, setShowSourceDetails] = useState(false); + + const handleNewDeposit = () => { + widget.reset(); + widget.goToStep("amount"); + }; + + const handleClose = () => { + widget.reset(); + onClose?.(); + }; + + // Use user's requested amount + const receiveAmountUsd = + widget.confirmationDetails?.receiveAmountAfterSwapUsd?.toFixed(2) ?? "0"; + const completionTime = formatTimer(widget.timer); + + const hasSourceSwaps = widget.sourceSwaps.length > 0; + + // Build deposit transaction URL + const depositTxUrl = + widget.destination.explorerUrl && widget.depositTxHash + ? `${widget.destination.explorerUrl}/tx/${widget.depositTxHash}` + : null; + + return ( + <> + + +
+
+ + + Transaction successful + +
+
+ {/* Collected on sources section - only show when swap was not skipped */} + {!widget.skipSwap && ( +
+
+ + Collected on sources + + +
+
+
+
+ {hasSourceSwaps + ? // Show individual source chain links + widget.sourceSwaps.map((swap, index) => ( + + {swap.chainName} + + + )) + : // No source swaps - show Nexus intent URL + widget.nexusIntentUrl && ( + + View on Nexus Explorer + + + )} +
+
+
+
+ )} + + {/* Deposit transaction */} + {depositTxUrl && ( + + )} +
+ + {/* Fees section */} +
+
+
+ + Total fees + +
+ + {usdFormatter.format( + widget.feeBreakdown.gasUsd + + (widget?.confirmationDetails?.totalFeeUsd ?? 0), + )}{" "} + USD + +
+
+
+
+
+ + +
+
+
+ + + ); +}; + +export default TransactionCompleteContainer; diff --git a/apps/megaeth/src/components/deposit/components/transaction-failed-container.tsx b/apps/megaeth/src/components/deposit/components/transaction-failed-container.tsx new file mode 100644 index 0000000..776002d --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/transaction-failed-container.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { CardContent, CardFooter } from "../../ui/card"; +import { Button } from "../../ui/button"; +import WidgetHeader from "./widget-header"; +import { ReceiveAmountDisplay } from "./receive-amount-display"; +import type { DepositWidgetContextValue } from "../types"; + +interface TransactionFailedContainerProps { + widget: DepositWidgetContextValue; + heading?: string; + onClose?: () => void; +} + +const TransactionFailedContainer = ({ + widget, + heading, + onClose, +}: TransactionFailedContainerProps) => { + const handleRetry = () => { + widget.setTxError(null); + widget.reset(); + widget.goToStep("amount"); + }; + + const handleClose = () => { + widget.reset(); + onClose?.(); + }; + + return ( + <> + + +
+
+ +
+

+ {widget?.txError ?? + `It's not you, it's us. Everything seems to be in order from your + side, our engineers might have broken something.`} +

+

+ Retry in a bit? +

+
+
+
+ + +
+
+
+ + + ); +}; + +export default TransactionFailedContainer; diff --git a/apps/megaeth/src/components/deposit/components/transaction-status-container.tsx b/apps/megaeth/src/components/deposit/components/transaction-status-container.tsx new file mode 100644 index 0000000..ad02a50 --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/transaction-status-container.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { CardContent, CardFooter } from "../../ui/card"; +import WidgetHeader from "./widget-header"; +import { AmountDisplay } from "./amount-display"; +import { TransactionSteps, type SimplifiedStep } from "./transaction-steps"; +import type { DepositWidgetContextValue } from "../types"; +import { useMemo } from "react"; +import { usdFormatter } from "../../common"; +import { useNexus } from "../../nexus/NexusProvider"; + +interface TransactionStatusContainerProps { + widget: DepositWidgetContextValue; + heading?: string; + onClose?: () => void; +} + +function TransferIndicator({ isProcessing }: { isProcessing: boolean }) { + const baseClasses = "w-2 h-2 transition-all duration-300"; + + if (isProcessing) { + return ( + <> +
+
+
+
+
+ + ); + } + + return ( + <> +
+
+
+
+
+ + ); +} + +const TransactionStatusContainer = ({ + widget, + heading, + onClose, +}: TransactionStatusContainerProps) => { + const { steps, confirmationDetails, activeIntent, isProcessing } = widget; + + const receiveAmount = confirmationDetails?.receiveAmountAfterSwap ?? "0"; + const receiveTokenSymbol = confirmationDetails?.receiveTokenSymbol ?? "USDC"; + const destinationChainName = + confirmationDetails?.destinationChainName ?? + activeIntent?.intent?.destination?.chain?.name ?? + "destination"; + const sourceCount = widget.skipSwap + ? 0 // No source assets when using existing balance + : (activeIntent?.intent?.sources?.length ?? 0); + const spendAmountUsd = widget?.confirmationDetails?.amountSpent ?? 0; + + // Derive simplified steps from actual SDK events + const simplifiedSteps = useMemo((): SimplifiedStep[] => { + // When swap is skipped, only show deposit transaction step + if (widget.skipSwap) { + return [ + { + id: "deposit-transaction", + label: "Deposit transaction", + completed: widget.isSuccess, + }, + ]; + } + + const hasRffId = steps.some((s) => s.step.type === "RFF_ID" && s.completed); + // Use SOURCE_SWAP_HASH for "Collecting on Source" step + const hasSourceSwapHash = steps.some( + (s) => s.step.type === "DESTINATION_SWAP_HASH" && s.completed, + ); + // Deposit transaction only completes when the entire transaction succeeds + const isTransactionComplete = widget.isSuccess; + + return [ + { + id: "intent-verification", + label: "Intent Verification", + completed: hasRffId, + }, + { + id: "collecting-on-source", + label: "Collecting on Source", + completed: hasSourceSwapHash, + }, + { + id: "deposit-transaction", + label: "Deposit transaction", + completed: isTransactionComplete, + }, + ]; + }, [steps, widget.isSuccess, widget.skipSwap]); + + // Calculate progress based on completed steps + const progress = useMemo(() => { + const completedCount = simplifiedSteps.filter((s) => s.completed).length; + const totalSteps = simplifiedSteps.length; + return Math.round((completedCount / totalSteps) * 100); + }, [simplifiedSteps]); + + const getStatusMessage = () => { + if (widget.isError && widget.txError) { + return {widget.txError}; + } + if (widget.isSuccess) return "Transaction complete"; + if (widget.isProcessing) return "Processing transaction..."; + return "Verifying intent"; + }; + + return ( + <> + + +
+
+
+ +
+ +
+ +
+
+
+
+
+
+ {getStatusMessage()} +
+ +
+ + + + ); +}; + +export default TransactionStatusContainer; diff --git a/apps/megaeth/src/components/deposit/components/transaction-steps.tsx b/apps/megaeth/src/components/deposit/components/transaction-steps.tsx new file mode 100644 index 0000000..8ace31c --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/transaction-steps.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { AnimatedSpinner } from "../../ui/animated-spinner"; +import { CheckIcon } from "./icons"; + +export interface SimplifiedStep { + id: string; + label: string; + completed: boolean; + /** If true, this step will be grouped with the next step (no separator, only gap) */ + groupWithNext?: boolean; +} + +interface TransactionStepsProps { + steps: SimplifiedStep[]; +} + +type StepStatus = "completed" | "in-progress" | "pending"; + +function getStepStatus( + steps: SimplifiedStep[], + stepIndex: number, +): StepStatus { + const step = steps[stepIndex]; + if (step?.completed) return "completed"; + + // Find the first incomplete step + const firstIncompleteIndex = steps.findIndex((s) => !s.completed); + if (stepIndex === firstIncompleteIndex) return "in-progress"; + + return "pending"; +} + +export function TransactionSteps({ steps }: TransactionStepsProps) { + // Group steps based on groupWithNext property + const groupedSteps: SimplifiedStep[][] = []; + let currentGroup: SimplifiedStep[] = []; + + steps.forEach((step) => { + currentGroup.push(step); + if (!step.groupWithNext) { + groupedSteps.push(currentGroup); + currentGroup = []; + } + }); + if (currentGroup.length > 0) { + groupedSteps.push(currentGroup); + } + + return ( +
+ {groupedSteps.map((group, groupIndex) => { + const isLastGroup = groupIndex === groupedSteps.length - 1; + + return ( +
+ {group.map((step) => { + const stepIndex = steps.findIndex((s) => s.id === step.id); + const status = getStepStatus(steps, stepIndex); + + return ( +
+
+ {status === "completed" && ( + + )} + {status === "in-progress" && ( + + )} + {status === "pending" && ( +
+ )} +
+ + {step.label} + +
+ ); + })} +
+ ); + })} +
+ ); +} diff --git a/apps/megaeth/src/components/deposit/components/widget-header.tsx b/apps/megaeth/src/components/deposit/components/widget-header.tsx new file mode 100644 index 0000000..fc1668a --- /dev/null +++ b/apps/megaeth/src/components/deposit/components/widget-header.tsx @@ -0,0 +1,51 @@ +"use client"; + +import Image from "next/image"; +import { CloseIcon, LeftChevronIcon } from "./icons"; + +interface WidgetHeaderProps { + title: string; + depositTargetLogo?: string; + onBack?: () => void; + onClose?: () => void; +} + +const WidgetHeader = ({ + title, + onBack, + onClose, + depositTargetLogo, +}: WidgetHeaderProps) => { + return ( +
+ {onBack ? ( + + ) : depositTargetLogo ? ( + + ) : ( +
+ )} +

+ {title} +

+ +
+ ); +}; + +export default WidgetHeader; diff --git a/apps/megaeth/src/components/deposit/constants/assets.ts b/apps/megaeth/src/components/deposit/constants/assets.ts new file mode 100644 index 0000000..58ece79 --- /dev/null +++ b/apps/megaeth/src/components/deposit/constants/assets.ts @@ -0,0 +1,45 @@ +export const DEPOSIT_WIDGET_ASSETS = { + tokens: { + USDC: "/usdc.svg", + ETH: "/ethereum.svg", + }, + protocols: { + aave: "/aave.svg", + }, + wallets: { + metamask: "/metamask.svg", + phantom: "/phantom.svg", + }, + // features now use React icon components instead of SVG files +} as const; + +export const TOKEN_IMAGES: Record = { + USDC: "https://coin-images.coingecko.com/coins/images/6319/large/usdc.png", + USDT: "https://coin-images.coingecko.com/coins/images/35023/large/USDT.png", + USDM: "https://raw.githubusercontent.com/availproject/nexus-assets/main/tokens/usdm/logo.png", + "USDâ‚®0": + "https://coin-images.coingecko.com/coins/images/35023/large/USDT.png", + WETH: "https://assets.coingecko.com/coins/images/279/large/ethereum.png?1595348880", + USDS: "https://assets.coingecko.com/coins/images/39926/standard/usds.webp?1726666683", + SOPH: "https://assets.coingecko.com/coins/images/38680/large/sophon_logo_200.png", + KAIA: "https://assets.coingecko.com/asset_platforms/images/9672/large/kaia.png", + BNB: "https://assets.coingecko.com/coins/images/825/large/bnb-icon2_2x.png", + // Add ETH as fallback for any ETH-related tokens + ETH: "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628", + // Add common token fallbacks + POL: "https://coin-images.coingecko.com/coins/images/32440/standard/polygon.png", + AVAX: "https://assets.coingecko.com/coins/images/12559/standard/Avalanche_Circle_RedWhite_Trans.png", + FUEL: "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png", + HYPE: "https://assets.coingecko.com/asset_platforms/images/243/large/hyperliquid.png", + // Popular swap tokens + DAI: "https://coin-images.coingecko.com/coins/images/9956/large/Badge_Dai.png?1696509996", + UNI: "https://coin-images.coingecko.com/coins/images/12504/large/uni.jpg?1696512319", + AAVE: "https://coin-images.coingecko.com/coins/images/12645/large/AAVE.png?1696512452", + LDO: "https://coin-images.coingecko.com/coins/images/13573/large/Lido_DAO.png?1696513326", + PEPE: "https://coin-images.coingecko.com/coins/images/29850/large/pepe-token.jpeg?1696528776", + OP: "https://coin-images.coingecko.com/coins/images/25244/large/Optimism.png?1696524385", + ZRO: "https://coin-images.coingecko.com/coins/images/28206/large/ftxG9_TJ_400x400.jpeg?1696527208", + OM: "https://assets.coingecko.com/coins/images/12151/standard/OM_Token.png?1696511991", + KAITO: + "https://assets.coingecko.com/coins/images/54411/standard/Qm4DW488_400x400.jpg", +}; diff --git a/apps/megaeth/src/components/deposit/constants/widget.ts b/apps/megaeth/src/components/deposit/constants/widget.ts new file mode 100644 index 0000000..b169323 --- /dev/null +++ b/apps/megaeth/src/components/deposit/constants/widget.ts @@ -0,0 +1,31 @@ +export const SCROLL_THRESHOLD_PX = 50; +export const PROGRESS_BAR_ANIMATION_DELAY_MS = 50; +export const PROGRESS_BAR_EXIT_DURATION_MS = 300; + +// Timing +export const LOADING_SKELETON_DELAY_MS = 600; +export const CHARACTER_ANIMATION_DURATION_MS = 400; +export const SHINE_ANIMATION_DURATION_MS = 500; +export const SIMULATION_POLL_INTERVAL_MS = 15000; + +// Safety & Calculations +export const BALANCE_SAFETY_MARGIN = 0.92; // Keep 8% as safety buffer +export const DEFAULT_TOKEN_DECIMALS = 6; + +// Layout +export const CHAIN_ITEM_HEIGHT_PX = 49; +export const VERTICAL_LINE_TOP_OFFSET_PX = 48; +export const MAX_INPUT_WIDTH_PX = 300; + +// Asset Selection +export const STABLECOIN_SYMBOLS = ["USDC", "USDT", "DAI", "TUSD", "USDP"] as const; + +// Animation classes (for reference, actual classes defined in CSS) +export const ANIMATION_CLASSES = { + slideInFromRight: "animate-slide-in-from-right", + slideInFromLeft: "animate-slide-in-from-left", + digitIn: "animate-digit-in", + glareShine: "animate-glare-shine", + transferWave: "animate-transfer-wave", + progress: "animate-progress", +} as const; diff --git a/apps/megaeth/src/components/deposit/hooks/use-asset-selection.ts b/apps/megaeth/src/components/deposit/hooks/use-asset-selection.ts new file mode 100644 index 0000000..e5d2e10 --- /dev/null +++ b/apps/megaeth/src/components/deposit/hooks/use-asset-selection.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +import type { AssetSelectionState } from "../types"; +import type { UserAsset } from "@avail-project/nexus-core"; + +/** + * Creates fresh initial asset selection state + */ +export const createInitialAssetSelection = (): AssetSelectionState => ({ + selectedChainIds: new Set(), + filter: "all", + expandedTokens: new Set(), +}); + +/** + * Hook for managing asset selection state in the deposit widget. + * Handles selection of tokens/chains for cross-chain swaps. + */ +export function useAssetSelection(swapBalance: UserAsset[] | null) { + const [assetSelection, setAssetSelectionState] = + useState(createInitialAssetSelection); + const hasUserModifiedSelection = useRef(false); + + // Extract primitive value for effect dependency (rerender-dependencies) + const selectedChainIdsCount = assetSelection.selectedChainIds.size; + + // Auto-select all assets when swapBalance first loads + useEffect(() => { + if ( + swapBalance && + selectedChainIdsCount === 0 && + !hasUserModifiedSelection.current + ) { + const allChainIds = new Set(); + swapBalance.forEach((asset) => { + if (asset.breakdown) { + asset.breakdown.forEach((b) => { + if (b.chain && b.balance) { + allChainIds.add(`${b.contractAddress}-${b.chain.id}`); + } + }); + } + }); + if (allChainIds.size > 0) { + setAssetSelectionState({ + selectedChainIds: allChainIds, + filter: "all", + expandedTokens: new Set(), + }); + } + } + }, [swapBalance, selectedChainIdsCount]); + + const setAssetSelection = useCallback( + (update: Partial) => { + hasUserModifiedSelection.current = true; + setAssetSelectionState((prev) => ({ ...prev, ...update })); + }, + [] + ); + + const resetAssetSelection = useCallback(() => { + hasUserModifiedSelection.current = false; + setAssetSelectionState(createInitialAssetSelection()); + }, []); + + return { + assetSelection, + setAssetSelection, + resetAssetSelection, + }; +} diff --git a/apps/megaeth/src/components/deposit/hooks/use-deposit-computed.ts b/apps/megaeth/src/components/deposit/hooks/use-deposit-computed.ts new file mode 100644 index 0000000..d8673ee --- /dev/null +++ b/apps/megaeth/src/components/deposit/hooks/use-deposit-computed.ts @@ -0,0 +1,434 @@ +"use client"; + +import { useMemo } from "react"; +import type { DestinationConfig, AssetSelectionState } from "../types"; +import type { + OnSwapIntentHookData, + NexusSDK, + UserAsset, +} from "@avail-project/nexus-core"; +import { CHAIN_METADATA, formatTokenBalance } from "@avail-project/nexus-core"; +import { usdFormatter } from "../../common"; +import type { SwapSkippedData } from "./use-deposit-state"; + +const NATIVE_TOKEN_PLACEHOLDER_ADDRESS = + "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +function normalizeAddress(address?: string | null): string { + return (address ?? "").toLowerCase(); +} + +function isNativeLikeAddress(address?: string | null): boolean { + const normalized = normalizeAddress(address); + return ( + normalized === NATIVE_TOKEN_PLACEHOLDER_ADDRESS || + normalized === ZERO_ADDRESS + ); +} + +function resolvePricingSymbol(params: { + chainId: number; + contractAddress?: string | null; + fallbackSymbol: string; +}): string { + const { chainId, contractAddress, fallbackSymbol } = params; + if (!isNativeLikeAddress(contractAddress)) { + return fallbackSymbol; + } + + const nativeSymbol = + CHAIN_METADATA[chainId as keyof typeof CHAIN_METADATA]?.nativeCurrency + ?.symbol; + return nativeSymbol ?? fallbackSymbol; +} + +interface UseDepositComputedProps { + swapBalance: UserAsset[] | null; + assetSelection: AssetSelectionState; + activeIntent: OnSwapIntentHookData | null; + destination: DestinationConfig; + inputAmount: string | undefined; + exchangeRate: Record | null; + getFiatValue: (amount: number, symbol: string) => number; + actualGasFeeUsd: number | null; + swapSkippedData: SwapSkippedData | null; + skipSwap: boolean; + nexusSDK: NexusSDK | null; +} + +/** + * Available asset item from swap balance + */ +export interface AvailableAsset { + chainId: number; + tokenAddress: `0x${string}`; + decimals: number; + symbol: string; + balance: string; + balanceInFiat?: number; + tokenLogo?: string; + chainLogo?: string; + chainName?: string; +} + +/** + * Hook for computing derived values from deposit widget state. + * Separates computation logic from main hook for better maintainability. + */ +export function useDepositComputed(props: UseDepositComputedProps) { + const { + swapBalance, + assetSelection, + activeIntent, + destination, + inputAmount, + exchangeRate, + getFiatValue, + actualGasFeeUsd, + swapSkippedData, + skipSwap, + nexusSDK, + } = props; + + /** + * Flatten swap balance into a sorted list of available assets + */ + const availableAssets = useMemo(() => { + if (!swapBalance) return []; + const items: AvailableAsset[] = []; + + for (const asset of swapBalance) { + if (!asset?.breakdown?.length) continue; + for (const breakdown of asset.breakdown) { + if (!breakdown?.chain?.id || !breakdown.balance) continue; + const numericBalance = Number.parseFloat(breakdown.balance); + if (!Number.isFinite(numericBalance) || numericBalance <= 0) continue; + + items.push({ + chainId: breakdown.chain.id, + tokenAddress: breakdown.contractAddress as `0x${string}`, + decimals: breakdown.decimals ?? asset.decimals, + symbol: asset.symbol, + balance: breakdown.balance, + balanceInFiat: breakdown.balanceInFiat, + tokenLogo: asset.icon, + chainLogo: breakdown.chain.logo, + chainName: breakdown.chain.name, + }); + } + } + return items.toSorted( + (a, b) => (b.balanceInFiat ?? 0) - (a.balanceInFiat ?? 0), + ); + }, [swapBalance]); + + /** + * Total USD value of selected assets + */ + const totalSelectedBalance = useMemo( + () => + availableAssets.reduce((sum, asset) => { + const key = `${asset.tokenAddress}-${asset.chainId}`; + if (assetSelection.selectedChainIds.has(key)) { + return sum + (asset.balanceInFiat ?? 0); + } + return sum; + }, 0), + [availableAssets, assetSelection.selectedChainIds], + ); + + /** + * Total balance across all assets + */ + const totalBalance = useMemo(() => { + const balance = + swapBalance?.reduce( + (acc, balance) => acc + parseFloat(balance.balance), + 0, + ) ?? 0; + const usdBalance = + swapBalance?.reduce((acc, balance) => acc + balance.balanceInFiat, 0) ?? + 0; + return { balance, usdBalance }; + }, [swapBalance]); + + /** + * User's existing balance on destination chain + */ + const destinationBalance = useMemo(() => { + if (!nexusSDK || !swapBalance || !destination) return undefined; + return swapBalance + ?.find((token) => token.symbol === destination.tokenSymbol) + ?.breakdown?.find((chain) => chain.chain?.id === destination.chainId); + }, [swapBalance, nexusSDK, destination]); + + /** + * Confirmation screen details computed from intent or skipped swap data + */ + const confirmationDetails = useMemo(() => { + // Handle swap skipped case - compute from swapSkippedData + if (swapSkippedData && skipSwap) { + const { destination: destData, gas } = swapSkippedData; + + // Format the token amount from raw units + const rawAmount = Number.parseFloat(destData.amount); + const tokenAmount = rawAmount / Math.pow(10, destData.token.decimals); + const receiveAmountUsd = getFiatValue(tokenAmount, destData.token.symbol); + + // Format for display + const receiveAmountAfterSwap = `${tokenAmount.toFixed(2)} ${destData.token.symbol}`; + + // Gas fee calculation from swapSkippedData + const estimatedFeeWei = Number.parseFloat(gas.estimatedFee); + const estimatedFeeEth = estimatedFeeWei / 1e18; + const gasFeeUsd = getFiatValue( + estimatedFeeEth, + destination.gasTokenSymbol ?? "ETH", + ); + + return { + sourceLabel: destination.label ?? "Deposit", + sources: [], + gasTokenSymbol: destination.gasTokenSymbol, + estimatedTime: destination.estimatedTime ?? "~30s", + amountSpent: receiveAmountUsd, + totalFeeUsd: gasFeeUsd, + receiveTokenSymbol: destData.token.symbol, + receiveAmountAfterSwapUsd: receiveAmountUsd, + receiveAmountAfterSwap, + receiveTokenLogo: destination.tokenLogo, + receiveTokenChain: destData.chain.id, + destinationChainName: destData.chain.name, + }; + } + + if (!activeIntent || !nexusSDK) return null; + + // Use user's requested amount (from input), not SDK's optimized bridge amount + const receiveAmountUsd = inputAmount + ? parseFloat(inputAmount.replace(/,/g, "")) + : 0; + + // Convert USD amount to token amount for display + const tokenExchangeRate = exchangeRate?.[destination.tokenSymbol] ?? 1; + const receiveTokenAmount = receiveAmountUsd / tokenExchangeRate; + + const receiveAmountAfterSwap = formatTokenBalance( + receiveTokenAmount.toString(), + { + symbol: destination.tokenSymbol, + decimals: destination.tokenDecimals, + }, + ); + + // Build sources array from intent sources + const sources: Array<{ + chainId: number; + tokenAddress: `0x${string}`; + decimals: number; + symbol: string; + balance: string; + balanceInFiat?: number; + tokenLogo?: string; + chainLogo?: string; + chainName?: string; + isDestinationBalance?: boolean; + }> = []; + + activeIntent.intent.sources.forEach((source) => { + const sourcePricingSymbol = resolvePricingSymbol({ + chainId: source.chain.id, + contractAddress: source.token.contractAddress, + fallbackSymbol: source.token.symbol, + }); + const sourceAmount = Number.parseFloat(source.amount); + const sourceAmountUsd = Number.isFinite(sourceAmount) + ? getFiatValue(sourceAmount, sourcePricingSymbol) + : 0; + + const matchingAsset = availableAssets.find( + (asset) => + asset.chainId === source.chain.id && + (normalizeAddress(asset.tokenAddress) === + normalizeAddress(source.token.contractAddress) || + asset.symbol.toUpperCase() === source.token.symbol.toUpperCase()), + ); + + if (matchingAsset) { + sources.push({ + ...matchingAsset, + symbol: sourcePricingSymbol, + balance: source.amount, + balanceInFiat: sourceAmountUsd, + isDestinationBalance: false, + }); + } else { + sources.push({ + chainId: source.chain.id, + tokenAddress: source.token.contractAddress as `0x${string}`, + decimals: source.token.decimals, + symbol: sourcePricingSymbol, + balance: source.amount, + balanceInFiat: sourceAmountUsd, + chainLogo: source.chain.logo, + chainName: source.chain.name, + isDestinationBalance: false, + }); + } + }); + + // Calculate total spent from cross-chain sources + const totalAmountSpentUsd = activeIntent.intent.sources?.reduce( + (acc, source) => { + const sourcePricingSymbol = resolvePricingSymbol({ + chainId: source.chain.id, + contractAddress: source.token.contractAddress, + fallbackSymbol: source.token.symbol, + }); + const amount = Number.parseFloat(source.amount); + const usdAmount = Number.isFinite(amount) + ? getFiatValue(amount, sourcePricingSymbol) + : 0; + return acc + usdAmount; + }, + 0, + ); + + // Get the actual amount arriving on destination (AFTER fees) + const destinationAmount = Number.parseFloat( + activeIntent.intent.destination?.amount ?? "0", + ); + const destinationPricingSymbol = resolvePricingSymbol({ + chainId: + activeIntent.intent.destination?.chain?.id ?? destination.chainId, + contractAddress: activeIntent.intent.destination?.token?.contractAddress, + fallbackSymbol: + activeIntent.intent.destination?.token?.symbol ?? + destination.tokenSymbol, + }); + const destinationAmountUsd = getFiatValue( + destinationAmount, + destinationPricingSymbol, + ); + + // Calculate bridge/protocol fees + const totalFeeUsd = Math.max(0, totalAmountSpentUsd - destinationAmountUsd); + + // Calculate destination balance used + const usedFromDestinationUsd = Math.max( + 0, + receiveAmountUsd - destinationAmountUsd, + ); + + if (usedFromDestinationUsd > 0.01 && destinationBalance) { + const usedTokenAmount = usedFromDestinationUsd / tokenExchangeRate; + const chainMeta = + CHAIN_METADATA[destination.chainId as keyof typeof CHAIN_METADATA]; + + sources.push({ + chainId: destination.chainId, + tokenAddress: destination.tokenAddress, + decimals: destination.tokenDecimals, + symbol: destination.tokenSymbol, + balance: usedTokenAmount.toString(), + balanceInFiat: usedFromDestinationUsd, + tokenLogo: destination.tokenLogo, + chainLogo: chainMeta?.logo, + chainName: chainMeta?.name, + isDestinationBalance: true, + }); + } + + const actualAmountSpent = totalAmountSpentUsd + usedFromDestinationUsd; + + return { + sourceLabel: destination.label ?? "Deposit", + sources, + gasTokenSymbol: destination.gasTokenSymbol, + estimatedTime: destination.estimatedTime ?? "~30s", + amountSpent: actualAmountSpent, + totalFeeUsd, + receiveTokenSymbol: destination.tokenSymbol, + receiveAmountAfterSwapUsd: receiveAmountUsd, + receiveAmountAfterSwap, + receiveTokenLogo: destination.tokenLogo, + receiveTokenChain: destination.chainId, + destinationChainName: activeIntent.intent.destination?.chain?.name, + }; + }, [ + activeIntent, + nexusSDK, + destination, + availableAssets, + inputAmount, + exchangeRate, + getFiatValue, + destinationBalance, + swapSkippedData, + skipSwap, + ]); + + /** + * Gas fee breakdown for display + */ + const feeBreakdown = useMemo(() => { + // Use actual gas fee from receipt if available + if (actualGasFeeUsd !== null) { + const gasFormatted = usdFormatter.format(actualGasFeeUsd); + return { + totalGasFee: actualGasFeeUsd, + gasUsd: actualGasFeeUsd, + gasFormatted, + }; + } + + // Use gas from swapSkippedData when swap is skipped + if (swapSkippedData && skipSwap) { + const { gas } = swapSkippedData; + const estimatedFeeWei = Number.parseFloat(gas.estimatedFee); + const estimatedFeeEth = estimatedFeeWei / 1e18; + const gasUsd = getFiatValue( + estimatedFeeEth, + destination.gasTokenSymbol ?? "ETH", + ); + const gasFormatted = usdFormatter.format(gasUsd); + return { totalGasFee: gasUsd, gasUsd, gasFormatted }; + } + + // Otherwise use estimated gas from intent + if (!activeIntent?.intent?.destination?.gas) { + return { totalGasFee: 0, gasUsd: 0, gasFormatted: "0" }; + } + + const gas = activeIntent.intent.destination.gas; + const gasAmount = parseFloat(gas.amount); + const gasSymbol = resolvePricingSymbol({ + chainId: + activeIntent.intent.destination?.chain?.id ?? destination.chainId, + contractAddress: gas.token?.contractAddress, + fallbackSymbol: gas.token?.symbol ?? destination.gasTokenSymbol ?? "ETH", + }); + const gasUsd = getFiatValue(gasAmount, gasSymbol); + const gasFormatted = usdFormatter.format(gasUsd); + + return { totalGasFee: gasUsd, gasUsd, gasFormatted }; + }, [ + activeIntent, + getFiatValue, + actualGasFeeUsd, + swapSkippedData, + skipSwap, + destination.chainId, + destination.gasTokenSymbol, + ]); + + return { + availableAssets, + totalSelectedBalance, + totalBalance, + destinationBalance, + confirmationDetails, + feeBreakdown, + }; +} diff --git a/apps/megaeth/src/components/deposit/hooks/use-deposit-state.ts b/apps/megaeth/src/components/deposit/hooks/use-deposit-state.ts new file mode 100644 index 0000000..75e0249 --- /dev/null +++ b/apps/megaeth/src/components/deposit/hooks/use-deposit-state.ts @@ -0,0 +1,233 @@ +"use client"; + +import { useReducer } from "react"; +import type { + WidgetStep, + TransactionStatus, + DepositInputs, + NavigationDirection, +} from "../types"; +import type { OnSwapIntentHookData } from "@avail-project/nexus-core"; + +/** + * Source swap info collected during transaction execution + */ +export interface SourceSwapInfo { + chainId: number; + chainName: string; + explorerUrl: string; +} + +/** + * Data from SDK when swap is skipped (using existing destination balance) + */ +export interface SwapSkippedData { + destination: { + amount: string; + chain: { id: number; name: string }; + token: { + contractAddress: `0x${string}`; + decimals: number; + symbol: string; + }; + }; + input: { + amount: string; + token: { + contractAddress: `0x${string}`; + decimals: number; + symbol: string; + }; + }; + gas: { + required: string; + price: string; + estimatedFee: string; + }; +} + +/** + * Core deposit widget state + */ +export interface DepositState { + step: WidgetStep; + inputs: DepositInputs; + status: TransactionStatus; + explorerUrls: { + sourceExplorerUrl: string | null; + destinationExplorerUrl: string | null; + }; + sourceSwaps: SourceSwapInfo[]; + nexusIntentUrl: string | null; + depositTxHash: string | null; + actualGasFeeUsd: number | null; + error: string | null; + lastResult: unknown; + navigationDirection: NavigationDirection; + simulation: { + swapIntent: OnSwapIntentHookData; + } | null; + simulationLoading: boolean; + receiveAmount: string | null; + skipSwap: boolean; + intentReady: boolean; + swapSkippedData: SwapSkippedData | null; +} + +/** + * Action types for state reducer + */ +export type DepositAction = + | { + type: "setStep"; + payload: { step: WidgetStep; direction: NavigationDirection }; + } + | { type: "setInputs"; payload: Partial } + | { type: "setStatus"; payload: TransactionStatus } + | { + type: "setExplorerUrls"; + payload: Partial; + } + | { type: "setError"; payload: string | null } + | { type: "setLastResult"; payload: unknown } + | { + type: "setSimulation"; + payload: { + swapIntent: OnSwapIntentHookData; + }; + } + | { type: "setSimulationLoading"; payload: boolean } + | { type: "setReceiveAmount"; payload: string | null } + | { type: "setSkipSwap"; payload: boolean } + | { type: "setIntentReady"; payload: boolean } + | { type: "setSwapSkippedData"; payload: SwapSkippedData | null } + | { type: "addSourceSwap"; payload: SourceSwapInfo } + | { type: "setNexusIntentUrl"; payload: string | null } + | { type: "setDepositTxHash"; payload: string | null } + | { type: "setActualGasFeeUsd"; payload: number | null } + | { type: "reset" }; + +/** + * Step history for back navigation + */ +export const STEP_HISTORY: Record = { + amount: null, + confirmation: "amount", + "transaction-status": null, + "transaction-complete": null, + "transaction-failed": null, + "asset-selection": "amount", +} as const; + +/** + * Creates fresh initial state + */ +export const createInitialState = (): DepositState => ({ + step: "amount", + inputs: { + amount: undefined, + selectedToken: "USDC", + }, + status: "idle", + explorerUrls: { + sourceExplorerUrl: null, + destinationExplorerUrl: null, + }, + sourceSwaps: [], + nexusIntentUrl: null, + depositTxHash: null, + actualGasFeeUsd: null, + error: null, + lastResult: null, + navigationDirection: null, + simulation: null, + simulationLoading: false, + receiveAmount: null, + skipSwap: false, + intentReady: false, + swapSkippedData: null, +}); + +/** + * State reducer for deposit widget + */ +function depositReducer(state: DepositState, action: DepositAction): DepositState { + switch (action.type) { + case "setStep": + return { + ...state, + step: action.payload.step, + navigationDirection: action.payload.direction, + }; + case "setInputs": { + const newInputs = { ...state.inputs, ...action.payload }; + let newStatus = state.status; + if ( + state.status === "idle" && + newInputs.amount && + Number.parseFloat(newInputs.amount) > 0 + ) { + newStatus = "previewing"; + } + if ( + state.status === "previewing" && + (!newInputs.amount || Number.parseFloat(newInputs.amount) <= 0) + ) { + newStatus = "idle"; + } + // Clear error when user changes inputs + return { ...state, inputs: newInputs, status: newStatus, error: null }; + } + case "setStatus": + return { ...state, status: action.payload }; + case "setExplorerUrls": + return { + ...state, + explorerUrls: { ...state.explorerUrls, ...action.payload }, + }; + case "setError": + return { ...state, error: action.payload }; + case "setLastResult": + return { ...state, lastResult: action.payload }; + case "setSimulation": + return { + ...state, + simulation: action.payload, + }; + case "setSimulationLoading": + return { ...state, simulationLoading: action.payload }; + case "setReceiveAmount": + return { ...state, receiveAmount: action.payload }; + case "setSkipSwap": + return { ...state, skipSwap: action.payload }; + case "setIntentReady": + return { ...state, intentReady: action.payload }; + case "setSwapSkippedData": + return { ...state, swapSkippedData: action.payload }; + case "addSourceSwap": + return { ...state, sourceSwaps: [...state.sourceSwaps, action.payload] }; + case "setNexusIntentUrl": + return { ...state, nexusIntentUrl: action.payload }; + case "setDepositTxHash": + return { ...state, depositTxHash: action.payload }; + case "setActualGasFeeUsd": + return { ...state, actualGasFeeUsd: action.payload }; + case "reset": + return createInitialState(); + default: + return state; + } +} + +/** + * Hook for managing deposit widget state via reducer + */ +export function useDepositState() { + const [state, dispatch] = useReducer( + depositReducer, + undefined, + createInitialState + ); + + return { state, dispatch }; +} diff --git a/apps/megaeth/src/components/deposit/hooks/use-deposit-widget.ts b/apps/megaeth/src/components/deposit/hooks/use-deposit-widget.ts new file mode 100644 index 0000000..a4af65b --- /dev/null +++ b/apps/megaeth/src/components/deposit/hooks/use-deposit-widget.ts @@ -0,0 +1,643 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + WidgetStep, + DepositWidgetContextValue, + DepositInputs, + DestinationConfig, +} from "../types"; +import { + NEXUS_EVENTS, + CHAIN_METADATA, + type SwapStepType, + type ExecuteParams, + type SwapAndExecuteParams, + type SwapAndExecuteResult, + parseUnits, +} from "@avail-project/nexus-core"; +import { + SWAP_EXPECTED_STEPS, + useNexusError, + usePolling, + useStopwatch, + useTransactionSteps, +} from "../../common"; +import { type Address, type Hex, formatEther } from "viem"; +import { useAccount } from "wagmi"; +import { useNexus } from "../../nexus/NexusProvider"; +import { SIMULATION_POLL_INTERVAL_MS } from "../constants/widget"; + +// Import extracted hooks +import { + useDepositState, + STEP_HISTORY, + type SwapSkippedData, +} from "./use-deposit-state"; +import { useAssetSelection } from "./use-asset-selection"; +import { useDepositComputed } from "./use-deposit-computed"; + +interface UseDepositProps { + executeDeposit: ( + tokenSymbol: string, + tokenAddress: `0x${string}`, + amount: bigint, + chainId: number, + user: Address, + ) => Omit; + destination: DestinationConfig; + onSuccess?: () => void; + onError?: (error: string) => void; +} + +/** + * Main deposit widget hook that orchestrates state, SDK integration, + * and computed values via smaller focused hooks. + */ +export function useDepositWidget( + props: UseDepositProps, +): DepositWidgetContextValue { + const { executeDeposit, destination, onSuccess, onError } = props; + + // External dependencies + const { + nexusSDK, + swapIntent, + swapBalance, + fetchSwapBalance, + getFiatValue, + exchangeRate, + resolveTokenUsdRate, + } = useNexus(); + const { address } = useAccount(); + const handleNexusError = useNexusError(); + + // Core state management + const { state, dispatch } = useDepositState(); + const [pollingEnabled, setPollingEnabled] = useState(false); + + // Asset selection state + const { assetSelection, setAssetSelection, resetAssetSelection } = + useAssetSelection(swapBalance); + + // Refs for tracking + const hasAutoSelected = useRef(false); + const initialSimulationDone = useRef(false); + const determiningSwapComplete = useRef(false); + const lastSimulationTime = useRef(0); + + const denyActiveSwapIntent = useCallback(() => { + try { + swapIntent.current?.deny(); + } catch (error) { + console.error("Failed to deny active swap intent", error); + } finally { + swapIntent.current = null; + } + }, [swapIntent]); + + // Transaction steps tracking + const { + seed, + onStepComplete, + reset: resetSteps, + steps, + } = useTransactionSteps(); + + // Stopwatch for timing + const stopwatch = useStopwatch({ + running: + state.status === "executing" || + (state.status === "previewing" && determiningSwapComplete.current), + intervalMs: 100, + }); + + // Derived state + const isProcessing = state.status === "executing"; + const isSuccess = state.status === "success"; + const isError = state.status === "error"; + const activeIntent = state.simulation?.swapIntent ?? swapIntent.current; + + // Computed values + const { + availableAssets, + totalSelectedBalance, + totalBalance, + confirmationDetails, + feeBreakdown, + } = useDepositComputed({ + swapBalance, + assetSelection, + activeIntent, + destination, + inputAmount: state.inputs.amount, + exchangeRate, + getFiatValue, + actualGasFeeUsd: state.actualGasFeeUsd, + swapSkippedData: state.swapSkippedData, + skipSwap: state.skipSwap, + nexusSDK, + }); + + // Action callbacks + const setInputs = useCallback( + (next: Partial) => { + dispatch({ type: "setInputs", payload: next }); + }, + [dispatch], + ); + + const setTxError = useCallback( + (error: string | null) => { + dispatch({ type: "setError", payload: error }); + }, + [dispatch], + ); + + /** + * Start the swap and execute flow with the SDK + */ + const start = useCallback( + (inputs: SwapAndExecuteParams) => { + if (!nexusSDK || !inputs || isProcessing) return; + + seed(SWAP_EXPECTED_STEPS); + + // Build source list from selected assets + const fromSources: Array<{ tokenAddress: Hex; chainId: number }> = []; + assetSelection.selectedChainIds.forEach((key) => { + const lastDashIndex = key.lastIndexOf("-"); + const tokenAddress = key.substring(0, lastDashIndex) as Hex; + const chainId = parseInt(key.substring(lastDashIndex + 1), 10); + fromSources.push({ tokenAddress, chainId }); + }); + + const inputsWithSources = { + ...inputs, + fromSources: fromSources.length > 0 ? fromSources : undefined, + }; + + nexusSDK + .swapAndExecute(inputsWithSources, { + onEvent: (event) => { + if (event.name === NEXUS_EVENTS.SWAP_STEP_COMPLETE) { + const step = event.args as SwapStepType & { + completed?: boolean; + data?: SwapSkippedData; + }; + + // Handle SWAP_SKIPPED - go directly to transaction-status + if (step?.type === "SWAP_SKIPPED") { + dispatch({ type: "setSkipSwap", payload: true }); + dispatch({ + type: "setSwapSkippedData", + payload: step.data ?? null, + }); + dispatch({ type: "setStatus", payload: "executing" }); + dispatch({ + type: "setStep", + payload: { step: "transaction-status", direction: "forward" }, + }); + stopwatch.start(); + } + + if (step?.type === "DETERMINING_SWAP" && step?.completed) { + determiningSwapComplete.current = true; + stopwatch.start(); + dispatch({ type: "setIntentReady", payload: true }); + } + onStepComplete(step); + } + }, + }) + .then((data: SwapAndExecuteResult) => { + // Extract source swaps from the result + const sourceSwapsFromResult = data.swapResult?.sourceSwaps ?? []; + sourceSwapsFromResult.forEach((sourceSwap) => { + const chainMeta = + CHAIN_METADATA[sourceSwap.chainId as keyof typeof CHAIN_METADATA]; + const baseUrl = chainMeta?.blockExplorerUrls?.[0] ?? ""; + const explorerUrl = baseUrl + ? `${baseUrl}/tx/${sourceSwap.txHash}` + : ""; + dispatch({ + type: "addSourceSwap", + payload: { + chainId: sourceSwap.chainId, + chainName: chainMeta?.name ?? `Chain ${sourceSwap.chainId}`, + explorerUrl, + }, + }); + }); + + // Set explorer URLs from the result + if (sourceSwapsFromResult.length > 0) { + const firstSourceSwap = sourceSwapsFromResult[0]; + const chainMeta = + CHAIN_METADATA[ + firstSourceSwap.chainId as keyof typeof CHAIN_METADATA + ]; + const baseUrl = chainMeta?.blockExplorerUrls?.[0] ?? ""; + const sourceExplorerUrl = baseUrl + ? `${baseUrl}/tx/${firstSourceSwap.txHash}` + : ""; + dispatch({ + type: "setExplorerUrls", + payload: { sourceExplorerUrl }, + }); + } + + // Destination explorer URL + const destChainMeta = + CHAIN_METADATA[destination.chainId as keyof typeof CHAIN_METADATA]; + const destBaseUrl = destChainMeta?.blockExplorerUrls?.[0] ?? ""; + const destinationExplorerUrl = + data.swapResult?.explorerURL ?? + (data.executeResponse?.txHash && destBaseUrl + ? `${destBaseUrl}/tx/${data.executeResponse.txHash}` + : null); + + if (destinationExplorerUrl) { + dispatch({ + type: "setExplorerUrls", + payload: { destinationExplorerUrl }, + }); + } + + // Store Nexus intent URL and deposit tx hash + dispatch({ + type: "setNexusIntentUrl", + payload: data.swapResult?.explorerURL ?? null, + }); + dispatch({ + type: "setDepositTxHash", + payload: data.executeResponse?.txHash ?? null, + }); + + // Calculate actual gas fee from receipt + const receipt = data.executeResponse?.receipt; + if (receipt?.gasUsed && receipt?.effectiveGasPrice) { + const gasUsed = BigInt(receipt.gasUsed); + const effectiveGasPrice = BigInt(receipt.effectiveGasPrice); + const gasCostWei = gasUsed * effectiveGasPrice; + const gasCostNative = parseFloat(formatEther(gasCostWei)); + const gasTokenSymbol = destination.gasTokenSymbol ?? "ETH"; + const gasCostUsd = getFiatValue(gasCostNative, gasTokenSymbol); + dispatch({ + type: "setActualGasFeeUsd", + payload: gasCostUsd, + }); + } + + dispatch({ + type: "setReceiveAmount", + payload: swapIntent.current?.intent?.destination?.amount ?? "", + }); + onSuccess?.(); + dispatch({ type: "setStatus", payload: "success" }); + dispatch({ + type: "setStep", + payload: { step: "transaction-complete", direction: "forward" }, + }); + }) + .catch((error) => { + const { message } = handleNexusError(error); + dispatch({ type: "setError", payload: message }); + dispatch({ type: "setStatus", payload: "error" }); + + if (initialSimulationDone.current) { + dispatch({ + type: "setStep", + payload: { step: "transaction-failed", direction: "forward" }, + }); + } else { + dispatch({ + type: "setStep", + payload: { step: "amount", direction: "backward" }, + }); + } + onError?.(message); + }) + .finally(async () => { + await fetchSwapBalance(); + }); + }, + [ + nexusSDK, + isProcessing, + seed, + onStepComplete, + swapIntent, + onSuccess, + onError, + handleNexusError, + assetSelection.selectedChainIds, + destination, + getFiatValue, + fetchSwapBalance, + dispatch, + stopwatch, + ], + ); + + /** + * Handle amount input continue - starts simulation + */ + const beginAmountSimulation = useCallback( + async (totalAmountUsd: number) => { + if (!nexusSDK) { + dispatch({ type: "setError", payload: "Nexus SDK is not initialized." }); + dispatch({ type: "setStatus", payload: "error" }); + return false; + } + if (!address) { + dispatch({ type: "setError", payload: "Connect your wallet to continue." }); + dispatch({ type: "setStatus", payload: "error" }); + return false; + } + const destinationRate = await resolveTokenUsdRate(destination.tokenSymbol); + if ( + !destinationRate || + !Number.isFinite(destinationRate) || + destinationRate <= 0 + ) { + dispatch({ + type: "setError", + payload: `Unable to fetch pricing for ${destination.tokenSymbol}. Please try again.`, + }); + dispatch({ type: "setStatus", payload: "error" }); + return false; + } + + // Reset state and refs for a fresh simulation + dispatch({ type: "setError", payload: null }); + dispatch({ type: "setIntentReady", payload: false }); + initialSimulationDone.current = false; + determiningSwapComplete.current = false; + denyActiveSwapIntent(); + + const tokenAmount = totalAmountUsd / destinationRate; + const tokenAmountStr = tokenAmount.toFixed(destination.tokenDecimals); + const parsed = parseUnits(tokenAmountStr, destination.tokenDecimals); + + const executeParams = executeDeposit( + destination.tokenSymbol, + destination.tokenAddress, + parsed, + destination.chainId, + address, + ); + + const newInputs: SwapAndExecuteParams = { + toChainId: destination.chainId, + toTokenAddress: destination.tokenAddress, + toAmount: parsed, + execute: { + to: executeParams.to, + value: executeParams.value, + data: executeParams.data, + gasPrice: executeParams.gasPrice, + tokenApproval: executeParams.tokenApproval as { + token: `0x${string}`; + amount: bigint; + spender: Hex; + }, + gas: BigInt(400_000), + }, + }; + + dispatch({ + type: "setInputs", + payload: { amount: totalAmountUsd.toString() }, + }); + dispatch({ type: "setStatus", payload: "simulation-loading" }); + dispatch({ type: "setSimulationLoading", payload: true }); + start(newInputs); + return true; + }, + [ + nexusSDK, + address, + resolveTokenUsdRate, + destination, + executeDeposit, + start, + denyActiveSwapIntent, + dispatch, + ], + ); + + const handleAmountContinue = useCallback( + (totalAmountUsd: number) => { + void beginAmountSimulation(totalAmountUsd); + }, + [beginAmountSimulation], + ); + + /** + * Handle order confirmation - allow intent to execute + */ + const handleConfirmOrder = useCallback(() => { + if (!activeIntent) return; + dispatch({ type: "setStatus", payload: "executing" }); + dispatch({ + type: "setStep", + payload: { step: "transaction-status", direction: "forward" }, + }); + activeIntent.allow(); + }, [activeIntent, dispatch]); + + /** + * Navigate to a specific step + */ + const goToStep = useCallback( + (newStep: WidgetStep) => { + if (state.step === "amount" && newStep === "confirmation") { + const amount = state.inputs.amount; + if (amount) { + const totalAmountUsd = parseFloat(amount.replace(/,/g, "")); + if (totalAmountUsd > 0) { + void (async () => { + const started = await beginAmountSimulation(totalAmountUsd); + if (!started) return; + dispatch({ + type: "setStep", + payload: { step: newStep, direction: "forward" }, + }); + })(); + return; + } + } + } + dispatch({ + type: "setStep", + payload: { step: newStep, direction: "forward" }, + }); + }, + [state.step, state.inputs.amount, beginAmountSimulation, dispatch], + ); + + /** + * Navigate back to previous step + */ + const goBack = useCallback(async () => { + const previousStep = STEP_HISTORY[state.step]; + if (previousStep) { + dispatch({ type: "setError", payload: null }); + dispatch({ + type: "setStep", + payload: { step: previousStep, direction: "backward" }, + }); + denyActiveSwapIntent(); + initialSimulationDone.current = false; + lastSimulationTime.current = 0; + setPollingEnabled(false); + stopwatch.stop(); + stopwatch.reset(); + await fetchSwapBalance(); + } + }, [state.step, stopwatch, dispatch, denyActiveSwapIntent, fetchSwapBalance]); + + /** + * Reset widget to initial state + */ + const reset = useCallback(async () => { + dispatch({ type: "reset" }); + resetAssetSelection(); + resetSteps(); + denyActiveSwapIntent(); + initialSimulationDone.current = false; + lastSimulationTime.current = 0; + setPollingEnabled(false); + stopwatch.stop(); + stopwatch.reset(); + await fetchSwapBalance(); + }, [ + resetSteps, + stopwatch, + dispatch, + resetAssetSelection, + denyActiveSwapIntent, + fetchSwapBalance, + ]); + + /** + * Refresh simulation data + */ + const refreshSimulation = useCallback(async () => { + const timeSinceLastSimulation = Date.now() - lastSimulationTime.current; + if (timeSinceLastSimulation < 5000) { + return; + } + + try { + dispatch({ type: "setSimulationLoading", payload: true }); + const updated = await swapIntent.current?.refresh(); + if (updated) { + swapIntent.current!.intent = updated; + dispatch({ + type: "setSimulation", + payload: { + swapIntent: swapIntent.current!, + }, + }); + } + } catch (e) { + console.error(e); + } finally { + dispatch({ type: "setSimulationLoading", payload: false }); + stopwatch.reset(); + lastSimulationTime.current = Date.now(); + } + }, [stopwatch, swapIntent, dispatch]); + + const startTransaction = useCallback(() => { + if (isProcessing) return; + dispatch({ type: "setError", payload: null }); + }, [isProcessing, dispatch]); + + // Effect: Handle swap intent when it arrives + useEffect(() => { + if (!state.intentReady || initialSimulationDone.current) { + return; + } + + if (!swapIntent.current) { + return; + } + + initialSimulationDone.current = true; + dispatch({ + type: "setSimulation", + payload: { swapIntent: swapIntent.current! }, + }); + dispatch({ type: "setSimulationLoading", payload: false }); + dispatch({ type: "setStatus", payload: "previewing" }); + lastSimulationTime.current = Date.now(); + setPollingEnabled(true); + }, [state.intentReady, swapIntent, dispatch]); + + // Effect: Fetch swap balance on mount + useEffect(() => { + if (!nexusSDK) return; + + if (!swapBalance) { + void fetchSwapBalance(); + return; + } + + if (!hasAutoSelected.current && availableAssets.length > 0) { + hasAutoSelected.current = true; + } + }, [nexusSDK, swapBalance, availableAssets, fetchSwapBalance]); + + // Polling for simulation refresh + usePolling( + pollingEnabled && + state.status === "previewing" && + Boolean(swapIntent.current) && + !state.simulationLoading, + async () => { + await refreshSimulation(); + }, + SIMULATION_POLL_INTERVAL_MS, + ); + + // Return the full context value + return { + step: state.step, + inputs: state.inputs, + setInputs, + status: state.status, + explorerUrls: state.explorerUrls, + sourceSwaps: state.sourceSwaps, + nexusIntentUrl: state.nexusIntentUrl, + depositTxHash: state.depositTxHash, + destination, + isProcessing, + isSuccess, + isError, + txError: state.error, + setTxError, + goToStep, + goBack, + reset, + navigationDirection: state.navigationDirection, + startTransaction, + lastResult: state.lastResult, + assetSelection, + setAssetSelection, + swapBalance, + activeIntent, + confirmationDetails, + feeBreakdown, + steps, + timer: stopwatch.seconds, + handleConfirmOrder, + handleAmountContinue, + totalSelectedBalance, + skipSwap: state.skipSwap, + simulationLoading: state.simulationLoading, + totalBalance, + }; +} diff --git a/apps/megaeth/src/components/deposit/nexus-deposit.tsx b/apps/megaeth/src/components/deposit/nexus-deposit.tsx new file mode 100644 index 0000000..d669d77 --- /dev/null +++ b/apps/megaeth/src/components/deposit/nexus-deposit.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { cn } from "./utils"; +import { useDepositWidget } from "./hooks/use-deposit-widget"; +import { + AmountContainer, + ConfirmationContainer, + TransactionStatusContainer, + TransactionCompleteContainer, + TransactionFailedContainer, + AssetSelectionContainer, +} from "./components"; +import type { + WidgetStep, + DepositWidgetProps, + NavigationDirection, +} from "./types"; +import { Card } from "../ui/card"; +import { Dialog, DialogContent, DialogTrigger } from "../ui/dialog"; +import { Button } from "../ui/button"; +import { WidgetErrorBoundary } from "../common"; + +const ANIMATION_CLASSES: Record, string> = { + forward: "animate-slide-in-from-right", + backward: "animate-slide-in-from-left", +}; + +const getAnimationClass = (direction: NavigationDirection): string => + direction ? ANIMATION_CLASSES[direction] : ""; + +type ScreenRenderer = ( + widget: ReturnType, + heading?: string, + onClose?: () => void, +) => React.ReactNode; + +const SCREENS: Record = { + amount: (widget, heading, onClose) => ( + + ), + confirmation: (widget, heading, onClose) => ( + + ), + "transaction-status": (widget, heading, onClose) => ( + + ), + "transaction-complete": (widget, heading, onClose) => ( + + ), + "transaction-failed": (widget, heading, onClose) => ( + + ), + "asset-selection": (widget, heading, onClose) => ( + + ), +}; + +const NexusDeposit = ({ + heading = "Deposit USDC", + embed = false, + className, + onClose, + onSuccess, + onError, + executeDeposit, + destination, + open: controlledOpen, + onOpenChange, + defaultOpen = false, + children, +}: DepositWidgetProps & { children?: React.ReactNode }) => { + const widget = useDepositWidget({ + executeDeposit, + destination, + onSuccess, + onError, + }); + const [internalOpen, setInternalOpen] = useState(defaultOpen); + + // Use controlled or uncontrolled open state + const isControlled = controlledOpen !== undefined; + const isOpen = isControlled ? controlledOpen : internalOpen; + const resetWidget = widget.reset; + const shouldPreventDialogDismiss = widget.isProcessing; + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open && shouldPreventDialogDismiss) { + return; + } + if (!isControlled) { + setInternalOpen(open); + } + onOpenChange?.(open); + if (!open) { + onClose?.(); + resetWidget(); + } + }, + [ + isControlled, + onOpenChange, + onClose, + shouldPreventDialogDismiss, + resetWidget, + ], + ); + + const handleClose = useCallback(() => { + handleOpenChange(false); + }, [handleOpenChange]); + + const animationClass = getAnimationClass(widget.navigationDirection); + + // Embed mode: render as inline Card + if (embed) { + return ( + + +
+ {SCREENS[widget.step](widget, heading)} +
+
+
+ ); + } + + return ( + + + {children || ( + + )} + + + +
+ {SCREENS[widget.step](widget, heading, handleClose)} +
+
+
+
+ ); +}; + +export default NexusDeposit; + +// Re-export types and hooks for consumers +export type { + WidgetStep, + DepositWidgetContextValue, + DepositWidgetProps, + BaseDepositWidgetProps, + DestinationConfig, + ExecuteDepositParams, + ExecuteDepositResult, + UseDepositWidgetProps, + TransactionStatus, + AssetFilterType, + DepositInputs, + AssetSelectionState, +} from "./types"; +export { useDepositWidget } from "./hooks/use-deposit-widget"; diff --git a/apps/megaeth/src/components/deposit/types.ts b/apps/megaeth/src/components/deposit/types.ts new file mode 100644 index 0000000..d116331 --- /dev/null +++ b/apps/megaeth/src/components/deposit/types.ts @@ -0,0 +1,230 @@ +import type { + SUPPORTED_CHAINS_IDS, + ExecuteParams, + OnSwapIntentHookData, + SwapStepType, + UserAsset, +} from "@avail-project/nexus-core"; +import type { Address } from "viem"; + +export type WidgetStep = + | "amount" + | "confirmation" + | "transaction-status" + | "transaction-complete" + | "transaction-failed" + | "asset-selection"; + +export type TransactionStatus = + | "idle" + | "previewing" + | "simulation-loading" + | "executing" + | "success" + | "error"; + +export type NavigationDirection = "forward" | "backward" | null; + +export type AssetFilterType = "all" | "stablecoins" | "native" | "custom"; + +export type TokenCategory = "stablecoin" | "native" | "memecoin"; + +export interface ChainItem { + id: string; + tokenAddress: `0x${string}`; + chainId: number; + name: string; + usdValue: number; + amount: number; +} + +export interface Token { + id: string; + symbol: string; + decimals: number; + chainsLabel: string; + usdValue: string; + amount: string; + logo: string; + category: TokenCategory; + chains: ChainItem[]; +} + +export interface DepositInputs { + amount?: string; + selectedToken: string; + toChainId?: number; + toTokenAddress?: `0x${string}`; + toAmount?: bigint; +} + +export interface AssetSelectionState { + selectedChainIds: Set; + filter: AssetFilterType; + expandedTokens: Set; +} + +export interface DestinationConfig { + chainId: SUPPORTED_CHAINS_IDS; + depositTargetLogo?: string; + tokenAddress: `0x${string}`; + tokenSymbol: string; + tokenDecimals: number; + tokenLogo?: string; + label?: string; + estimatedTime?: string; + gasTokenSymbol?: string; + explorerUrl?: string; +} + +export interface ExecuteDepositParams { + tokenSymbol: string; + tokenAddress: string; + amount: bigint; + chainId: number; + user: Address; +} + +export type ExecuteDepositResult = Omit; + +export interface UseDepositWidgetProps { + executeDeposit: ( + tokenSymbol: string, + tokenAddress: `0x${string}`, + amount: bigint, + chainId: number, + user: Address, + ) => Omit; + destination: DestinationConfig; + onSuccess?: () => void; + onError?: (error: string) => void; +} + +export interface DepositWidgetContextValue { + // Core state + step: WidgetStep; + inputs: DepositInputs; + status: TransactionStatus; + + // Input management + setInputs: (inputs: Partial) => void; + + // Explorer URLs + explorerUrls: { + sourceExplorerUrl: string | null; + destinationExplorerUrl: string | null; + }; + + // Source swap transactions (from BRIDGE_DEPOSIT events) + sourceSwaps: Array<{ + chainId: number; + chainName: string; + explorerUrl: string; + }>; + + // Transaction result data + nexusIntentUrl: string | null; + depositTxHash: string | null; + + // Destination config (for building explorer URLs) + destination: DestinationConfig; + + // Derived state + isProcessing: boolean; + isSuccess: boolean; + isError: boolean; + + // Error handling + txError: string | null; + setTxError: (error: string | null) => void; + + // Navigation + goToStep: (step: WidgetStep) => void; + goBack: () => void; + reset: () => void; + navigationDirection: NavigationDirection; + + // Transaction actions + startTransaction: () => void; + + // Results + lastResult: unknown; + + // Asset selection + assetSelection: AssetSelectionState; + setAssetSelection: (selection: Partial) => void; + + // SDK integration + swapBalance: UserAsset[] | null; + activeIntent: OnSwapIntentHookData | null; + confirmationDetails: { + sourceLabel: string; + sources: Array< + | { + chainId: number; + tokenAddress: `0x${string}`; + decimals: number; + symbol: string; + balance: string; + balanceInFiat?: number; + tokenLogo?: string; + chainLogo?: string; + chainName?: string; + isDestinationBalance?: boolean; + } + | undefined + >; + gasTokenSymbol?: string; + estimatedTime?: string; + amountSpent: number; + totalFeeUsd: number; + receiveTokenSymbol: string; + receiveAmountAfterSwap: string; + receiveAmountAfterSwapUsd: number; + receiveTokenLogo?: string; + receiveTokenChain: number; + destinationChainName?: string; + } | null; + feeBreakdown: { + totalGasFee: number; + gasUsd: number; + gasFormatted: string; + }; + steps: Array<{ + id: number; + completed: boolean; + step: SwapStepType; + }>; + timer: number; + handleConfirmOrder: () => void; + handleAmountContinue: (totalAmountUsd: number) => void; + totalSelectedBalance: number; + skipSwap: boolean; + simulationLoading: boolean; + totalBalance: + | { + balance: number; + usdBalance: number; + } + | undefined; +} + +export interface BaseDepositWidgetProps { + onSuccess?: () => void; + onError?: (error: string) => void; +} + +export interface DepositWidgetProps + extends UseDepositWidgetProps, + BaseDepositWidgetProps { + heading?: string; + embed?: boolean; + className?: string; + onClose?: () => void; + /** Control the dialog open state (non-embed mode only) */ + open?: boolean; + /** Callback when dialog open state changes (non-embed mode only) */ + onOpenChange?: (open: boolean) => void; + /** Default open state for uncontrolled usage (non-embed mode only) */ + defaultOpen?: boolean; +} diff --git a/apps/megaeth/src/components/deposit/utils.ts b/apps/megaeth/src/components/deposit/utils.ts new file mode 100644 index 0000000..13adb69 --- /dev/null +++ b/apps/megaeth/src/components/deposit/utils.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +/** + * Parse currency input by removing $ and commas, keeping only numbers and decimal + */ +export function parseCurrencyInput(input: string): string { + return input.replace(/[^0-9.]/g, ""); +} \ No newline at end of file diff --git a/apps/megaeth/src/components/deposit/utils/asset-helpers.ts b/apps/megaeth/src/components/deposit/utils/asset-helpers.ts new file mode 100644 index 0000000..291f88a --- /dev/null +++ b/apps/megaeth/src/components/deposit/utils/asset-helpers.ts @@ -0,0 +1,105 @@ +import type { Token, AssetFilterType } from "../types"; +import { STABLECOIN_SYMBOLS } from "../constants/widget"; +import { CHAIN_METADATA } from "@avail-project/nexus-core"; + +export function isStablecoin(symbol: string): boolean { + return STABLECOIN_SYMBOLS.includes( + symbol as (typeof STABLECOIN_SYMBOLS)[number], + ); +} + +export function isNative(symbol: string): boolean { + return Object.values(CHAIN_METADATA).some( + (chain) => chain.nativeCurrency.symbol === symbol, + ); +} + +/** + * Get checkbox state for a token based on selected chains + */ +export function getTokenCheckState( + token: Token, + selectedChainIds: Set, +): boolean | "indeterminate" { + const selectedChainCount = token.chains.filter((c) => + selectedChainIds.has(c.id), + ).length; + + if (selectedChainCount === 0) return false; + if (selectedChainCount === token.chains.length) return true; + return "indeterminate"; +} + +/** + * Check if current selection matches a preset filter + * Returns the matching filter type or "custom" + */ +export function checkIfMatchesPreset( + tokens: Token[], + selectedChainIds: Set, +): AssetFilterType { + if (selectedChainIds.size === 0) return "custom"; + + const allIds = new Set(); + const stableIds = new Set(); + const nativeIds = new Set(); + + tokens.forEach((token) => { + token.chains.forEach((chain) => { + allIds.add(chain.id); + if (isStablecoin(token.symbol)) { + stableIds.add(chain.id); + } + if (isNative(token.symbol)) { + nativeIds.add(chain.id); + } + }); + }); + + const setsEqual = (a: Set, b: Set) => + a.size === b.size && [...a].every((id) => b.has(id)); + + if (setsEqual(selectedChainIds, allIds)) return "all"; + if (setsEqual(selectedChainIds, stableIds)) return "stablecoins"; + if (setsEqual(selectedChainIds, nativeIds)) return "native"; + return "custom"; +} + +/** + * Get chain IDs for a preset filter + */ +export function getChainIdsForFilter( + tokens: Token[], + filter: "all" | "stablecoins" | "native", +): Set { + const ids = new Set(); + tokens.forEach((token) => { + const shouldInclude = + filter === "all" || + (filter === "stablecoins" && isStablecoin(token.symbol)) || + (filter === "native" && isNative(token.symbol)); + + if (shouldInclude) { + token.chains.forEach((chain) => ids.add(chain.id)); + } + }); + return ids; +} + +/** + * Calculate total USD value for selected chain IDs + */ +export function calculateSelectedAmount( + tokens: Token[], + selectedChainIds: Set, +): number { + let total = 0; + tokens.forEach((token) => { + token.chains.forEach((chain) => { + if (selectedChainIds.has(chain.id)) { + total += chain.usdValue; + } + }); + }); + return total; +} diff --git a/apps/megaeth/src/components/deposit/utils/index.ts b/apps/megaeth/src/components/deposit/utils/index.ts new file mode 100644 index 0000000..6c7f66e --- /dev/null +++ b/apps/megaeth/src/components/deposit/utils/index.ts @@ -0,0 +1 @@ +export { getTokenCheckState, checkIfMatchesPreset, getChainIdsForFilter, calculateSelectedAmount, isStablecoin, isNative } from "./asset-helpers"; diff --git a/apps/megaeth/src/components/nexus/NexusProvider.tsx b/apps/megaeth/src/components/nexus/NexusProvider.tsx index 4c24873..32a3502 100644 --- a/apps/megaeth/src/components/nexus/NexusProvider.tsx +++ b/apps/megaeth/src/components/nexus/NexusProvider.tsx @@ -16,13 +16,20 @@ import { type RefObject, useCallback, useContext, - useEffect, useMemo, useRef, useState, } from "react"; import { useAccountEffect } from "wagmi"; -import { TOKEN_IMAGES } from "../common"; +import { + DEFAULT_USD_PEGGED_TOKEN_SYMBOLS, + USD_PEGGED_FALLBACK_RATE, + buildUsdPeggedSymbolSet, + fetchCoinbaseUsdRate, + getCoinbaseSymbolCandidates, + normalizeTokenSymbol, + toFinitePositiveNumber, +} from "../common/utils/token-pricing"; interface NexusContextType { nexusSDK: NexusSDK | null; @@ -40,11 +47,10 @@ interface NexusContextType { fetchBridgableBalance: () => Promise; fetchSwapBalance: () => Promise; getFiatValue: (amount: number, token: string) => number; + resolveTokenUsdRate: (tokenSymbol: string) => Promise; initializeNexus: (provider: EthereumProvider) => Promise; deinitializeNexus: () => Promise; attachEventHooks: () => void; - setIntent: (data: OnIntentHookData | null) => void; - setAllowance: (data: OnAllowanceHookData | null) => void; } const NexusContext = createContext(undefined); @@ -68,7 +74,7 @@ const NexusProvider = ({ }: NexusProviderProps) => { const stableConfig = useMemo( () => ({ ...defaultConfig, ...config }), - [config], + [config] ); const sdkRef = useRef(null); @@ -79,56 +85,124 @@ const NexusProvider = ({ const [nexusSDK, setNexusSDK] = useState(null); const [loading, setLoading] = useState(false); - const [supportedChainsAndTokens, setSupportedChainsAndTokens] = - useState( - sdk.utils - .getSupportedChains(stableConfig.network === "testnet" ? 0 : undefined) - .map((chain) => ({ - ...chain, - tokens: chain.tokens.map((token) => ({ - ...token, - logo: - token.symbol.toUpperCase() === "USDM" - ? TOKEN_IMAGES.USDM - : token.logo, - })), - })) ?? null, - ); - const [swapSupportedChainsAndTokens, setSwapSupportedChainsAndTokens] = - useState( - sdk.utils.getSwapSupportedChainsAndTokens() ?? null, - ); + const supportedChainsAndTokens = + useRef(null); + const swapSupportedChainsAndTokens = useRef( + null + ); const [bridgableBalance, setBridgableBalance] = useState( - null, + null ); const [swapBalance, setSwapBalance] = useState(null); + const [exchangeRateState, setExchangeRateState] = useState< + Record | null + >(null); const exchangeRate = useRef | null>(null); + const coinbaseUsdRateCache = useRef>({}); + const coinbaseUsdRateRequests = useRef>>( + {}, + ); + const usdPeggedSymbols = useRef>( + new Set(DEFAULT_USD_PEGGED_TOKEN_SYMBOLS), + ); const intent = useRef(null); const allowance = useRef(null); const swapIntent = useRef(null); - useEffect(() => { - const list = sdk.utils.getSupportedChains( - stableConfig.network === "testnet" ? 0 : undefined, + const cacheUsdRate = useCallback((tokenSymbol: string, usdRate: number) => { + const normalized = normalizeTokenSymbol(tokenSymbol); + const rate = toFinitePositiveNumber(usdRate); + if (!normalized || !rate) return; + + coinbaseUsdRateCache.current[normalized] = rate; + const currentRates = exchangeRate.current ?? {}; + if (currentRates[normalized] === rate) return; + + const nextRates = { + ...currentRates, + [normalized]: rate, + }; + exchangeRate.current = nextRates; + setExchangeRateState(nextRates); + }, []); + + const resolveTokenUsdRate = useCallback(async (tokenSymbol: string) => { + const normalizedSymbol = normalizeTokenSymbol(tokenSymbol); + if (!normalizedSymbol) return null; + + const sdkRate = toFinitePositiveNumber( + exchangeRate.current?.[normalizedSymbol], ); - setSupportedChainsAndTokens(list ?? null); - const swapList = sdk.utils.getSwapSupportedChainsAndTokens(); - setSwapSupportedChainsAndTokens(swapList ?? null); - }, [sdk, stableConfig.network]); + if (sdkRate) { + return sdkRate; + } + + const cachedRate = toFinitePositiveNumber( + coinbaseUsdRateCache.current[normalizedSymbol], + ); + if (cachedRate) { + return cachedRate; + } + + const inFlightRequest = coinbaseUsdRateRequests.current[normalizedSymbol]; + if (inFlightRequest) { + return inFlightRequest; + } + + const requestPromise = (async (): Promise => { + for (const candidate of getCoinbaseSymbolCandidates(normalizedSymbol)) { + const sdkCandidateRate = toFinitePositiveNumber( + exchangeRate.current?.[candidate], + ); + if (sdkCandidateRate) { + cacheUsdRate(normalizedSymbol, sdkCandidateRate); + return sdkCandidateRate; + } + + const cachedCandidateRate = toFinitePositiveNumber( + coinbaseUsdRateCache.current[candidate], + ); + if (cachedCandidateRate) { + cacheUsdRate(normalizedSymbol, cachedCandidateRate); + return cachedCandidateRate; + } + } + + const coinbaseRate = await fetchCoinbaseUsdRate(normalizedSymbol); + if (coinbaseRate) { + cacheUsdRate(normalizedSymbol, coinbaseRate); + return coinbaseRate; + } + + if (usdPeggedSymbols.current.has(normalizedSymbol)) { + cacheUsdRate(normalizedSymbol, USD_PEGGED_FALLBACK_RATE); + return USD_PEGGED_FALLBACK_RATE; + } + + return null; + })(); + + coinbaseUsdRateRequests.current[normalizedSymbol] = requestPromise; + try { + return await requestPromise; + } finally { + delete coinbaseUsdRateRequests.current[normalizedSymbol]; + } + }, [cacheUsdRate]); const setupNexus = useCallback(async () => { const list = sdk.utils.getSupportedChains( - stableConfig.network === "testnet" ? 0 : undefined, + config?.network === "testnet" ? 0 : undefined ); - setSupportedChainsAndTokens(list ?? null); + supportedChainsAndTokens.current = list ?? null; + usdPeggedSymbols.current = buildUsdPeggedSymbolSet(list ?? null); const swapList = sdk.utils.getSwapSupportedChainsAndTokens(); - setSwapSupportedChainsAndTokens(swapList ?? null); + swapSupportedChainsAndTokens.current = swapList ?? null; const [bridgeAbleBalanceResult, rates] = await Promise.allSettled([ sdk.getBalancesForBridge(), sdk.utils.getCoinbaseRates(), ]); - console.log("bridgeAbleBalanceResult", bridgeAbleBalanceResult); if (bridgeAbleBalanceResult.status === "fulfilled") { setBridgableBalance(bridgeAbleBalanceResult.value); @@ -142,34 +216,46 @@ const NexusProvider = ({ for (const [symbol, value] of Object.entries(rates.value)) { const unitsPerUsd = Number.parseFloat(String(value)); if (Number.isFinite(unitsPerUsd) && unitsPerUsd > 0) { - usdPerUnit[symbol.toUpperCase()] = 1 / unitsPerUsd; + usdPerUnit[normalizeTokenSymbol(symbol)] = 1 / unitsPerUsd; } } exchangeRate.current = usdPerUnit; + setExchangeRateState(usdPerUnit); } - }, [sdk, stableConfig.network]); - - const initializeNexus = async (provider: EthereumProvider) => { - setLoading(true); - try { - if (sdk.isInitialized()) throw new Error("Nexus is already initialized"); - await sdk.initialize(provider); - setNexusSDK(sdk); - } catch (error) { - console.error("Error initializing Nexus:", error); - } finally { - setLoading(false); - } - }; + }, [sdk, config?.network]); + + const initializeNexus = useCallback( + async (provider: EthereumProvider) => { + setLoading(true); + try { + if (!sdk.isInitialized()) { + await sdk.initialize(provider); + } + setNexusSDK(sdk); + } catch (error) { + console.error("Error initializing Nexus:", error); + throw error; + } finally { + setLoading(false); + } + }, + [sdk], + ); - const deinitializeNexus = async () => { + const deinitializeNexus = useCallback(async () => { try { - if (!nexusSDK) return; - await nexusSDK.deinit(); + if (!nexusSDK) throw new Error("Nexus is not initialized"); + await nexusSDK?.deinit(); setNexusSDK(null); + supportedChainsAndTokens.current = null; + swapSupportedChainsAndTokens.current = null; setBridgableBalance(null); setSwapBalance(null); exchangeRate.current = null; + setExchangeRateState(null); + coinbaseUsdRateCache.current = {}; + coinbaseUsdRateRequests.current = {}; + usdPeggedSymbols.current = new Set(DEFAULT_USD_PEGGED_TOKEN_SYMBOLS); intent.current = null; swapIntent.current = null; allowance.current = null; @@ -177,9 +263,9 @@ const NexusProvider = ({ } catch (error) { console.error("Error deinitializing Nexus:", error); } - }; + }, [nexusSDK]); - const attachEventHooks = () => { + const attachEventHooks = useCallback(() => { sdk.setOnAllowanceHook((data: OnAllowanceHookData) => { /** * Useful when you want the user to select, min, max or a custom value @@ -211,62 +297,55 @@ const NexusProvider = ({ */ swapIntent.current = data; }); - }; - - const handleInit = async (provider: EthereumProvider) => { - console.log("[NexusProvider] handleInit called"); - console.log("[NexusProvider] SDK isInitialized:", sdk.isInitialized()); - console.log("[NexusProvider] Loading:", loading); - - if (sdk.isInitialized() || loading) { - console.log( - "[NexusProvider] Skipping init - already initialized or loading", - ); - return; - } - - if (!provider || typeof provider.request !== "function") { - console.error("[NexusProvider] Invalid provider:", provider); - throw new Error("Invalid EIP-1193 provider"); - } - - console.log("[NexusProvider] Calling initializeNexus..."); - await initializeNexus(provider); - - console.log("[NexusProvider] Calling setupNexus..."); - await setupNexus(); - - console.log("[NexusProvider] Calling attachEventHooks..."); - attachEventHooks(); + }, [sdk]); - console.log("[NexusProvider] handleInit complete!"); - }; + const handleInit = useCallback( + async (provider: EthereumProvider) => { + if (sdk.isInitialized() || loading) { + return; + } + if (!provider || typeof provider.request !== "function") { + throw new Error("Invalid EIP-1193 provider"); + } + try { + await initializeNexus(provider); + if (!sdk.isInitialized()) return; + await setupNexus(); + attachEventHooks(); + } catch (error) { + console.error("Error during Nexus setup flow:", error); + throw error; + } + }, + [sdk, loading, initializeNexus, setupNexus, attachEventHooks], + ); - const fetchBridgableBalance = async () => { + const fetchBridgableBalance = useCallback(async () => { try { const updatedBalance = await sdk.getBalancesForBridge(); - console.log("bridgeAbleBalanceResult", updatedBalance); setBridgableBalance(updatedBalance); } catch (error) { console.error("Error fetching bridgable balance:", error); } - }; + }, [sdk]); - const fetchSwapBalance = async () => { + const fetchSwapBalance = useCallback(async () => { try { const updatedBalance = await sdk.getBalancesForSwap(); - console.log("swapBalance", updatedBalance); setSwapBalance(updatedBalance); } catch (error) { console.error("Error fetching swap balance:", error); } - }; - - function getFiatValue(amount: number, token: string) { - const key = token.toUpperCase(); - const rate = exchangeRate.current?.[key] ?? 1; + }, [sdk]); + + const getFiatValue = useCallback((amount: number, token: string) => { + const key = normalizeTokenSymbol(token); + const rate = + toFinitePositiveNumber(exchangeRate.current?.[key]) ?? + toFinitePositiveNumber(coinbaseUsdRateCache.current[key]) ?? + (usdPeggedSymbols.current.has(key) ? USD_PEGGED_FALLBACK_RATE : 0); return rate * amount; - } + }, []); useAccountEffect({ onDisconnect() { @@ -274,14 +353,6 @@ const NexusProvider = ({ }, }); - const setIntent = (data: OnIntentHookData | null) => { - intent.current = data; - }; - - const setAllowance = (data: OnAllowanceHookData | null) => { - allowance.current = data; - }; - const value = useMemo( () => ({ nexusSDK, @@ -291,8 +362,8 @@ const NexusProvider = ({ intent, allowance, handleInit, - supportedChainsAndTokens, - swapSupportedChainsAndTokens, + supportedChainsAndTokens: supportedChainsAndTokens.current, + swapSupportedChainsAndTokens: swapSupportedChainsAndTokens.current, bridgableBalance, swapBalance: swapBalance, network: config?.network, @@ -300,10 +371,9 @@ const NexusProvider = ({ fetchBridgableBalance, fetchSwapBalance, swapIntent, - exchangeRate: exchangeRate.current, + exchangeRate: exchangeRateState, getFiatValue, - setIntent, - setAllowance, + resolveTokenUsdRate, }), [ nexusSDK, @@ -311,16 +381,16 @@ const NexusProvider = ({ deinitializeNexus, attachEventHooks, handleInit, + bridgableBalance, swapBalance, config, loading, fetchBridgableBalance, fetchSwapBalance, - setIntent, - setAllowance, - supportedChainsAndTokens, - swapSupportedChainsAndTokens, - ], + exchangeRateState, + getFiatValue, + resolveTokenUsdRate, + ] ); return ( {children} diff --git a/apps/megaeth/src/components/opportunities/OpportunityCard.tsx b/apps/megaeth/src/components/opportunities/OpportunityCard.tsx new file mode 100644 index 0000000..6bad928 --- /dev/null +++ b/apps/megaeth/src/components/opportunities/OpportunityCard.tsx @@ -0,0 +1,272 @@ +"use client"; + +import type { Opportunity } from "@/lib/types/opportunity"; +import { ArrowRight } from "lucide-react"; +import config from "../../../config"; +import NexusDeposit from "../deposit/nexus-deposit"; +import { SUPPORTED_CHAINS } from "@avail-project/nexus-core"; +import { encodeFunctionData, maxUint256, formatUnits } from "viem"; +import { useAccount, useReadContract } from "wagmi"; +import { WithdrawModal } from "./WithdrawModal"; +import { useModal } from "connectkit"; +import { useState } from "react"; + +interface OpportunityCardProps { + opportunity: Opportunity; + onClick?: (opportunity: Opportunity) => void; +} + +export function OpportunityCard({ + opportunity, + onClick, +}: OpportunityCardProps) { + const { title, description, tags, apy, proceedText, token } = opportunity; + const protocol = tags?.[0] || "DeFi"; + const chain = config.chainName; + const { isConnected, address } = useAccount(); + const { setOpen: setConnectModalOpen } = useModal(); + const [open, setOpen] = useState(false); + + const withdrawConfig = opportunity.withdraw; + + const withdrawContractAddress = + withdrawConfig?.withdrawalAmount?.to?.startsWith("0x") + ? (withdrawConfig.withdrawalAmount.to as `0x${string}`) + : (`0x${withdrawConfig?.withdrawalAmount?.to}` as `0x${string}`); + + const { data: balanceData, refetch: refetchBalance } = useReadContract({ + chainId: SUPPORTED_CHAINS.MEGAETH, + address: withdrawContractAddress, + abi: withdrawConfig?.withdrawalAmount?.abi as any, + functionName: withdrawConfig?.withdrawalAmount?.functionName || "balanceOf", + args: withdrawConfig?.withdrawalAmount?.params?.map((p) => + p === "$user" ? address : p, + ) as any[], + query: { + enabled: !!address && !!withdrawConfig, + }, + }); + + const { data: decimalsData } = useReadContract({ + chainId: SUPPORTED_CHAINS.MEGAETH, + address: withdrawContractAddress, + abi: [ + { + inputs: [], + name: "decimals", + outputs: [{ internalType: "uint8", name: "", type: "uint8" }], + stateMutability: "view", + type: "function", + }, + ], + functionName: "decimals", + query: { + enabled: !!address && !!withdrawConfig, + }, + }); + + const decimals = + decimalsData !== undefined + ? Number(decimalsData) + : opportunity.token.decimals; + const balance = balanceData !== undefined ? BigInt(balanceData as any) : 0n; + + console.log(`Withdrawal Amount for ${opportunity.title}`, { + balance, + decimals, + rawBalance: balanceData, + }); + + // Generate gradient based on primary color + const primaryColor = config.primaryColor; + const t = opportunity.logic.logics[0].postBridge!.transaction!; + const approval = opportunity.logic.logics[0].postBridge!.approval!; + + return ( +
onClick?.(opportunity)} + > + {/* Gradient Header */} +
+ {opportunity.banner && ( +
+ {opportunity.title} +
+ )} +
+ + {/* Content */} +
+ {/* Tags */} +
+ {/* APY Badge */} + {apy && ( + + {apy} APY* + + )} + {tags?.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ + {/* Title */} +

+ {token.icon && ( + {token.symbol} + )} + {title} +

+ + {/* Description */} +

{description}

+ + {/* Footer */} +
+ {/* Protocol & Chain */} +
+
+ + {protocol} • {chain} + +
+ {balance > 0n && ( + + Withdrawable: {formatUnits(balance, decimals)}{" "} + {opportunity.token.symbol} + + )} +
+ + {/* CTA Button */} +
+ {balance > 0n && ( + refetchBalance()} + /> + )} + {!isConnected ? ( + + ) : ( + refetchBalance()} + executeDeposit={( + symbol, + address, + amount, + chainId, + userAddress, + ) => { + console.log(symbol, address, amount, chainId, userAddress); + const args = t.params!.map((p) => { + switch (p) { + case "$user": + return userAddress; + case "$amount": { + return amount; + } + default: + return p; + } + }); + + const data = encodeFunctionData({ + abi: t.abi!, + functionName: t.functionName!, + args, + }); + return { + to: t.to as `0x${string}`, + data, + gas: 1_500_000n, // 1.5 million units + // set static gas limit to avoid issues and high gas fees + gasPrice: "medium", + tokenApproval: { + token: token.address, + amount: approval.amount === "input" ? amount : maxUint256, + spender: approval.spender as `0x${string}`, + }, + }; + }} + > + + + )} +
+
+
+
+ ); +} diff --git a/apps/megaeth/src/components/opportunities/OpportunityList.tsx b/apps/megaeth/src/components/opportunities/OpportunityList.tsx new file mode 100644 index 0000000..c5bd371 --- /dev/null +++ b/apps/megaeth/src/components/opportunities/OpportunityList.tsx @@ -0,0 +1,54 @@ +"use client"; + +import type { Opportunity } from "@/lib/types/opportunity"; +import { OpportunityCard } from "./OpportunityCard"; + +interface OpportunityListProps { + opportunities: Opportunity[]; + onOpportunityClick?: (opportunity: Opportunity) => void; +} + +export function OpportunityList({ + opportunities, + onOpportunityClick, +}: OpportunityListProps) { + if (opportunities.length === 0) { + return ( +
+
+ + + +
+

+ No opportunities available +

+

+ Check back later for new DeFi opportunities +

+
+ ); + } + + return ( +
+ {opportunities.map((opportunity) => ( + + ))} +
+ ); +} diff --git a/apps/megaeth/src/components/opportunities/WithdrawModal.tsx b/apps/megaeth/src/components/opportunities/WithdrawModal.tsx new file mode 100644 index 0000000..be54396 --- /dev/null +++ b/apps/megaeth/src/components/opportunities/WithdrawModal.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { formatUnits, parseUnits } from "viem"; +import { + useWriteContract, + useWaitForTransactionReceipt, + useAccount, + useSwitchChain, +} from "wagmi"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Loader2 } from "lucide-react"; +import type { Opportunity } from "@/lib/types/opportunity"; + +interface WithdrawModalProps { + opportunity: Opportunity; + balance: bigint; + decimals: number; + userAddress: string; + primaryColor: string; + chainId: number; + onSuccess?: () => void; +} + +export function WithdrawModal({ + opportunity, + balance, + decimals, + userAddress, + primaryColor, + chainId, + onSuccess, +}: WithdrawModalProps) { + const [open, setOpen] = useState(false); + const [amount, setAmount] = useState(""); + + const withdrawLogic = + opportunity.withdraw?.logics?.[0]?.preBridge?.transaction; + + const { chainId: currentChainId } = useAccount(); + const { switchChainAsync } = useSwitchChain(); + const { writeContract, isPending, data: hash, error } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ + hash, + }); + + useEffect(() => { + if (isSuccess && onSuccess) { + onSuccess(); + } + }, [isSuccess, onSuccess]); + + console.log("Withdraw Modal Balance Data:", { + balance, + formatUnits: formatUnits(balance, decimals), + }); + + const handleWithdraw = async () => { + console.log("Withdraw Initiate:", { withdrawLogic, amount, decimals }); + if (!withdrawLogic) return; + if (!amount || isNaN(Number(amount))) return; + + const parsedAmount = parseUnits(amount, decimals); + if (parsedAmount === 0n) return; + + const args = withdrawLogic.params?.map((p) => { + switch (p) { + case "$user": + return userAddress; + case "$amount": + return parsedAmount; + default: + return p; + } + }); + + const txPayload = { + chainId, + address: withdrawLogic.to.startsWith("0x") + ? (withdrawLogic.to as `0x${string}`) + : (`0x${withdrawLogic.to}` as `0x${string}`), + abi: withdrawLogic.abi as any, + functionName: withdrawLogic.functionName as string, + args, + }; + + console.log("Submitting Write Contract Payload:", txPayload); + + try { + if (currentChainId !== chainId) { + console.log(`Switching chain from ${currentChainId} to ${chainId}...`); + await switchChainAsync({ chainId }); + } + + writeContract(txPayload, { + onError: (err) => { + console.error("writeContract failed:", err); + }, + onSuccess: (txHash) => { + console.log("Transaction successfully sent! Hash:", txHash); + }, + }); + } catch (err: any) { + console.error("Chain switch failed:", err); + } + }; + + return ( +
e.stopPropagation()}> + + + + + + + Withdraw {opportunity.token.symbol} + +
+
+
+ Amount + + Balance: {formatUnits(balance, decimals)}{" "} + {opportunity.token.symbol} + +
+
+ setAmount(e.target.value)} + className="pr-16" + /> + +
+
+ + {error && ( +
+ {error.message || "An error occurred during transaction"} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/megaeth/src/components/ui/animated-spinner.tsx b/apps/megaeth/src/components/ui/animated-spinner.tsx new file mode 100644 index 0000000..dc5afc5 --- /dev/null +++ b/apps/megaeth/src/components/ui/animated-spinner.tsx @@ -0,0 +1,93 @@ +"use client"; + +export function AnimatedSpinner({ className = "" }: { className?: string }) { + return ( +
+ + {/* Top spike */} + + + {/* Top-right spike */} + + + {/* Right spike */} + + + {/* Bottom-right spike */} + + + {/* Bottom spike */} + + + {/* Bottom-left spike */} + + + {/* Left spike */} + + + {/* Top-left spike */} + + + + +
+ ); +} diff --git a/apps/megaeth/src/components/ui/animated-tabs.tsx b/apps/megaeth/src/components/ui/animated-tabs.tsx new file mode 100644 index 0000000..abb0d6c --- /dev/null +++ b/apps/megaeth/src/components/ui/animated-tabs.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { motion, LayoutGroup } from "motion/react"; +import { cn } from "@/lib/utils"; +import config from "../../../config"; + +interface Tab { + id: string; + label: string; +} + +interface AnimatedTabsProps { + tabs: Tab[]; + activeTab: string; + onTabChange: (tabId: string) => void; + className?: string; +} + +export function AnimatedTabs({ + tabs, + activeTab, + onTabChange, + className, +}: AnimatedTabsProps) { + const [selectedTab, setSelectedTab] = useState(activeTab); + + // Sync with external activeTab when it changes (e.g., browser back/forward) + useEffect(() => { + setSelectedTab(activeTab); + }, [activeTab]); + + const handleTabClick = (tabId: string) => { + setSelectedTab(tabId); + // Delay navigation slightly to allow animation to start smoothly + requestAnimationFrame(() => { + onTabChange(tabId); + }); + }; + + return ( + +
+ {tabs.map((tab) => { + const isActive = selectedTab === tab.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/megaeth/src/components/ui/checkbox.tsx b/apps/megaeth/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..cb0b07b --- /dev/null +++ b/apps/megaeth/src/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { CheckIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/apps/megaeth/src/components/ui/dialog.tsx b/apps/megaeth/src/components/ui/dialog.tsx index d9ccec9..11cb859 100644 --- a/apps/megaeth/src/components/ui/dialog.tsx +++ b/apps/megaeth/src/components/ui/dialog.tsx @@ -50,9 +50,13 @@ function DialogContent({ className, children, showCloseButton = true, + dismissible = true, + onInteractOutside, + onEscapeKeyDown, ...props }: React.ComponentProps & { showCloseButton?: boolean + dismissible?: boolean }) { return ( @@ -63,10 +67,22 @@ function DialogContent({ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", className )} + onInteractOutside={(event) => { + if (!dismissible) { + event.preventDefault() + } + onInteractOutside?.(event) + }} + onEscapeKeyDown={(event) => { + if (!dismissible) { + event.preventDefault() + } + onEscapeKeyDown?.(event) + }} {...props} > {children} - {showCloseButton && ( + {showCloseButton && dismissible && ( ) { + return ( + + ); +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/megaeth/src/components/view-history/view-history.tsx b/apps/megaeth/src/components/view-history/view-history.tsx index b58dd82..96be3fe 100644 --- a/apps/megaeth/src/components/view-history/view-history.tsx +++ b/apps/megaeth/src/components/view-history/view-history.tsx @@ -16,7 +16,7 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import useViewHistory from "./hooks/useViewHistory"; -import { TOKEN_IMAGES } from "../common"; +import { TOKEN_IMAGES } from "../deposit/constants/assets"; const SourceChains = ({ sources }: { sources: RFF["sources"] }) => { return ( diff --git a/apps/megaeth/src/lib/opportunities-data.ts b/apps/megaeth/src/lib/opportunities-data.ts new file mode 100644 index 0000000..5aad016 --- /dev/null +++ b/apps/megaeth/src/lib/opportunities-data.ts @@ -0,0 +1,134 @@ +import type { Opportunity } from "./types/opportunity"; + +// Sample opportunities data - this would typically come from an API +// Note: APY values are indicative and may change. These are third-party platforms. +export const sampleOpportunities: Opportunity[] = [ + { + id: "aave-usdm-pool", + logo: "https://files.availproject.org/fastbridge/megaeth/aave-favicon.svg", + tags: ["Aave", "USDM"], + title: "Deposit USDM on MegaETH to Aave", + description: + "Deposit USDM on MegaETH and earn upto 0.01% APY on the deposited USDM. Access Aave directly, deposit once, earn continuously.", + proceedText: "Deposit", + apy: "0.01%", + requiresCA: true, + features: [], + display: [], + label: "on Aave (MegaETH)", + banner: "https://files.availproject.org/fastbridge/megaeth/aave-logo.svg", + token: { + icon: "https://mega.etherscan.io/token/images/usdm_32.png", + symbol: "USDM", + decimals: 18, + address: "0xFAfDdbb3FC7688494971a79cc65DCa3EF82079E7", + }, + logic: { + logics: [ + { + postBridge: { + universe: "evm", + approval: { + tokenAddress: "0xFAfDdbb3FC7688494971a79cc65DCa3EF82079E7", + spender: "0x7e324AbC5De01d112AfC03a584966ff199741C28", + amount: "input", + }, + transaction: { + to: "0x7e324AbC5De01d112AfC03a584966ff199741C28", + abi: [ + { + inputs: [ + { internalType: "address", name: "asset", type: "address" }, + { + internalType: "uint256", + name: "amount", + type: "uint256", + }, + { + internalType: "address", + name: "onBehalfOf", + type: "address", + }, + { + internalType: "uint16", + name: "referralCode", + type: "uint16", + }, + ], + name: "supply", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + ], + functionName: "supply", + params: [ + "0xFAfDdbb3FC7688494971a79cc65DCa3EF82079E7", + "$amount", + "$user", + "0", + ], + paramsTypes: ["string", "bigint", "string", "number"], + }, + }, + } + ], + }, + withdraw: { + withdrawalAmount: { + abi: [ + { + inputs: [ + { internalType: "address", name: "user", type: "address" }, + ], + name: "balanceOf", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + ], + to: "0x5dF82810CB4B8f3e0Da3c031cCc9208ee9cF9500", + functionName: "balanceOf", + params: ["$user"], + paramsTypes: ["string"], + returnTypes: ["bigint"], + }, + logics: [ + { + preBridge: { + universe: "evm", + transaction: { + to: "0x7e324AbC5De01d112AfC03a584966ff199741C28", + abi: [ + { + inputs: [ + { internalType: "address", name: "asset", type: "address" }, + { + internalType: "uint256", + name: "amount", + type: "uint256", + }, + { internalType: "address", name: "to", type: "address" }, + ], + name: "withdraw", + outputs: [ + { internalType: "uint256", name: "", type: "uint256" }, + ], + stateMutability: "nonpayable", + type: "function", + }, + ], + functionName: "withdraw", + params: [ + "0x5dF82810CB4B8f3e0Da3c031cCc9208ee9cF9500", + "$amount", + "$user", + ], + paramsTypes: ["string", "bigint", "string"], + }, + }, + }, + ], + } + }, +]; diff --git a/apps/megaeth/src/lib/types/opportunity.ts b/apps/megaeth/src/lib/types/opportunity.ts new file mode 100644 index 0000000..7db398b --- /dev/null +++ b/apps/megaeth/src/lib/types/opportunity.ts @@ -0,0 +1,191 @@ +// Types for DeFi opportunities + +type OppImage = { + type: "image"; + url: string; + dimensions?: { + width?: number; + height?: number; + }; + alt?: string; +}; + +type OppVideo = { + type: "video"; + url: string; + dimensions?: { + width?: number; + height?: number; + aspectRatio?: number; + }; + format: "video/mp4" | "video/webm"; + autoplay?: boolean; + thumbnailUrl?: string; +}; + +type OppChart = { + type: "chart"; + data: { + labels: string[]; + data: number[]; + }; +}; + +type OppDisplay = OppImage | OppVideo | OppChart; + +type OpportunityTextInput = { + type: "text"; + id: string; + label: string; + validation?: { + pattern?: string; + minLength?: string; + maxLength?: string; + }[]; +}; + +type OpportunityNumberInput = { + type: "number"; + label: string; + range?: boolean; + max?: "unifiedBalance" | number; + suffix?: { + icon: string; + text: string; + }; +}; + +type OpportunityInput = OpportunityTextInput | OpportunityNumberInput; + +type CommonOps = { + universe: "evm"; + approval?: { + tokenAddress?: string; + spender: string; + amount: "max" | "input"; + maxConfirmations?: number; + }; + transaction?: { + contract?: string; + to: string; + abi?: any[]; + functionName?: string; + params?: string[]; + paramsTypes: ( + | "string" + | "bigint" + | "float" + | "integer" + | "number" + | "boolean" + | "tuple" + )[]; + maxConfirmations?: number; + }; + signature?: { + data: any; + domain?: any; + types?: any; + api?: { + method: "GET" | "POST" | "PUT" | "WS"; + url: string; + dataStructure: any; + }; + request: + | "sign" + | "signMessage" + | "signTransaction" + | "personal_sign" + | "eth_signTypedDataV4"; + }; + api?: { + method: "GET" | "POST" | "PUT" | "WS"; + url: string; + dataStructure: any; + }; +}; + +type OppLogic = { + preBridge?: CommonOps; + postBridge?: CommonOps; +}; + +interface OpportunityLogic { + // token: { + // symbol: string; + // icon: string; + // decimals: number; + // address: string; + // chain: { + // universe: "evm"; + // id: number | string; + // network: string; + // }; + // }; + logics: OppLogic[]; +} + +interface Opportunity { + id: string; + logo?: string; + tags?: string[]; + title: string; + description: string; + banner?: string; + apy?: string; // e.g., "7.82%" + features: { + key: string; + value: string; + }[]; + display: OppDisplay[]; + requiresCA: boolean; + proceedText: string; + token: { + icon: string; + symbol: string; + decimals: number; + address: string; + }; + logic: OpportunityLogic; + label: string; + withdraw?: { + withdrawalAmount: { + abi?: any[]; + functionName?: string; + params?: string[]; + paramsTypes?: ( + | "string" + | "bigint" + | "float" + | "integer" + | "number" + | "boolean" + | "tuple" + )[]; + returnTypes?: ( + | "string" + | "bigint" + | "float" + | "integer" + | "number" + | "boolean" + | "tuple" + )[]; + isNative?: boolean; + to: string; + }; + logics: OppLogic[]; + }; +} + +export type { + Opportunity, + OpportunityLogic, + CommonOps, + OppDisplay, + OppImage, + OppVideo, + OppChart, + OppLogic, + OpportunityInput, +}; diff --git a/apps/megaeth/src/lib/types/position.ts b/apps/megaeth/src/lib/types/position.ts new file mode 100644 index 0000000..31153af --- /dev/null +++ b/apps/megaeth/src/lib/types/position.ts @@ -0,0 +1,27 @@ +// Types for user positions (invested opportunities) + +export interface PositionInterestRate { + rate: string; + period: string; // e.g., "7 Days", "30 Days", "360 Days" +} + +export interface Position { + id: string; + opportunityId: string; + protocol: string; + chain: string; + token: { + symbol: string; + icon: string; + decimals: number; + }; + currentValue: string; + totalDeposits: string; + depositedUsd?: string; + currentValueUsd?: string; + returnRate: string; // e.g., "8.6%" + returnType: string; // e.g., "XIRR", "APY", "APR" + interestRates?: PositionInterestRate[]; + category: "lending" | "staking" | "borrowing" | "all"; + createdAt?: string; +} diff --git a/apps/megaeth/src/pages/Opportunities.tsx b/apps/megaeth/src/pages/Opportunities.tsx new file mode 100644 index 0000000..35b8cf3 --- /dev/null +++ b/apps/megaeth/src/pages/Opportunities.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { OpportunityList } from "../components/opportunities/OpportunityList"; +import { sampleOpportunities } from "@/lib/opportunities-data"; + +export default function Opportunities() { + return ( +
+ {/* Header */} +
+

+ DeFi Opportunities +

+

+ Explore yield-generating opportunities across multiple chains. Deposit + once, earn continuously. +

+

+ ⚠️ These are third-party platforms. APY rates are indicative and may + change based on market conditions. +

+
+ + {/* Opportunities List */} + + + {/* Powered by footer */} +
+

+ Powered by +

+ + Avail Logo + +
+
+ ); +} diff --git a/apps/monad/components.json b/apps/monad/components.json index edcaef2..4571173 100644 --- a/apps/monad/components.json +++ b/apps/monad/components.json @@ -18,5 +18,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "registries": {} + "registries": { + "@nexus-elements": "https://elements.nexus.availproject.org/r/{name}.json" + } } diff --git a/apps/monad/package.json b/apps/monad/package.json index 5ec2a3a..0c5845b 100644 --- a/apps/monad/package.json +++ b/apps/monad/package.json @@ -11,12 +11,16 @@ "preview": "vite preview --port 5173" }, "dependencies": { - "@avail-project/nexus-core": "github:availproject/nexus-sdk#cca99c1a570a8598334e3e89453d39a27d70ede7", + "@avail-project/nexus-core": "github:availproject/nexus-sdk#014065adde74b2c8d635f52486a475dfd150204f", "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@rainbow-me/rainbowkit": "^2.2.8", "@tailwindcss/vite": "^4.1.16", @@ -26,17 +30,20 @@ "clsx": "^2.1.1", "connectkit": "^1.9.1", "decimal.js": "^10.6.0", + "framer-motion": "^12.34.1", "lucide-react": "^0.544.0", + "next": "^16.1.6", "next-themes": "^0.4.6", "posthog-js": "^1.336.4", "react": "19.2.2", "react-dom": "19.2.2", + "react-router-dom": "^7.13.0", "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1", + "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.13", - "viem": "^2.37.9", + "viem": "^2.46.2", "vite-plugin-node-polyfills": "^0.24.0", - "wagmi": "^2.17.5" + "wagmi": "^2.19.5" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/apps/monad/src/App.tsx b/apps/monad/src/App.tsx index 6e18e78..0b2f806 100644 --- a/apps/monad/src/App.tsx +++ b/apps/monad/src/App.tsx @@ -1,56 +1,97 @@ +import { + BrowserRouter, + Routes, + Route, + useNavigate, + useLocation, +} from "react-router-dom"; import Web3Provider from "@/providers/web3Provider"; import { Toaster } from "@/components/ui/sonner"; import Navbar from "@/components/navbar"; -import HeroSection from "@/components/hero-section"; -import FastBridgeShowcase from "@/components/fast-bridge-showcase"; +import Home from "@/components/fast-bridge/fast-bridge"; +import Opportunities from "@/pages/Opportunities"; import NexusProvider from "@/components/nexus/NexusProvider"; +import { NexusInitializer } from "@/components/NexusInitializer"; import config from "../config"; +import { SUPPORTED_CHAINS, type NexusNetwork } from "@avail-project/nexus-core"; +import { AnimatedTabs } from "@/components/ui/animated-tabs"; +import { useAccount } from "wagmi"; -// @ts-expect-error - Environment is not exported from @avail-project/nexus-core -enum Environment { - FOLLY, // Dev with test-net tokens - CERISE, // Dev with main-net tokens - CORAL, // Test-net with main-net tokens - JADE, // Main-net with main-net tokens -} +function AppContent() { + const navigate = useNavigate(); + const location = useLocation(); + const { address } = useAccount(); + + const tabs = [ + { id: "fastbridge", label: "Fast Bridge" }, + { id: "opportunities", label: "Opportunities" }, + ]; + + const activeTab = location.pathname.startsWith("/opportunities") + ? "opportunities" + : "fastbridge"; + + const handleTabChange = (id: string) => { + if (id === "fastbridge") navigate("/"); + else if (id === "opportunities") navigate("/opportunities"); + }; -export default function App() { return ( - - -
- -
-
+ +
+
+
+
+ -
- - -
-
- - - + + + + } + /> + } /> + + +
+
+ ); +} + +export default function App() { + return ( + + + + + + + + + + ); } diff --git a/apps/monad/src/components/NexusInitializer.tsx b/apps/monad/src/components/NexusInitializer.tsx new file mode 100644 index 0000000..fe64245 --- /dev/null +++ b/apps/monad/src/components/NexusInitializer.tsx @@ -0,0 +1,102 @@ +"use client"; +import * as React from "react"; +import type { EthereumProvider } from "@avail-project/nexus-core"; +import { useAccount } from "wagmi"; +import { useNexus } from "./nexus/NexusProvider"; +import { toast } from "sonner"; + +/** + * NexusInitializer - Lives at App level to initialize Nexus once. + * This component handles Nexus SDK initialization/deinitialization + * based on wallet connection status. Since it's mounted at the App level, + * it won't re-initialize when routes change. + */ +export function NexusInitializer({ children }: { children: React.ReactNode }) { + const [loading, setLoading] = React.useState(false); + const [initError, setInitError] = React.useState(null); + const { status, connector, address } = useAccount(); + const { nexusSDK, handleInit, deinitializeNexus } = useNexus(); + const prevAddressRef = React.useRef(undefined); + const initializingRef = React.useRef(false); + + const initializeNexus = React.useCallback(async () => { + // Prevent multiple simultaneous initialization attempts + if (loading || nexusSDK || initializingRef.current) return; + + initializingRef.current = true; + setLoading(true); + setInitError(null); + + try { + const provider = (await connector?.getProvider()) as EthereumProvider; + if (!provider) { + throw new Error("No provider available"); + } + + await handleInit(provider); + } catch (error) { + console.error("Nexus initialization failed:", error); + const errorMessage = (error as Error)?.message || "Unknown error"; + setInitError(errorMessage); + toast.error(`Failed to initialize Nexus: ${errorMessage}`); + } finally { + setLoading(false); + initializingRef.current = false; + } + }, [connector, handleInit, loading, nexusSDK]); + + // Handle wallet disconnection - clear Nexus state + React.useEffect(() => { + if (status === "disconnected" && nexusSDK) { + deinitializeNexus(); + prevAddressRef.current = undefined; + location.reload(); + } // reload page to free the resources + }, [status, nexusSDK, deinitializeNexus]); + + // Handle account change - reinitialize Nexus when account address changes + React.useEffect(() => { + if ( + status === "connected" && + address && + address !== prevAddressRef.current + ) { + const previousAddress = prevAddressRef.current; + prevAddressRef.current = address; + + // If account changed and Nexus is initialized, reinitialize with new account + if (nexusSDK && previousAddress !== undefined) { + deinitializeNexus().then(() => { + setTimeout(() => { + if ( + address === prevAddressRef.current && + !initializingRef.current + ) { + initializeNexus(); + } + }, 100); + }); + } + } else if (status === "connected" && address && !prevAddressRef.current) { + prevAddressRef.current = address; + } + }, [status, nexusSDK, address, initializeNexus, deinitializeNexus]); + + // Auto-initialize Nexus when wallet is connected (first time) + React.useEffect(() => { + if ( + status === "connected" && + !nexusSDK && + !loading && + !initError && + address && + !initializingRef.current + ) { + initializeNexus(); + } + }, [status, nexusSDK, initError, address, initializeNexus, loading]); + + // Expose initialization state via context or just render children + // The actual loading UI is handled by PreviewPanel + return <>{children}; +} diff --git a/apps/monad/src/components/common/components/ErrorBoundary.tsx b/apps/monad/src/components/common/components/ErrorBoundary.tsx new file mode 100644 index 0000000..9808b2e --- /dev/null +++ b/apps/monad/src/components/common/components/ErrorBoundary.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { Component, type ErrorInfo, type ReactNode } from "react"; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +/** + * Error boundary component that catches JavaScript errors in child components. + * Displays a fallback UI instead of crashing the entire widget. + * + * @example + * Something went wrong
} + * onError={(error) => console.error(error)} + * > + * + * + */ +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error("ErrorBoundary caught an error:", error, errorInfo); + this.props.onError?.(error, errorInfo); + } + + render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+ Something went wrong +
+

+ An unexpected error occurred. Please try again. +

+ +
+ ); + } + + return this.props.children; + } +} + +/** + * A more specific error boundary for widget containers with reset capability. + */ +interface WidgetErrorBoundaryProps extends ErrorBoundaryProps { + widgetName?: string; + onReset?: () => void; +} + +export class WidgetErrorBoundary extends Component< + WidgetErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: WidgetErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error( + `WidgetErrorBoundary [${this.props.widgetName ?? "Unknown"}]:`, + error, + errorInfo + ); + this.props.onError?.(error, errorInfo); + } + + handleReset = (): void => { + this.props.onReset?.(); + this.setState({ hasError: false, error: null }); + }; + + render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+ {this.props.widgetName + ? `${this.props.widgetName} encountered an error` + : "Widget error"} +
+

+ {this.state.error?.message || "An unexpected error occurred."} +

+ +
+ ); + } + + return this.props.children; + } +} diff --git a/apps/monad/src/components/common/hooks/useLatest.ts b/apps/monad/src/components/common/hooks/useLatest.ts new file mode 100644 index 0000000..4f30294 --- /dev/null +++ b/apps/monad/src/components/common/hooks/useLatest.ts @@ -0,0 +1,22 @@ +import { useRef, useLayoutEffect } from "react"; + +/** + * Returns a ref that always contains the latest value. + * Useful for accessing current values in callbacks without causing re-renders. + * + * @example + * const countRef = useLatest(count); + * const handleClick = useCallback(() => { + * console.log(countRef.current); // Always the latest count + * }, []); // No dependency needed! + */ +export function useLatest(value: T): React.MutableRefObject { + const ref = useRef(value); + + // Use useLayoutEffect to update synchronously before any effects run + useLayoutEffect(() => { + ref.current = value; + }); + + return ref; +} diff --git a/apps/monad/src/components/common/hooks/useNexusError.ts b/apps/monad/src/components/common/hooks/useNexusError.ts index b14851e..f28c3d9 100644 --- a/apps/monad/src/components/common/hooks/useNexusError.ts +++ b/apps/monad/src/components/common/hooks/useNexusError.ts @@ -1,23 +1,171 @@ -import { NexusError } from "@avail-project/nexus-core"; +import { ERROR_CODES, NexusError } from "@avail-project/nexus-core"; -function handler(err: unknown) { +const DEFAULT_ERROR_MESSAGE = "Oops! Something went wrong. Please try again."; +const USER_REJECTED_MESSAGE = "Transaction was rejected in your wallet."; +const EMPTY_ERROR_MESSAGE = + "Unable to determine transaction state. Please refresh and try again."; + +const ERROR_MESSAGE_BY_CODE: Partial> = { + [ERROR_CODES.INVALID_VALUES_ALLOWANCE_HOOK]: + "Invalid allowance selection. Please review allowance values and try again.", + [ERROR_CODES.SDK_NOT_INITIALIZED]: + "Nexus SDK is not initialized. Reconnect your wallet and try again.", + [ERROR_CODES.SDK_INIT_STATE_NOT_EXPECTED]: + "Nexus is still initializing. Please wait a few seconds and retry.", + [ERROR_CODES.CHAIN_NOT_FOUND]: + "Selected chain is not supported for this route.", + [ERROR_CODES.CHAIN_DATA_NOT_FOUND]: + "Chain metadata is unavailable for this route. Please try another chain.", + [ERROR_CODES.ASSET_NOT_FOUND]: + "Requested asset was not found in your balances.", + [ERROR_CODES.COSMOS_ERROR]: + "Cosmos-side operation failed. Please retry in a moment.", + [ERROR_CODES.TOKEN_NOT_SUPPORTED]: + "Selected token is not supported for this route.", + [ERROR_CODES.UNIVERSE_NOT_SUPPORTED]: + "Selected chain universe is not supported yet.", + [ERROR_CODES.ENVIRONMENT_NOT_SUPPORTED]: + "Selected environment is not supported yet.", + [ERROR_CODES.ENVIRONMENT_NOT_KNOWN]: + "Selected environment is not recognized.", + [ERROR_CODES.UNKNOWN_SIGNATURE]: + "Unsupported signature type for this transaction.", + [ERROR_CODES.TRON_DEPOSIT_FAIL]: + "TRON deposit transaction failed. Please retry.", + [ERROR_CODES.TRON_APPROVAL_FAIL]: + "TRON approval transaction failed. Please retry.", + [ERROR_CODES.LIQUIDITY_TIMEOUT]: + "Timed out waiting for liquidity. Please retry.", + [ERROR_CODES.USER_DENIED_INTENT]: USER_REJECTED_MESSAGE, + [ERROR_CODES.USER_DENIED_ALLOWANCE]: USER_REJECTED_MESSAGE, + [ERROR_CODES.USER_DENIED_INTENT_SIGNATURE]: USER_REJECTED_MESSAGE, + [ERROR_CODES.USER_DENIED_SIWE_SIGNATURE]: USER_REJECTED_MESSAGE, + [ERROR_CODES.INSUFFICIENT_BALANCE]: "Insufficient balance to proceed.", + [ERROR_CODES.WALLET_NOT_CONNECTED]: + "Wallet is not connected. Connect your wallet and try again.", + [ERROR_CODES.FETCH_GAS_PRICE_FAILED]: + "Unable to estimate gas right now. Please retry.", + [ERROR_CODES.SIMULATION_FAILED]: + "Simulation failed. Please review your inputs and try again.", + [ERROR_CODES.QUOTE_FAILED]: + "Unable to fetch a quote right now. Please retry.", + [ERROR_CODES.SWAP_FAILED]: "Swap execution failed. Please retry.", + [ERROR_CODES.VAULT_CONTRACT_NOT_FOUND]: + "Required vault contract is unavailable on this chain.", + [ERROR_CODES.SLIPPAGE_EXCEEDED_ALLOWANCE]: + "Slippage exceeded tolerance. Refresh quote and retry.", + [ERROR_CODES.RATES_CHANGED_BEYOND_TOLERANCE]: + "Rates changed beyond tolerance. Review and retry.", + [ERROR_CODES.RFF_FEE_EXPIRED]: + "Quote expired. Refresh and try again.", + [ERROR_CODES.INVALID_INPUT]: + "Some transaction inputs are invalid. Please review and try again.", + [ERROR_CODES.INVALID_ADDRESS_LENGTH]: + "Address format is invalid for the selected chain.", + [ERROR_CODES.NO_BALANCE_FOR_ADDRESS]: + "No balance found for this wallet on supported source chains.", + [ERROR_CODES.TRANSACTION_TIMEOUT]: + "Transaction is taking longer than expected. Check your wallet and explorer.", + [ERROR_CODES.TRANSACTION_REVERTED]: + "Transaction reverted on-chain. Please verify inputs and retry.", + [ERROR_CODES.DESTINATION_REQUEST_HASH_NOT_FOUND]: + "Could not finalize destination request. Please retry.", +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getErrorMessage(value: unknown): string | undefined { + if (!isRecord(value)) return undefined; + const message = value.message; + return typeof message === "string" ? message : undefined; +} + +function getErrorCode(value: unknown): string | number | undefined { + if (!isRecord(value)) return undefined; + const code = value.code; + if (typeof code === "string" || typeof code === "number") { + return code; + } + return undefined; +} + +function looksLikeUserRejection(err: unknown): boolean { if (err instanceof NexusError) { + return ( + err.code === ERROR_CODES.USER_DENIED_ALLOWANCE || + err.code === ERROR_CODES.USER_DENIED_INTENT || + err.code === ERROR_CODES.USER_DENIED_INTENT_SIGNATURE || + err.code === ERROR_CODES.USER_DENIED_SIWE_SIGNATURE + ); + } + + const code = getErrorCode(err); + if (code === 4001 || code === "ACTION_REJECTED") { + return true; + } + + const message = getErrorMessage(err)?.toLowerCase(); + if (!message) return false; + return ( + message.includes("user denied") || + message.includes("user rejected") || + message.includes("rejected request") || + message.includes("denied transaction signature") + ); +} + +function sanitizeMessage(message?: string): string { + if (!message) return DEFAULT_ERROR_MESSAGE; + const cleaned = message + .replace(/^Internal error:\s*/i, "") + .replace(/^COSMOS:\s*/i, "") + .trim(); + return cleaned || DEFAULT_ERROR_MESSAGE; +} + +function handler(err: unknown) { + if (err === null || err === undefined) { + console.error("Unexpected empty error from Nexus SDK:", err); return { - code: err?.code, - message: err?.message, - context: err?.data?.context, - details: err?.data?.details, + code: "unexpected_error", + message: EMPTY_ERROR_MESSAGE, + context: undefined, + details: undefined, }; - } else { - console.error("Unexpected error:", err); + } + + if (looksLikeUserRejection(err)) { return { - code: "unexpected_error", - message: "Oops! Something went wrong. Please try again.", + code: ERROR_CODES.USER_DENIED_INTENT, + message: USER_REJECTED_MESSAGE, context: undefined, details: undefined, }; } + + if (err instanceof NexusError) { + const mappedMessage = + ERROR_MESSAGE_BY_CODE[err.code] ?? sanitizeMessage(err.message); + return { + code: err.code, + message: mappedMessage, + context: err?.data?.context, + details: err?.data?.details, + }; + } + + const unknownMessage = sanitizeMessage(getErrorMessage(err)); + console.error("Unexpected error:", err); + return { + code: String(getErrorCode(err) ?? "unexpected_error"), + message: unknownMessage || DEFAULT_ERROR_MESSAGE, + context: undefined, + details: undefined, + }; } + export function useNexusError() { return handler; } diff --git a/apps/monad/src/components/common/hooks/useTransactionExecution.ts b/apps/monad/src/components/common/hooks/useTransactionExecution.ts new file mode 100644 index 0000000..ab48636 --- /dev/null +++ b/apps/monad/src/components/common/hooks/useTransactionExecution.ts @@ -0,0 +1,382 @@ +import { + type BridgeStepType, + NEXUS_EVENTS, + type NexusSDK, + type OnAllowanceHookData, + type OnIntentHookData, +} from "@avail-project/nexus-core"; +import { + type Dispatch, + type RefObject, + type SetStateAction, + useCallback, + useRef, +} from "react"; +import { type TransactionStatus } from "../tx/types"; +import { + type SourceSelectionValidation, + type TransactionFlowEvent, + type TransactionFlowExecutor, + type TransactionFlowInputs, +} from "../types/transaction-flow"; + +interface NexusErrorInfo { + code: string; + message: string; + context?: unknown; + details?: unknown; +} + +type NexusErrorHandler = (error: unknown) => NexusErrorInfo; + +interface UseTransactionExecutionProps { + operationName: "bridge" | "transfer"; + nexusSDK: NexusSDK | null; + intent: RefObject; + allowance: RefObject; + inputs: TransactionFlowInputs; + configuredMaxAmount?: string; + allAvailableSourceChainIds: number[]; + sourceChainsForSdk?: number[]; + sourceSelectionKey: string; + sourceSelection: SourceSelectionValidation; + loading: boolean; + txError: string | null; + areInputsValid: boolean; + executeTransaction: TransactionFlowExecutor; + getMaxForCurrentSelection: () => Promise; + onStepsList: (steps: BridgeStepType[]) => void; + onStepComplete: (step: BridgeStepType) => void; + resetSteps: () => void; + setStatus: (status: TransactionStatus) => void; + resetInputs: () => void; + setRefreshing: Dispatch>; + setIsDialogOpen: Dispatch>; + setTxError: Dispatch>; + setLastExplorerUrl: Dispatch>; + setSelectedSourceChains: Dispatch>; + setAppliedSourceSelectionKey: Dispatch>; + stopwatch: { + start: () => void; + stop: () => void; + reset: () => void; + }; + handleNexusError: NexusErrorHandler; + onStart?: () => void; + onComplete?: () => void; + onError?: (message: string) => void; + fetchBalance: () => Promise; + notifyHistoryRefresh?: () => void; +} + +export function useTransactionExecution({ + operationName, + nexusSDK, + intent, + allowance, + inputs, + configuredMaxAmount, + allAvailableSourceChainIds, + sourceChainsForSdk, + sourceSelectionKey, + sourceSelection, + loading, + txError, + areInputsValid, + executeTransaction, + getMaxForCurrentSelection, + onStepsList, + onStepComplete, + resetSteps, + setStatus, + resetInputs, + setRefreshing, + setIsDialogOpen, + setTxError, + setLastExplorerUrl, + setSelectedSourceChains, + setAppliedSourceSelectionKey, + stopwatch, + handleNexusError, + onStart, + onComplete, + onError, + fetchBalance, + notifyHistoryRefresh, +}: UseTransactionExecutionProps) { + const commitLockRef = useRef(false); + const runIdRef = useRef(0); + + const refreshIntent = async (options?: { reportError?: boolean }) => { + if (!intent.current) return false; + const activeRunId = runIdRef.current; + setRefreshing(true); + try { + const updated = await intent.current.refresh(sourceChainsForSdk); + if (activeRunId !== runIdRef.current) return false; + if (updated) { + intent.current.intent = updated; + } + setAppliedSourceSelectionKey(sourceSelectionKey); + return true; + } catch (error) { + if (activeRunId !== runIdRef.current) return false; + console.error("Transaction failed:", error); + if (options?.reportError) { + const message = "Unable to refresh source selection. Please try again."; + setTxError(message); + onError?.(message); + } + return false; + } finally { + if (activeRunId !== runIdRef.current) return; + setRefreshing(false); + } + }; + + const onSuccess = async () => { + stopwatch.stop(); + setStatus("success"); + onComplete?.(); + intent.current = null; + allowance.current = null; + resetInputs(); + setRefreshing(false); + setSelectedSourceChains(null); + setAppliedSourceSelectionKey("ALL"); + await fetchBalance(); + notifyHistoryRefresh?.(); + }; + + const handleTransaction = async () => { + if (commitLockRef.current) return; + commitLockRef.current = true; + const currentRunId = ++runIdRef.current; + let didEnterExecutingState = false; + const cleanupSupersededExecution = () => { + if (!didEnterExecutingState) return; + setRefreshing(false); + setIsDialogOpen(false); + setLastExplorerUrl(""); + stopwatch.stop(); + stopwatch.reset(); + resetSteps(); + setStatus("idle"); + }; + + try { + if ( + !inputs?.amount || + !inputs?.recipient || + !inputs?.chain || + !inputs?.token + ) { + console.error("Missing required inputs"); + return; + } + if (!nexusSDK) { + const message = "Nexus SDK not initialized"; + setTxError(message); + onError?.(message); + return; + } + if (allAvailableSourceChainIds.length === 0) { + const message = + "No eligible source chains available for the selected token and destination."; + setTxError(message); + onError?.(message); + setStatus("error"); + return; + } + + const parsedAmount = Number(inputs.amount); + if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { + const message = "Enter a valid amount greater than 0."; + setTxError(message); + onError?.(message); + setStatus("error"); + return; + } + + const amountBigInt = nexusSDK.convertTokenReadableAmountToBigInt( + inputs.amount, + inputs.token, + inputs.chain, + ); + + if (configuredMaxAmount) { + const configuredMaxRaw = nexusSDK.convertTokenReadableAmountToBigInt( + configuredMaxAmount, + inputs.token, + inputs.chain, + ); + if (amountBigInt > configuredMaxRaw) { + const message = `Amount exceeds maximum limit of ${configuredMaxAmount} ${inputs.token}.`; + setTxError(message); + onError?.(message); + setStatus("error"); + return; + } + } + + const maxForCurrentSelection = await getMaxForCurrentSelection(); + if (currentRunId !== runIdRef.current) return; + if (!maxForCurrentSelection) { + const message = `Unable to determine max ${operationName} amount for selected sources. Please try again.`; + setTxError(message); + onError?.(message); + setStatus("error"); + return; + } + const maxForSelectionRaw = nexusSDK.convertTokenReadableAmountToBigInt( + maxForCurrentSelection, + inputs.token, + inputs.chain, + ); + if (amountBigInt > maxForSelectionRaw) { + const message = `Selected sources can provide up to ${maxForCurrentSelection} ${inputs.token}. Reduce amount or enable more sources.`; + setTxError(message); + onError?.(message); + setStatus("error"); + return; + } + + setStatus("executing"); + didEnterExecutingState = true; + setTxError(null); + onStart?.(); + setLastExplorerUrl(""); + setAppliedSourceSelectionKey(sourceSelectionKey); + + const onEvent = (event: TransactionFlowEvent) => { + if (currentRunId !== runIdRef.current) return; + if (event.name === NEXUS_EVENTS.STEPS_LIST) { + const list = Array.isArray(event.args) ? event.args : []; + onStepsList(list as BridgeStepType[]); + } + if (event.name === NEXUS_EVENTS.STEP_COMPLETE) { + if ( + !Array.isArray(event.args) && + "type" in event.args && + event.args.type === "INTENT_HASH_SIGNED" + ) { + stopwatch.start(); + } + if (!Array.isArray(event.args)) { + onStepComplete(event.args as BridgeStepType); + } + } + }; + + const transactionResult = await executeTransaction({ + token: inputs.token, + amount: amountBigInt, + toChainId: inputs.chain, + recipient: inputs.recipient, + sourceChains: sourceChainsForSdk, + onEvent, + }); + + if (currentRunId !== runIdRef.current) { + cleanupSupersededExecution(); + return; + } + if (!transactionResult) { + throw new Error("Transaction rejected by user"); + } + setLastExplorerUrl(transactionResult.explorerUrl); + await onSuccess(); + } catch (error) { + if (currentRunId !== runIdRef.current) { + cleanupSupersededExecution(); + return; + } + const { message, code, context, details } = handleNexusError(error); + console.error(`Fast ${operationName} transaction failed:`, { + code, + message, + context, + details, + }); + intent.current?.deny(); + intent.current = null; + allowance.current = null; + setTxError(message); + onError?.(message); + setIsDialogOpen(false); + setSelectedSourceChains(null); + setRefreshing(false); + stopwatch.stop(); + stopwatch.reset(); + resetSteps(); + void fetchBalance(); + setStatus("error"); + } finally { + commitLockRef.current = false; + } + }; + + const reset = () => { + runIdRef.current += 1; + intent.current?.deny(); + intent.current = null; + allowance.current = null; + resetInputs(); + setStatus("idle"); + setRefreshing(false); + setSelectedSourceChains(null); + setAppliedSourceSelectionKey("ALL"); + setLastExplorerUrl(""); + stopwatch.stop(); + stopwatch.reset(); + resetSteps(); + }; + + const startTransaction = () => { + if (!intent.current) return; + if (allAvailableSourceChainIds.length === 0) { + const message = + "No eligible source chains available for the selected token and destination."; + setTxError(message); + onError?.(message); + return; + } + if (sourceSelection.isBelowRequired && inputs?.token) { + const message = `Selected sources are not enough. Add ${sourceSelection.missingToProceed} ${inputs.token} more to make this transaction.`; + setTxError(message); + onError?.(message); + return; + } + void (async () => { + const refreshed = await refreshIntent({ reportError: true }); + if (!refreshed || !intent.current) return; + intent.current.allow(); + setIsDialogOpen(true); + setTxError(null); + })(); + }; + + const commitAmount = async () => { + if (intent.current || loading || txError || !areInputsValid) return; + await handleTransaction(); + }; + + const invalidatePendingExecution = useCallback(() => { + runIdRef.current += 1; + if (intent.current) { + intent.current.deny(); + intent.current = null; + } + setRefreshing(false); + setAppliedSourceSelectionKey("ALL"); + }, [intent, setAppliedSourceSelectionKey, setRefreshing]); + + return { + refreshIntent, + handleTransaction, + startTransaction, + commitAmount, + reset, + invalidatePendingExecution, + }; +} diff --git a/apps/monad/src/components/common/hooks/useTransactionFlow.ts b/apps/monad/src/components/common/hooks/useTransactionFlow.ts new file mode 100644 index 0000000..a0d02a2 --- /dev/null +++ b/apps/monad/src/components/common/hooks/useTransactionFlow.ts @@ -0,0 +1,573 @@ +import { + type BridgeStepType, + type NexusNetwork, + NexusSDK, + type OnAllowanceHookData, + type OnIntentHookData, + parseUnits, + type UserAsset, +} from "@avail-project/nexus-core"; +import { + useEffect, + useMemo, + useCallback, + useRef, + useState, + useReducer, + type RefObject, +} from "react"; +import { type Address, isAddress } from "viem"; +import { useNexusError } from "./useNexusError"; +import { useTransactionExecution } from "./useTransactionExecution"; +import { usePolling } from "./usePolling"; +import { useStopwatch } from "./useStopwatch"; +import { useDebouncedCallback } from "./useDebouncedCallback"; +import { type TransactionStatus } from "../tx/types"; +import { useTransactionSteps } from "../tx/useTransactionSteps"; +import { + type SourceCoverageState, + type TransactionFlowExecutor, + type TransactionFlowInputs, + type TransactionFlowPrefill, + type TransactionFlowType, +} from "../types/transaction-flow"; +import { + MAX_AMOUNT_DEBOUNCE_MS, + buildInitialInputs, + clampAmountToMax, + formatAmountForDisplay, + getCoverageDecimals, + normalizeMaxAmount, +} from "../utils/transaction-flow"; + +interface BaseTransactionFlowProps { + type: TransactionFlowType; + network: NexusNetwork; + nexusSDK: NexusSDK | null; + intent: RefObject; + allowance: RefObject; + bridgableBalance: UserAsset[] | null; + prefill?: TransactionFlowPrefill; + onComplete?: () => void; + onStart?: () => void; + onError?: (message: string) => void; + fetchBalance: () => Promise; + maxAmount?: string | number; + isSourceMenuOpen?: boolean; + notifyHistoryRefresh?: () => void; + executeTransaction: TransactionFlowExecutor; +} + +export interface UseTransactionFlowProps extends BaseTransactionFlowProps { + connectedAddress?: Address; +} + +type State = { + inputs: TransactionFlowInputs; + status: TransactionStatus; +}; + +type Action = + | { type: "setInputs"; payload: Partial } + | { type: "resetInputs" } + | { type: "setStatus"; payload: TransactionStatus }; + +export function useTransactionFlow(props: UseTransactionFlowProps) { + const { + type, + network, + nexusSDK, + intent, + bridgableBalance, + prefill, + onComplete, + onStart, + onError, + fetchBalance, + allowance, + maxAmount, + isSourceMenuOpen = false, + notifyHistoryRefresh, + executeTransaction, + } = props; + + const connectedAddress = props.connectedAddress; + const operationName = type === "bridge" ? "bridge" : "transfer"; + const handleNexusError = useNexusError(); + const initialState: State = { + inputs: buildInitialInputs({ type, network, connectedAddress, prefill }), + status: "idle", + }; + + function reducer(state: State, action: Action): State { + switch (action.type) { + case "setInputs": + return { ...state, inputs: { ...state.inputs, ...action.payload } }; + case "resetInputs": + return { + ...state, + inputs: buildInitialInputs({ + type, + network, + connectedAddress, + prefill, + }), + }; + case "setStatus": + return { ...state, status: action.payload }; + default: + return state; + } + } + + const [state, dispatch] = useReducer(reducer, initialState); + const inputs = state.inputs; + const setInputs = ( + next: TransactionFlowInputs | Partial, + ) => { + dispatch({ + type: "setInputs", + payload: next as Partial, + }); + }; + + const loading = state.status === "executing"; + const [refreshing, setRefreshing] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [txError, setTxError] = useState(null); + const [lastExplorerUrl, setLastExplorerUrl] = useState(""); + const previousConnectedAddressRef = useRef
( + connectedAddress, + ); + const maxAmountRequestIdRef = useRef(0); + const [selectedSourceChains, setSelectedSourceChains] = useState< + number[] | null + >(null); + const [selectedSourcesMaxAmount, setSelectedSourcesMaxAmount] = useState< + string | null + >(null); + const [appliedSourceSelectionKey, setAppliedSourceSelectionKey] = + useState("ALL"); + const { + steps, + onStepsList, + onStepComplete, + reset: resetSteps, + } = useTransactionSteps(); + const configuredMaxAmount = useMemo( + () => normalizeMaxAmount(maxAmount), + [maxAmount], + ); + + const areInputsValid = useMemo(() => { + const hasToken = inputs?.token !== undefined && inputs?.token !== null; + const hasChain = inputs?.chain !== undefined && inputs?.chain !== null; + const hasAmount = Boolean(inputs?.amount) && Number(inputs?.amount) > 0; + const hasValidRecipient = + Boolean(inputs?.recipient) && isAddress(inputs.recipient as string); + return hasToken && hasChain && hasAmount && hasValidRecipient; + }, [inputs]); + + const filteredBridgableBalance = useMemo(() => { + return bridgableBalance?.find((bal) => + inputs?.token === "USDM" + ? bal?.symbol === "USDC" + : bal?.symbol === inputs?.token, + ); + }, [bridgableBalance, inputs?.token]); + + const availableSources = useMemo(() => { + const breakdown = filteredBridgableBalance?.breakdown ?? []; + const destinationChainId = inputs?.chain; + const nonZero = breakdown.filter((source) => { + if (Number.parseFloat(source.balance ?? "0") <= 0) return false; + if (typeof destinationChainId === "number") { + return source.chain.id !== destinationChainId; + } + return true; + }); + const decimals = filteredBridgableBalance?.decimals; + if (!nexusSDK || typeof decimals !== "number") { + return nonZero.sort( + (a, b) => Number.parseFloat(b.balance) - Number.parseFloat(a.balance), + ); + } + return nonZero.sort((a, b) => { + try { + const aRaw = parseUnits(a.balance ?? "0", decimals); + const bRaw = parseUnits(b.balance ?? "0", decimals); + if (aRaw === bRaw) return 0; + return aRaw > bRaw ? -1 : 1; + } catch { + return Number.parseFloat(b.balance) - Number.parseFloat(a.balance); + } + }); + }, [ + inputs?.chain, + filteredBridgableBalance?.breakdown, + filteredBridgableBalance?.decimals, + nexusSDK, + ]); + + const allAvailableSourceChainIds = useMemo( + () => availableSources.map((source) => source.chain.id), + [availableSources], + ); + + const effectiveSelectedSourceChains = useMemo(() => { + if (selectedSourceChains && selectedSourceChains.length > 0) { + const availableSet = new Set(allAvailableSourceChainIds); + const filteredSelection = selectedSourceChains.filter((id) => + availableSet.has(id), + ); + if (filteredSelection.length > 0) { + return filteredSelection; + } + } + return allAvailableSourceChainIds; + }, [selectedSourceChains, allAvailableSourceChainIds]); + + const sourceChainsForSdk = + effectiveSelectedSourceChains.length > 0 + ? effectiveSelectedSourceChains + : undefined; + + const sourceSelectionKey = useMemo(() => { + if (allAvailableSourceChainIds.length === 0) return "NONE"; + if (!selectedSourceChains || selectedSourceChains.length === 0) { + return "ALL"; + } + return [...effectiveSelectedSourceChains].sort((a, b) => a - b).join("|"); + }, [ + allAvailableSourceChainIds.length, + effectiveSelectedSourceChains, + selectedSourceChains, + ]); + const hasPendingSourceSelectionChanges = + sourceSelectionKey !== appliedSourceSelectionKey; + const intentSourceSpendAmount = intent.current?.intent?.sourcesTotal; + + const getMaxForCurrentSelection = useCallback(async () => { + if (!nexusSDK || !inputs?.token || !inputs?.chain) return undefined; + const maxBalAvailable = await nexusSDK.calculateMaxForBridge({ + token: inputs.token, + toChainId: inputs.chain, + recipient: inputs.recipient, + sourceChains: sourceChainsForSdk, + }); + if (!maxBalAvailable?.amount) return "0"; + return clampAmountToMax({ + amount: maxBalAvailable.amount, + maxAmount: configuredMaxAmount, + nexusSDK, + token: inputs.token, + chainId: inputs.chain, + }); + }, [ + configuredMaxAmount, + inputs?.chain, + inputs?.recipient, + inputs?.token, + nexusSDK, + sourceChainsForSdk, + ]); + + const toggleSourceChain = useCallback( + (chainId: number) => { + setSelectedSourceChains((prev) => { + if (allAvailableSourceChainIds.length === 0) return prev; + const current = + prev && prev.length > 0 ? prev : allAvailableSourceChainIds; + const next = current.includes(chainId) + ? current.filter((id) => id !== chainId) + : [...current, chainId]; + if (next.length === 0) { + return current; + } + const isAllSelected = + next.length === allAvailableSourceChainIds.length && + allAvailableSourceChainIds.every((id) => next.includes(id)); + return isAllSelected ? null : next; + }); + }, + [allAvailableSourceChainIds], + ); + + const sourceSelection = useMemo(() => { + const amount = + intentSourceSpendAmount?.trim() ?? inputs?.amount?.trim() ?? ""; + const decimals = getCoverageDecimals({ + type, + token: inputs?.token, + chainId: inputs?.chain, + fallback: filteredBridgableBalance?.decimals, + }); + const selectedChainSet = new Set(effectiveSelectedSourceChains); + const selectedTotalRaw = + !nexusSDK || typeof decimals !== "number" + ? BigInt(0) + : availableSources.reduce((sum, source) => { + if (!selectedChainSet.has(source.chain.id)) return sum; + try { + return sum + parseUnits(source.balance ?? "0", decimals); + } catch { + return sum; + } + }, BigInt(0)); + const selectedTotal = + !nexusSDK || typeof decimals !== "number" + ? "0" + : formatAmountForDisplay(selectedTotalRaw, decimals, nexusSDK); + const baseSelection = { + selectedTotal, + requiredTotal: amount || "0", + requiredSafetyTotal: amount || "0", + missingToProceed: "0", + missingToSafety: "0", + coverageState: "healthy" as SourceCoverageState, + coverageToSafetyPercent: 100, + isBelowRequired: false, + isBelowSafetyBuffer: false, + }; + + if (!nexusSDK || !inputs?.token || !inputs?.chain || !amount) { + return baseSelection; + } + + try { + const requiredRaw = nexusSDK.convertTokenReadableAmountToBigInt( + amount, + inputs.token, + inputs.chain, + ); + if (requiredRaw <= BigInt(0)) { + return baseSelection; + } + + const missingToProceedRaw = + selectedTotalRaw >= requiredRaw + ? BigInt(0) + : requiredRaw - selectedTotalRaw; + const missingToSafetyRaw = missingToProceedRaw; + + const coverageState: SourceCoverageState = + selectedTotalRaw < requiredRaw ? "error" : "healthy"; + + const coverageBasisPoints = + requiredRaw === BigInt(0) + ? 10_000 + : selectedTotalRaw >= requiredRaw + ? 10_000 + : Number((selectedTotalRaw * BigInt(10_000)) / requiredRaw); + + return { + selectedTotal, + requiredTotal: amount, + requiredSafetyTotal: amount, + missingToProceed: formatAmountForDisplay( + missingToProceedRaw, + decimals, + nexusSDK, + ), + missingToSafety: formatAmountForDisplay( + missingToSafetyRaw, + decimals, + nexusSDK, + ), + coverageState, + coverageToSafetyPercent: coverageBasisPoints / 100, + isBelowRequired: coverageState === "error", + isBelowSafetyBuffer: coverageState === "error", + }; + } catch { + return baseSelection; + } + }, [ + type, + filteredBridgableBalance?.decimals, + nexusSDK, + inputs?.chain, + inputs?.amount, + inputs?.token, + intentSourceSpendAmount, + availableSources, + effectiveSelectedSourceChains, + ]); + + const stopwatch = useStopwatch({ intervalMs: 100 }); + const setStatus = useCallback( + (status: TransactionStatus) => + dispatch({ type: "setStatus", payload: status }), + [], + ); + + const resetInputs = useCallback(() => { + dispatch({ type: "resetInputs" }); + }, []); + + const { + refreshIntent, + handleTransaction, + startTransaction, + commitAmount, + reset, + invalidatePendingExecution, + } = useTransactionExecution({ + operationName, + nexusSDK, + intent, + allowance, + inputs, + configuredMaxAmount, + allAvailableSourceChainIds, + sourceChainsForSdk, + sourceSelectionKey, + sourceSelection, + loading, + txError, + areInputsValid, + executeTransaction, + getMaxForCurrentSelection, + onStepsList, + onStepComplete, + resetSteps, + setStatus, + resetInputs, + setRefreshing, + setIsDialogOpen, + setTxError, + setLastExplorerUrl, + setSelectedSourceChains, + setAppliedSourceSelectionKey, + stopwatch, + handleNexusError, + onStart, + onComplete, + onError, + fetchBalance, + notifyHistoryRefresh, + }); + + usePolling( + Boolean(intent.current) && + !isDialogOpen && + !isSourceMenuOpen && + !hasPendingSourceSelectionChanges, + async () => { + await refreshIntent(); + }, + 15000, + ); + + const debouncedRefreshMaxForSelection = useDebouncedCallback( + async (requestId: number) => { + try { + const maxForCurrentSelection = await getMaxForCurrentSelection(); + if (requestId !== maxAmountRequestIdRef.current) return; + setSelectedSourcesMaxAmount(maxForCurrentSelection ?? "0"); + } catch (error) { + if (requestId !== maxAmountRequestIdRef.current) return; + console.error("Unable to calculate max for selected sources:", error); + setSelectedSourcesMaxAmount("0"); + } + }, + MAX_AMOUNT_DEBOUNCE_MS, + ); + + useEffect(() => { + debouncedRefreshMaxForSelection.cancel(); + if (!nexusSDK || !inputs?.token || !inputs?.chain) { + maxAmountRequestIdRef.current += 1; + setSelectedSourcesMaxAmount(null); + return; + } + if (allAvailableSourceChainIds.length === 0) { + maxAmountRequestIdRef.current += 1; + setSelectedSourcesMaxAmount("0"); + return; + } + const requestId = ++maxAmountRequestIdRef.current; + debouncedRefreshMaxForSelection(requestId); + }, [ + allAvailableSourceChainIds.length, + configuredMaxAmount, + debouncedRefreshMaxForSelection, + inputs?.recipient, + sourceSelectionKey, + inputs?.chain, + inputs?.token, + nexusSDK, + ]); + + useEffect(() => { + if (type !== "bridge" || !connectedAddress) return; + const previousConnectedAddress = previousConnectedAddressRef.current; + if (!previousConnectedAddress) { + previousConnectedAddressRef.current = connectedAddress; + return; + } + if (connectedAddress === previousConnectedAddress) return; + previousConnectedAddressRef.current = connectedAddress; + if (prefill?.recipient) return; + if (!inputs?.recipient || inputs.recipient === previousConnectedAddress) { + dispatch({ type: "setInputs", payload: { recipient: connectedAddress } }); + } + }, [type, connectedAddress, inputs?.recipient, prefill?.recipient]); + + useEffect(() => { + invalidatePendingExecution(); + }, [inputs, invalidatePendingExecution]); + + useEffect(() => { + setSelectedSourceChains(null); + }, [inputs?.token]); + + useEffect(() => { + if (!isDialogOpen) { + stopwatch.stop(); + stopwatch.reset(); + } + }, [isDialogOpen, stopwatch]); + + useEffect(() => { + if (txError) { + setTxError(null); + } + }, [inputs, txError]); + + return { + inputs, + setInputs, + timer: stopwatch.seconds, + setIsDialogOpen, + setTxError, + loading, + refreshing, + isDialogOpen, + txError, + handleTransaction, + reset, + filteredBridgableBalance, + startTransaction, + commitAmount, + lastExplorerUrl, + steps, + status: state.status, + availableSources, + selectedSourceChains: effectiveSelectedSourceChains, + toggleSourceChain, + isSourceSelectionInsufficient: sourceSelection.isBelowRequired, + isSourceSelectionBelowSafetyBuffer: sourceSelection.isBelowSafetyBuffer, + isSourceSelectionReadyForAccept: + sourceSelection.coverageState === "healthy", + sourceCoverageState: sourceSelection.coverageState, + sourceCoveragePercent: sourceSelection.coverageToSafetyPercent, + missingToProceed: sourceSelection.missingToProceed, + missingToSafety: sourceSelection.missingToSafety, + selectedTotal: sourceSelection.selectedTotal, + requiredTotal: sourceSelection.requiredTotal, + requiredSafetyTotal: sourceSelection.requiredSafetyTotal, + maxAvailableAmount: selectedSourcesMaxAmount ?? undefined, + isInputsValid: areInputsValid, + }; +} diff --git a/apps/monad/src/components/common/index.ts b/apps/monad/src/components/common/index.ts index f5a855e..7e954c0 100644 --- a/apps/monad/src/components/common/index.ts +++ b/apps/monad/src/components/common/index.ts @@ -2,10 +2,14 @@ export * from "./hooks/useStopwatch"; export * from "./hooks/usePolling"; export * from "./hooks/useInterval"; export * from "./hooks/useStableCallback"; +export * from "./hooks/useLatest"; export * from "./hooks/useDebouncedValue"; export * from "./hooks/useDebouncedCallback"; export * from "./hooks/useNexusError"; +export * from "./hooks/useTransactionFlow"; +export * from "./types/transaction-flow"; export * from "./tx/types"; export * from "./tx/steps"; export * from "./tx/useTransactionSteps"; export * from "./utils/constant"; +export * from "./components/ErrorBoundary"; diff --git a/apps/monad/src/components/common/types/transaction-flow.ts b/apps/monad/src/components/common/types/transaction-flow.ts new file mode 100644 index 0000000..27e81bc --- /dev/null +++ b/apps/monad/src/components/common/types/transaction-flow.ts @@ -0,0 +1,53 @@ +import { + type NexusSDK, + type SUPPORTED_CHAINS_IDS, + type SUPPORTED_TOKENS, +} from "@avail-project/nexus-core"; +import { type Address } from "viem"; + +export type TransactionFlowType = "bridge" | "transfer"; + +export interface TransactionFlowInputs { + chain: SUPPORTED_CHAINS_IDS; + token: SUPPORTED_TOKENS; + amount?: string; + recipient?: `0x${string}`; +} + +export interface TransactionFlowPrefill { + token: string; + chainId: number; + amount?: string; + recipient?: Address; +} + +type BridgeOptions = NonNullable[1]>; + +export type TransactionFlowEvent = + NonNullable extends (event: infer E) => void + ? E + : never; + +export type TransactionFlowOnEvent = NonNullable; + +export interface TransactionFlowExecuteParams { + token: SUPPORTED_TOKENS; + amount: bigint; + toChainId: SUPPORTED_CHAINS_IDS; + recipient: `0x${string}`; + sourceChains?: number[]; + onEvent: TransactionFlowOnEvent; +} + +export type TransactionFlowExecutor = ( + params: TransactionFlowExecuteParams, +) => Promise<{ explorerUrl: string } | null>; + +export type SourceCoverageState = "healthy" | "warning" | "error"; + +export interface SourceSelectionValidation { + coverageState: SourceCoverageState; + isBelowRequired: boolean; + missingToProceed: string; + missingToSafety: string; +} diff --git a/apps/monad/src/components/common/utils/constant.ts b/apps/monad/src/components/common/utils/constant.ts index 0996cca..a300ae3 100644 --- a/apps/monad/src/components/common/utils/constant.ts +++ b/apps/monad/src/components/common/utils/constant.ts @@ -9,54 +9,24 @@ export const SHORT_CHAIN_NAME: Record = { [SUPPORTED_CHAINS.POLYGON]: "Polygon", [SUPPORTED_CHAINS.AVALANCHE]: "Avalanche", [SUPPORTED_CHAINS.SCROLL]: "Scroll", + [SUPPORTED_CHAINS.MEGAETH]: "MegaETH", [SUPPORTED_CHAINS.KAIA]: "Kaia", [SUPPORTED_CHAINS.BNB]: "BNB", [SUPPORTED_CHAINS.MONAD]: "Monad", [SUPPORTED_CHAINS.HYPEREVM]: "HyperEVM", - 4326: "MegaETH", - 4114: "Citrea", - + [SUPPORTED_CHAINS.CITREA]: "Citrea", + // [SUPPORTED_CHAINS.TRON]: "Tron", [SUPPORTED_CHAINS.SEPOLIA]: "Sepolia", [SUPPORTED_CHAINS.BASE_SEPOLIA]: "Base Sepolia", [SUPPORTED_CHAINS.ARBITRUM_SEPOLIA]: "Arbitrum Sepolia", [SUPPORTED_CHAINS.OPTIMISM_SEPOLIA]: "Optimism Sepolia", [SUPPORTED_CHAINS.POLYGON_AMOY]: "Polygon Amoy", [SUPPORTED_CHAINS.MONAD_TESTNET]: "Monad Testnet", + // [SUPPORTED_CHAINS.TRON_SHASTA]: "Tron Shasta", } as const; const DEFAULT_SAFETY_MARGIN = 0.01; // 1% -export const TOKEN_IMAGES: Record = { - USDC: "https://coin-images.coingecko.com/coins/images/6319/large/usdc.png", - USDT: "https://coin-images.coingecko.com/coins/images/35023/large/USDT.png", - "USDâ‚®0": - "https://coin-images.coingecko.com/coins/images/35023/large/USDT.png", - USDM: "https://raw.githubusercontent.com/availproject/nexus-assets/refs/heads/main/tokens/usdm/logo.png", - WETH: "https://assets.coingecko.com/coins/images/279/large/ethereum.png?1595348880", - USDS: "https://assets.coingecko.com/coins/images/39926/standard/usds.webp?1726666683", - SOPH: "https://assets.coingecko.com/coins/images/38680/large/sophon_logo_200.png", - KAIA: "https://assets.coingecko.com/asset_platforms/images/9672/large/kaia.png", - BNB: "https://assets.coingecko.com/coins/images/825/large/bnb-icon2_2x.png", - // Add ETH as fallback for any ETH-related tokens - ETH: "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628", - // Add common token fallbacks - POL: "https://coin-images.coingecko.com/coins/images/32440/standard/polygon.png", - AVAX: "https://assets.coingecko.com/coins/images/12559/standard/Avalanche_Circle_RedWhite_Trans.png", - FUEL: "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png", - HYPE: "https://assets.coingecko.com/asset_platforms/images/243/large/hyperliquid.png", - // Popular swap tokens - DAI: "https://coin-images.coingecko.com/coins/images/9956/large/Badge_Dai.png?1696509996", - UNI: "https://coin-images.coingecko.com/coins/images/12504/large/uni.jpg?1696512319", - AAVE: "https://coin-images.coingecko.com/coins/images/12645/large/AAVE.png?1696512452", - LDO: "https://coin-images.coingecko.com/coins/images/13573/large/Lido_DAO.png?1696513326", - PEPE: "https://coin-images.coingecko.com/coins/images/29850/large/pepe-token.jpeg?1696528776", - OP: "https://coin-images.coingecko.com/coins/images/25244/large/Optimism.png?1696524385", - ZRO: "https://coin-images.coingecko.com/coins/images/28206/large/ftxG9_TJ_400x400.jpeg?1696527208", - OM: "https://assets.coingecko.com/coins/images/12151/standard/OM_Token.png?1696511991", - KAITO: - "https://assets.coingecko.com/coins/images/54411/standard/Qm4DW488_400x400.jpg", -}; - /** * Compute an amount string for fraction buttons (25%, 50%, 75%, 100%). * diff --git a/apps/monad/src/components/common/utils/transaction-flow.ts b/apps/monad/src/components/common/utils/transaction-flow.ts new file mode 100644 index 0000000..11ada46 --- /dev/null +++ b/apps/monad/src/components/common/utils/transaction-flow.ts @@ -0,0 +1,125 @@ +import { + formatUnits, + type NexusNetwork, + NexusSDK, + SUPPORTED_CHAINS, + type SUPPORTED_CHAINS_IDS, + type SUPPORTED_TOKENS, +} from "@avail-project/nexus-core"; +import { type Address } from "viem"; + +const MAX_AMOUNT_REGEX = /^\d*\.?\d+$/; + +export const MAX_AMOUNT_DEBOUNCE_MS = 300; + +export const normalizeMaxAmount = ( + maxAmount?: string | number, +): string | undefined => { + if (maxAmount === undefined || maxAmount === null) return undefined; + const value = String(maxAmount).trim(); + if (!value || value === "." || !MAX_AMOUNT_REGEX.test(value)) { + return undefined; + } + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed) || parsed <= 0) return undefined; + return value; +}; + +export const clampAmountToMax = ({ + amount, + maxAmount, + nexusSDK, + token, + chainId, +}: { + amount: string; + maxAmount?: string; + nexusSDK: NexusSDK; + token: SUPPORTED_TOKENS; + chainId: SUPPORTED_CHAINS_IDS; +}): string => { + if (!maxAmount) return amount; + try { + const amountRaw = nexusSDK.convertTokenReadableAmountToBigInt( + amount, + token, + chainId, + ); + const maxRaw = nexusSDK.convertTokenReadableAmountToBigInt( + maxAmount, + token, + chainId, + ); + return amountRaw > maxRaw ? maxAmount : amount; + } catch { + return amount; + } +}; + +export const formatAmountForDisplay = ( + amount: bigint, + decimals: number | undefined, + nexusSDK: NexusSDK, +): string => { + if (typeof decimals !== "number") return amount.toString(); + const formatted = formatUnits(amount, decimals); + if (!formatted.includes(".")) return formatted; + const [whole, fraction] = formatted.split("."); + const trimmedFraction = fraction.slice(0, 6).replace(/0+$/, ""); + if (!trimmedFraction && whole === "0" && amount > BigInt(0)) { + return "0.000001"; + } + return trimmedFraction ? `${whole}.${trimmedFraction}` : whole; +}; + +export const buildInitialInputs = ({ + type, + network, + connectedAddress, + prefill, +}: { + type: "bridge" | "transfer"; + network: NexusNetwork; + connectedAddress?: Address; + prefill?: { + token: string; + chainId: number; + amount?: string; + recipient?: Address; + }; +}) => { + return { + chain: + (prefill?.chainId as SUPPORTED_CHAINS_IDS) ?? + (network === "testnet" + ? SUPPORTED_CHAINS.SEPOLIA + : SUPPORTED_CHAINS.ETHEREUM), + token: (prefill?.token as SUPPORTED_TOKENS) ?? "USDC", + amount: prefill?.amount ?? undefined, + recipient: + (prefill?.recipient as `0x${string}`) ?? + (type === "bridge" ? connectedAddress : undefined), + }; +}; + +export const getCoverageDecimals = ({ + type, + token, + chainId, + fallback, +}: { + type: "bridge" | "transfer"; + token?: SUPPORTED_TOKENS; + chainId?: SUPPORTED_CHAINS_IDS; + fallback: number | undefined; +}) => { + if (token === "USDM") return 18; + if ( + type === "bridge" && + token === "USDC" && + chainId === SUPPORTED_CHAINS.BNB + ) { + return 18; + } + return fallback; +}; diff --git a/apps/monad/src/components/deposit/components/amount-card.tsx b/apps/monad/src/components/deposit/components/amount-card.tsx new file mode 100644 index 0000000..9128c72 --- /dev/null +++ b/apps/monad/src/components/deposit/components/amount-card.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useCallback, useRef, useEffect, useState, useMemo } from "react"; +import { TokenIcon } from "./token-icon"; +import { ErrorBanner } from "./error-banner"; +import { PercentageSelector } from "./percentage-selector"; +import { parseCurrencyInput } from "../utils"; +import { UpDownArrows } from "./icons"; +import { usdFormatter } from "../../common"; +import { type DestinationConfig } from "../types"; +import { + BALANCE_SAFETY_MARGIN, + CHARACTER_ANIMATION_DURATION_MS, + SHINE_ANIMATION_DURATION_MS, + MAX_INPUT_WIDTH_PX, +} from "../constants/widget"; + +// Hoisted RegExp to avoid recreation on every render (js-hoist-regexp) +const NUMERIC_INPUT_REGEX = /^\d*\.?\d*$/; + +interface AmountCardProps { + amount?: string; + onAmountChange?: (amount: string) => void; + selectedTokenAmount?: number; + onErrorStateChange?: (hasError: boolean) => void; + totalSelectedBalance: number; + totalBalance: { + balance: number; + usdBalance: number; + }; + destinationConfig: DestinationConfig; +} + +function AmountCard({ + amount: externalAmount, + onAmountChange, + selectedTokenAmount = 0, + onErrorStateChange, + totalSelectedBalance, + totalBalance, + destinationConfig, +}: AmountCardProps) { + const [internalAmount, setInternalAmount] = useState(""); + const amount = externalAmount ?? internalAmount; + const setAmount = onAmountChange ?? setInternalAmount; + + const [inputWidth, setInputWidth] = useState(0); + const [isShining, setIsShining] = useState(false); + const [animatingIndices, setAnimatingIndices] = useState>( + new Set(), + ); + const prevAmountRef = useRef(""); + const prevLengthRef = useRef(0); + const measureRef = useRef(null); + const inputRef = useRef(null); + + const displayValue = amount || ""; + const measureText = displayValue || "0"; + + // Split display value into characters for animation + const displayChars = displayValue.split(""); + + // Track which characters should animate (newly added ones) + useEffect(() => { + const currentLength = displayValue.length; + const prevLength = prevLengthRef.current; + + // Only animate when characters are added (not removed) + if (currentLength > prevLength) { + const newIndices = new Set(); + for (let i = prevLength; i < currentLength; i++) { + newIndices.add(i); + } + setAnimatingIndices(newIndices); + + // Clear animation after it completes + const timer = setTimeout(() => { + setAnimatingIndices(new Set()); + }, CHARACTER_ANIMATION_DURATION_MS); + + prevLengthRef.current = currentLength; + return () => clearTimeout(timer); + } + + prevLengthRef.current = currentLength; + }, [displayValue]); + + // Calculate numeric amount for USD equivalent + const numericAmount = useMemo(() => { + if (!amount) return 0; + const parsed = parseFloat(amount.replace(/,/g, "")); + return isNaN(parsed) ? 0 : parsed; + }, [amount]); + + // Check if amount exceeds wallet balance + const exceedsBalance = useMemo(() => { + if (!amount) return false; + const numericAmount = parseFloat(amount.replace(/,/g, "")); + return !isNaN(numericAmount) && numericAmount > totalBalance?.usdBalance; + }, [amount, totalBalance?.usdBalance]); + + // Check if amount exceeds selected token amount but is within wallet balance + const exceedsSelectedTokens = useMemo(() => { + if (!amount || selectedTokenAmount === 0) return false; + const numericAmount = parseFloat(amount.replace(/,/g, "")); + return ( + !isNaN(numericAmount) && + numericAmount > selectedTokenAmount && + numericAmount <= totalBalance?.usdBalance + ); + }, [amount, selectedTokenAmount, totalBalance?.usdBalance]); + + useEffect(() => { + if (measureRef.current) { + setInputWidth(measureRef.current.offsetWidth); + } + }, [measureText]); + + // Trigger shine effect when USD amount changes + useEffect(() => { + if (amount && amount !== prevAmountRef.current && numericAmount > 0) { + setIsShining(true); + const timer = setTimeout(() => { + setIsShining(false); + }, SHINE_ANIMATION_DURATION_MS); + prevAmountRef.current = amount; + return () => clearTimeout(timer); + } + prevAmountRef.current = amount; + }, [amount, numericAmount]); + + // Notify parent of error state changes + useEffect(() => { + const hasError = exceedsBalance || exceedsSelectedTokens; + onErrorStateChange?.(hasError); + }, [exceedsBalance, exceedsSelectedTokens, onErrorStateChange]); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const rawValue = parseCurrencyInput(e.target.value); + + // Validate numeric input (allow unlimited decimals) + if (rawValue === "" || NUMERIC_INPUT_REGEX.test(rawValue)) { + setAmount(rawValue); + } + }, + [setAmount], + ); + + const handlePercentageClick = useCallback( + (percentage: number) => { + const safeBalance = totalBalance?.usdBalance * BALANCE_SAFETY_MARGIN; + const calculatedAmount = safeBalance * percentage; + const newAmount = usdFormatter.format(calculatedAmount).replace("$", ""); + setAmount(newAmount); + }, + [setAmount, totalBalance?.usdBalance], + ); + + const handleDoubleClick = useCallback(() => { + // Select all text on double click + if (inputRef.current) { + inputRef.current.select(); + } + }, []); + + // Handle keyboard shortcuts like Ctrl+A + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.ctrlKey && e.key === "a") { + e.preventDefault(); + if (inputRef.current) { + inputRef.current.select(); + } + } + }, + [], + ); + + return ( +
+ {/* Hidden span to measure text width */} + + + {/* Amount Input Section */} +
0 ? "-mt-0.5" : "mt-1.5" + }`} + > + +
+ {/* Animated digits layer (behind input) */} + + + {/* Real input overlaid with transparent text (for cursor positioning) */} + 0 + ? Math.min(inputWidth + 4, MAX_INPUT_WIDTH_PX) + : undefined, + maxWidth: "calc(100vw - 100px)", + }} + className="absolute inset-0 font-display text-[32px] font-medium tracking-[0.8px] tabular-nums bg-transparent border-none outline-none min-w-[22px] text-transparent caret-card-foreground placeholder:text-transparent" + /> +
+
+ + {/* USD Equivalent - animated height reveal */} +
0 + ? "grid-rows-[1fr] opacity-100" + : "grid-rows-[0fr] opacity-0 mt-2" + }`} + > +
+
+ + ~ {usdFormatter.format(numericAmount)} + + +
+
+
+ + {/* Percentage Selector */} +
0 ? "-mt-px" : ""}> + +
+ + {/* Balance Display */} +
+ Balance: {usdFormatter.format(totalSelectedBalance)} +
+ + {/* Error Banner */} + {exceedsBalance && ( +
+ +
+ )} + {exceedsSelectedTokens && ( +
+ +
+ )} +
+ ); +} + +export default AmountCard; diff --git a/apps/monad/src/components/deposit/components/amount-container.tsx b/apps/monad/src/components/deposit/components/amount-container.tsx new file mode 100644 index 0000000..dfb5ead --- /dev/null +++ b/apps/monad/src/components/deposit/components/amount-container.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import WidgetHeader from "./widget-header"; +import type { DepositWidgetContextValue } from "../types"; +import AmountCard from "./amount-card"; +import PayUsing from "./pay-using"; +import { ErrorBanner } from "./error-banner"; +import { EmptyBalanceState } from "./empty-balance-state"; +import { Button } from "../../ui/button"; +import { CardContent } from "../../ui/card"; +import { Skeleton } from "../../ui/skeleton"; + +interface AmountContainerProps { + widget: DepositWidgetContextValue; + heading?: string; + onClose?: () => void; +} + +const AmountContainer = ({ + widget, + heading, + onClose, +}: AmountContainerProps) => { + const [hasAmountError, setHasAmountError] = useState(false); + const isSwapBalanceLoaded = widget.swapBalance !== null; + const hasAnySwapAsset = (widget.swapBalance?.length ?? 0) > 0; + const hasPositiveSwapBalance = useMemo( + () => + (widget.swapBalance ?? []).some((asset) => + (asset.breakdown ?? []).some((chain) => { + const amount = Number.parseFloat(chain.balance ?? "0"); + return Number.isFinite(amount) && amount > 0; + }), + ), + [widget.swapBalance], + ); + const shouldShowEmptyState = isSwapBalanceLoaded && !hasPositiveSwapBalance; + const selectedTokenAmount = useMemo( + () => widget.totalSelectedBalance, + [widget.totalSelectedBalance], + ); + + const handleAmountChange = useCallback( + (amount: string) => { + widget.setInputs({ amount }); + }, + [widget], + ); + + const handleErrorStateChange = useCallback((hasError: boolean) => { + setHasAmountError(hasError); + }, []); + + return ( + <> + + +
+ {!isSwapBalanceLoaded ? ( + + ) : shouldShowEmptyState ? ( + { + void widget.reset(); + }} + /> + ) : ( + + )} + + {widget.txError && widget.status === "error" && ( + + )} + {!shouldShowEmptyState && ( +
+ widget.goToStep("asset-selection")} + selectedChainIds={widget.assetSelection.selectedChainIds} + amount={widget.inputs.amount} + swapBalance={widget.swapBalance} + /> + +
+ )} +
+
+ + ); +}; + +export default AmountContainer; diff --git a/apps/monad/src/components/deposit/components/amount-display.tsx b/apps/monad/src/components/deposit/components/amount-display.tsx new file mode 100644 index 0000000..0fdc107 --- /dev/null +++ b/apps/monad/src/components/deposit/components/amount-display.tsx @@ -0,0 +1,58 @@ +interface AmountDisplayProps { + amount: string; + suffix: string; + label: string; + align?: "left" | "center" | "right"; + size?: "default" | "compact"; +} + +export function AmountDisplay({ + amount, + suffix, + label, + align = "center", + size = "default", +}: AmountDisplayProps) { + const alignmentClasses = { + left: "items-start text-left", + center: "items-center text-center", + right: "items-end text-right", + }; + + const amountSizeClasses = { + default: "text-[23px] tracking-[0.52px]", + compact: "text-xl tracking-[0.4px]", + }; + + const suffixSizeClasses = { + default: "text-sm", + compact: "text-xs", + }; + + const labelSizeClasses = { + default: "text-sm", + compact: "text-xs", + }; + + return ( +
+
+ + {amount} + + + {suffix} + +
+
+ {label} +
+
+ ); +} diff --git a/apps/monad/src/components/deposit/components/animated-amount.tsx b/apps/monad/src/components/deposit/components/animated-amount.tsx new file mode 100644 index 0000000..d130863 --- /dev/null +++ b/apps/monad/src/components/deposit/components/animated-amount.tsx @@ -0,0 +1,145 @@ +"use client"; + +type AnimationDirection = "up" | "down" | "none"; + +/** + * Determine animation direction for a single character + */ +function getCharDirection( + prevChar: string | undefined, + currChar: string +): AnimationDirection { + // Non-digit characters (like $, comma, period) don't animate + if (!/\d/.test(currChar)) return "none"; + + // New digit that didn't exist before - animate up + if (prevChar === undefined) return "up"; + + // Previous wasn't a digit - animate based on new value + if (!/\d/.test(prevChar)) return "up"; + + const prev = parseInt(prevChar, 10); + const curr = parseInt(currChar, 10); + + if (curr > prev) return "up"; + if (curr < prev) return "down"; + return "none"; +} + +/** + * Align strings from decimal point for proper digit comparison + */ +function alignStringsForComparison( + prev: string, + curr: string +): { prevAligned: string[]; currAligned: string[] } { + // Extract parts before and after decimal + const [prevInt, prevDec = ""] = prev.replace(/[^0-9.]/g, "").split("."); + const [currInt, currDec = ""] = curr.replace(/[^0-9.]/g, "").split("."); + + // Pad integer parts from the left + const maxIntLen = Math.max(prevInt.length, currInt.length); + const prevIntPadded = prevInt.padStart(maxIntLen, " "); + const currIntPadded = currInt.padStart(maxIntLen, " "); + + // Pad decimal parts from the right + const maxDecLen = Math.max(prevDec.length, currDec.length); + const prevDecPadded = prevDec.padEnd(maxDecLen, " "); + const currDecPadded = currDec.padEnd(maxDecLen, " "); + + // Reconstruct with formatting characters from current value + const currChars = curr.split(""); + const prevAligned: string[] = []; + + let intIdx = 0; + let decIdx = 0; + let inDecimal = false; + + for (const char of currChars) { + if (char === ".") { + inDecimal = true; + prevAligned.push("."); + } else if (/\d/.test(char)) { + if (inDecimal) { + prevAligned.push(prevDecPadded[decIdx] || " "); + decIdx++; + } else { + prevAligned.push(prevIntPadded[intIdx] || " "); + intIdx++; + } + } else { + // Non-digit, non-decimal (like $ or ,) + prevAligned.push(char); + } + } + + return { prevAligned, currAligned: currChars }; +} + +interface AnimatedDigitProps { + char: string; + direction: AnimationDirection; + delay: number; +} + +function AnimatedDigit({ char, direction, delay }: AnimatedDigitProps) { + const animationClass = + direction === "up" + ? "animate-digit-up" + : direction === "down" + ? "animate-digit-down" + : ""; + + return ( + + {char} + + ); +} + +export interface AnimatedAmountProps { + value: string; + previousValue: string; + className?: string; +} + +export function AnimatedAmount({ + value, + previousValue, + className, +}: AnimatedAmountProps) { + const { prevAligned, currAligned } = alignStringsForComparison( + previousValue, + value + ); + + let animatingIndex = 0; + + return ( + + {currAligned.map((char, index) => { + const prevChar = prevAligned[index]; + const direction = getCharDirection(prevChar, char); + const delay = direction !== "none" ? animatingIndex * 20 : 0; + if (direction !== "none") animatingIndex++; + + return ( + + ); + })} + + ); +} + diff --git a/apps/monad/src/components/deposit/components/asset-selection-container.tsx b/apps/monad/src/components/deposit/components/asset-selection-container.tsx new file mode 100644 index 0000000..f05f413 --- /dev/null +++ b/apps/monad/src/components/deposit/components/asset-selection-container.tsx @@ -0,0 +1,503 @@ +"use client"; + +import { + useMemo, + useCallback, + useState, + useEffect, + useRef, + startTransition, + useDeferredValue, +} from "react"; +import { ChevronDownIcon } from "./icons"; +import WidgetHeader from "./widget-header"; +import type { + DepositWidgetContextValue, + Token, + TokenCategory, + ChainItem, +} from "../types"; +import { Tabs, TabsList, TabsTrigger } from "../../ui/tabs"; +import { CardContent } from "../../ui/card"; +import { Button } from "../../ui/button"; +import TokenRow from "./token-row"; +import { + CHAIN_METADATA, + formatTokenBalance, + type UserAsset, +} from "@avail-project/nexus-core"; +import { usdFormatter } from "../../common"; +import { + isStablecoin, + checkIfMatchesPreset, + isNative, +} from "../utils/asset-helpers"; +import { X } from "lucide-react"; +import { + SCROLL_THRESHOLD_PX, + PROGRESS_BAR_ANIMATION_DELAY_MS, + PROGRESS_BAR_EXIT_DURATION_MS, +} from "../constants/widget"; + +interface AssetSelectionContainerProps { + widget: DepositWidgetContextValue; + heading?: string; + onClose?: () => void; +} + +function transformSwapBalanceToTokens( + swapBalance: UserAsset[] | null, +): Token[] { + if (!swapBalance) return []; + return swapBalance + .filter((asset) => asset.breakdown && asset.breakdown.length > 0) + .map((asset) => { + const chains: ChainItem[] = (asset.breakdown || []) + .filter((b) => b.chain && b.balance) + .map((b) => { + const balanceNum = parseFloat(b.balance); + return { + id: `${b.contractAddress}-${b.chain.id}`, + tokenAddress: b.contractAddress as `0x${string}`, + chainId: b.chain.id, + name: b.chain.name, + usdValue: b.balanceInFiat, + amount: balanceNum, + }; + }) + .sort((a, b) => { + const aVal = a.usdValue; + const bVal = b.usdValue; + return bVal - aVal; + }); + + const totalUsdValue = chains.reduce((sum, c) => sum + c.usdValue, 0); + + const totalAmount = chains.reduce((sum, c) => sum + c.amount, 0); + + let category: TokenCategory; + if (isStablecoin(asset.symbol)) { + category = "stablecoin"; + } else if (isNative(asset.symbol)) { + category = "native"; + } else { + category = "memecoin"; + } + + return { + id: asset.symbol, + symbol: asset.symbol, + chainsLabel: + chains.length > 1 + ? `${chains.length} Chain${chains.length !== 1 ? "s" : ""}` + : chains[0].name, + usdValue: usdFormatter.format(totalUsdValue), + amount: formatTokenBalance(totalAmount, { + decimals: asset.decimals, + symbol: asset.symbol, + }), + decimals: asset.decimals, + logo: asset.icon || "", + category, + chains, + }; + }); +} + +const AssetSelectionContainer = ({ + widget, + heading, + onClose, +}: AssetSelectionContainerProps) => { + const { assetSelection, setAssetSelection, swapBalance } = widget; + + const [isProgressBarVisible, setIsProgressBarVisible] = useState(false); + const [isProgressBarEntering, setIsProgressBarEntering] = useState(false); + const [isProgressBarExiting, setIsProgressBarExiting] = useState(false); + const [showStickyPopular, setShowStickyPopular] = useState(false); + const scrollContainerRef = useRef(null); + const popularSectionRef = useRef(null); + + const selectedChainIds = assetSelection.selectedChainIds; + const filter = assetSelection.filter; + const expandedTokens = assetSelection.expandedTokens; + + // Defer expensive token transformation to avoid blocking UI + const deferredSwapBalance = useDeferredValue(swapBalance); + + const tokens = useMemo( + () => transformSwapBalanceToTokens(deferredSwapBalance), + [deferredSwapBalance], + ); + + // Build index Map for O(1) token lookups (js-index-maps) + const tokensById = useMemo( + () => new Map(tokens.map((t) => [t.id, t])), + [tokens], + ); + + const mainTokens = useMemo( + () => + tokens.filter( + (t) => t.category === "stablecoin" || t.category === "native", + ), + [tokens], + ); + + const otherTokens = useMemo( + () => tokens.filter((t) => t.category === "memecoin"), + [tokens], + ); + + const selectedAmount = useMemo(() => { + let total = 0; + tokens.forEach((token) => { + token.chains.forEach((chain) => { + if (selectedChainIds.has(chain.id)) { + total += chain.usdValue; + } + }); + }); + return total; + }, [tokens, selectedChainIds]); + + const requiredAmount = widget.inputs.amount + ? parseFloat(widget.inputs.amount.replace(/,/g, "")) + : 0; + + const showProgressBar = requiredAmount > 0 && requiredAmount > selectedAmount; + const progressPercent = + requiredAmount > 0 + ? Math.min((selectedAmount / requiredAmount) * 100, 100) + : 0; + + useEffect(() => { + if (showProgressBar) { + setIsProgressBarVisible(true); + setIsProgressBarExiting(false); + setIsProgressBarEntering(true); + const timer = setTimeout(() => { + setIsProgressBarEntering(false); + }, PROGRESS_BAR_ANIMATION_DELAY_MS); + return () => clearTimeout(timer); + } else if (isProgressBarVisible) { + setIsProgressBarExiting(true); + const timer = setTimeout(() => { + setIsProgressBarVisible(false); + setIsProgressBarExiting(false); + }, PROGRESS_BAR_EXIT_DURATION_MS); + return () => clearTimeout(timer); + } + }, [showProgressBar, isProgressBarVisible]); + + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + // Use startTransition for non-urgent scroll updates (rerender-transitions) + const handleScroll = () => { + const scrollTop = container.scrollTop; + startTransition(() => { + setShowStickyPopular(scrollTop > SCROLL_THRESHOLD_PX); + }); + }; + + container.addEventListener("scroll", handleScroll, { passive: true }); + return () => container.removeEventListener("scroll", handleScroll); + }, []); + + const scrollToPopular = useCallback(() => { + scrollContainerRef.current?.scrollTo({ + top: 0, + behavior: "smooth", + }); + }, []); + + const handlePresetClick = useCallback( + (preset: "all" | "stablecoins" | "native") => { + const newChainIds = new Set(); + tokens.forEach((token) => { + const shouldInclude = + preset === "all" || + (preset === "stablecoins" && token.category === "stablecoin") || + (preset === "native" && token.category === "native"); + + if (shouldInclude) { + token.chains.forEach((chain) => newChainIds.add(chain.id)); + } + }); + setAssetSelection({ + selectedChainIds: newChainIds, + filter: preset, + }); + }, + [tokens, setAssetSelection], + ); + + const toggleTokenSelection = useCallback( + (tokenId: string) => { + const token = tokensById.get(tokenId); // O(1) lookup instead of O(n) + if (!token) return; + + const allChainsSelected = token.chains.every((c) => + selectedChainIds.has(c.id), + ); + const newChainIds = new Set(selectedChainIds); + + if (allChainsSelected) { + token.chains.forEach((chain) => newChainIds.delete(chain.id)); + } else { + token.chains.forEach((chain) => newChainIds.add(chain.id)); + } + + const newFilter = checkIfMatchesPreset(tokens, newChainIds); + setAssetSelection({ + selectedChainIds: newChainIds, + filter: newFilter, + }); + }, + [tokens, tokensById, selectedChainIds, setAssetSelection], + ); + + const toggleChainSelection = useCallback( + (chainId: string) => { + const newChainIds = new Set(selectedChainIds); + if (newChainIds.has(chainId)) { + newChainIds.delete(chainId); + } else { + newChainIds.add(chainId); + } + + const newFilter = checkIfMatchesPreset(tokens, newChainIds); + setAssetSelection({ + selectedChainIds: newChainIds, + filter: newFilter, + }); + }, + [tokens, selectedChainIds, setAssetSelection], + ); + + const toggleExpanded = useCallback( + (tokenId: string) => { + let newExpanded = new Set(expandedTokens); + if (tokenId === "others-section") { + if (newExpanded.has("others-section")) { + newExpanded.delete("others-section"); + } else { + newExpanded = new Set(newExpanded); + newExpanded.add("others-section"); + setTimeout(() => { + if (scrollContainerRef.current) { + const currentScrollTop = scrollContainerRef.current.scrollTop; + scrollContainerRef.current.scrollTo({ + top: currentScrollTop + 70, + behavior: "smooth", + }); + } + }, 100); + } + } else { + const othersExpanded = newExpanded.has("others-section"); + if (newExpanded.has(tokenId)) { + newExpanded = othersExpanded + ? new Set(["others-section"]) + : new Set(); + } else { + newExpanded = othersExpanded + ? new Set(["others-section", tokenId]) + : new Set([tokenId]); + } + } + setAssetSelection({ expandedTokens: newExpanded }); + }, + [expandedTokens, setAssetSelection], + ); + + const handleDeselectAll = useCallback(() => { + setAssetSelection({ + selectedChainIds: new Set(), + filter: "custom", + }); + }, [setAssetSelection]); + + const handleDone = useCallback(() => { + widget.goToStep("amount"); + }, [widget]); + + return ( + <> + + +
+
+ { + if (value !== "custom") { + handlePresetClick(value as "all" | "stablecoins" | "native"); + } + }} + > + + Any token + Stablecoins + Native + {filter === "custom" && ( + Custom + )} + + + +
+ +
+
+ {showStickyPopular && mainTokens.length > 0 && ( + + )} +
+ {mainTokens.length > 0 && ( +
+
+ + Popular + +
+ {mainTokens.map((token, index) => ( + toggleExpanded(token.id)} + onToggleToken={() => toggleTokenSelection(token.id)} + onToggleChain={toggleChainSelection} + isFirst={false} + isLast={index === mainTokens.length - 1} + /> + ))} +
+ )} + + {otherTokens.length > 0 && ( +
+
toggleExpanded("others-section")} + > + + Others ({otherTokens.length}) + + +
+ + {expandedTokens.has("others-section") && ( +
+ {otherTokens.map((token, index) => ( + toggleExpanded(token.id)} + onToggleToken={() => toggleTokenSelection(token.id)} + onToggleChain={toggleChainSelection} + isFirst={index === 0} + isLast={index === otherTokens.length - 1} + /> + ))} +
+ )} +
+ )} +
+ + {!showProgressBar && ( +
+ )} + {!showProgressBar && ( +
+ )} +
+ + +
+
+ + + {isProgressBarVisible && ( +
+
+ + Selected / Required + + + + ${selectedAmount.toLocaleString()} + + + {" "} + / ${requiredAmount.toLocaleString()} + + +
+
+
+
+
+ )} + + ); +}; + +export default AssetSelectionContainer; diff --git a/apps/monad/src/components/deposit/components/button-card.tsx b/apps/monad/src/components/deposit/components/button-card.tsx new file mode 100644 index 0000000..0fe6dcc --- /dev/null +++ b/apps/monad/src/components/deposit/components/button-card.tsx @@ -0,0 +1,70 @@ +import { cn } from "@/lib/utils"; + +interface ButtonCardProps { + title: React.ReactNode; + subtitle?: React.ReactNode; + icon: React.ReactNode; + rightIcon?: React.ReactNode; + rightIconClassName?: string; + onClick?: () => void; + disabled?: boolean; + roundedBottom?: boolean; +} + +function ButtonCard({ + title, + subtitle, + icon, + rightIcon, + rightIconClassName, + onClick, + disabled = false, + roundedBottom = true, +}: ButtonCardProps) { + return ( +
+
+ {/* Icon */} +
{icon}
+ + {/* Text Content */} +
+ {typeof title === "string" ? ( + + {title} + + ) : ( + title + )} + {subtitle && + (typeof subtitle === "string" ? ( + + {subtitle} + + ) : ( + subtitle + ))} +
+
+ + {rightIcon && ( +
+ {rightIcon} +
+ )} +
+ ); +} + +export default ButtonCard; diff --git a/apps/monad/src/components/deposit/components/confirmation-container.tsx b/apps/monad/src/components/deposit/components/confirmation-container.tsx new file mode 100644 index 0000000..cc4bc62 --- /dev/null +++ b/apps/monad/src/components/deposit/components/confirmation-container.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState, useMemo } from "react"; +import Image from "next/image"; +import SummaryCard from "./summary-card"; +import { GasPumpIcon, CoinIcon } from "./icons"; +import WidgetHeader from "./widget-header"; +import { ReceiveAmountDisplay } from "./receive-amount-display"; +import { ErrorBanner } from "./error-banner"; +import type { DepositWidgetContextValue } from "../types"; +import { Button } from "../../ui/button"; +import { CardContent } from "../../ui/card"; +import { usdFormatter } from "../../common"; +import { formatTokenBalance } from "@avail-project/nexus-core"; +import { useNexus } from "../../nexus/NexusProvider"; + +interface ConfirmationContainerProps { + widget: DepositWidgetContextValue; + heading?: string; + onClose?: () => void; +} + +const ConfirmationContainer = ({ + widget, + heading, + onClose, +}: ConfirmationContainerProps) => { + const [showSpendDetails, setShowSpendDetails] = useState(false); + const [showFeeDetails, setShowFeeDetails] = useState(false); + const { getFiatValue } = useNexus(); + + const { + confirmationDetails, + feeBreakdown, + handleConfirmOrder, + isProcessing, + txError, + activeIntent, + simulationLoading, + } = widget; + + const isLoading = simulationLoading || !activeIntent; + + const receiveAmount = + confirmationDetails?.receiveAmountAfterSwapUsd?.toFixed(2) ?? "0"; + const timeLabel = confirmationDetails?.estimatedTime ?? "~30s"; + // This is in USD + const amountSpent = confirmationDetails?.amountSpent; + // TODO: Ensure unique names are displayed + const tokenNames = confirmationDetails?.sources + .filter((s) => s) + .map((s) => s?.symbol) + .slice(0, 2) + .join(", "); + const moreCount = + (confirmationDetails?.sources.filter((s) => s).length ?? 0) - 2; + const tokenNamesSummary = + moreCount > 0 ? `${tokenNames} + ${moreCount} more` : tokenNames; + + // Combined filter + map into single iteration (js-combine-iterations) + const sourceDetails = useMemo(() => { + if (!confirmationDetails?.sources) return []; + const result: Array<{ + chainName: string; + chainLogo: string | undefined; + tokenSymbol: string; + tokenDecimals: number; + amount: string; + isDestinationBalance: boolean; + }> = []; + for (const source of confirmationDetails.sources) { + if (!source) continue; + result.push({ + chainName: source.chainName ?? "", + chainLogo: source.chainLogo, + tokenSymbol: source.symbol ?? "", + tokenDecimals: source.decimals ?? 6, + amount: source.balance ?? "0", + isDestinationBalance: source.isDestinationBalance ?? false, + }); + } + return result; + }, [confirmationDetails]); + + return ( + <> + + +
+
+ +
+ } + title="You spend" + subtitle={ + isLoading + ? "Calculating..." + : tokenNamesSummary || "Selected assets" + } + value={String(amountSpent)} + valueSuffix="USD" + showBreakdown={!isLoading && sourceDetails.length > 0} + loading={isLoading} + expanded={showSpendDetails} + onToggleExpand={() => setShowSpendDetails(!showSpendDetails)} + > +
+ {sourceDetails.map((source, index) => { + const amountUsd = getFiatValue( + parseFloat(source.amount), + source.tokenSymbol, + ); + return ( +
+
+ {source.chainLogo && ( + {source.chainName} + )} +
+ + {source.tokenSymbol} + + + {source.chainName} + +
+
+
+ + {usdFormatter.format(amountUsd)}USD + + + {formatTokenBalance(parseFloat(source.amount), { + decimals: source.tokenDecimals, + symbol: source.tokenSymbol, + })} + +
+
+ ); + })} +
+
+ + } + title="Total fees" + value={(confirmationDetails?.totalFeeUsd ?? 0).toFixed(2)} + valueSuffix="USD" + showBreakdown={false} + loading={isLoading} + expanded={false} + /> +
+
+ {txError && widget.status === "error" && ( + + )} + +
+
+ + ); +}; + +export default ConfirmationContainer; diff --git a/apps/monad/src/components/deposit/components/confirmation-loading.tsx b/apps/monad/src/components/deposit/components/confirmation-loading.tsx new file mode 100644 index 0000000..40f76b3 --- /dev/null +++ b/apps/monad/src/components/deposit/components/confirmation-loading.tsx @@ -0,0 +1,40 @@ +"use client"; + +import WidgetHeader from "./widget-header"; +import { CardContent } from "../../ui/card"; +import { Skeleton } from "../../ui/skeleton"; + +interface ConfirmationLoadingProps { + onClose?: () => void; +} + +const ConfirmationLoading = ({ onClose }: ConfirmationLoadingProps) => { + return ( + <> + + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + ); +}; + +export default ConfirmationLoading; diff --git a/apps/monad/src/components/deposit/components/empty-balance-state.tsx b/apps/monad/src/components/deposit/components/empty-balance-state.tsx new file mode 100644 index 0000000..f60f463 --- /dev/null +++ b/apps/monad/src/components/deposit/components/empty-balance-state.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Button } from "../../ui/button"; +import { InfoIcon } from "./icons"; + +type EmptyBalanceStateMode = "no-swap-assets" | "zero-balance"; + +interface EmptyBalanceStateProps { + mode: EmptyBalanceStateMode; + onRefresh?: () => void; +} + +const CONTENT: Record< + EmptyBalanceStateMode, + { title: string; description: string; hint: string } +> = { + "no-swap-assets": { + title: "No Supported Assets Found", + description: + "Your wallet doesn’t hold any assets supported for this deposit. Certain assets on chains such as Monad or MegaETH may be temporarily unavailable for use.", + hint: "Add a supported asset, then refresh balances to continue.", + }, + "zero-balance": { + title: "No available balance to deposit", + description: + "We found swap-supported assets for this wallet, but every available balance is currently zero.", + hint: "Fund one of the supported assets, then refresh balances to continue.", + }, +}; + +export function EmptyBalanceState({ mode, onRefresh }: EmptyBalanceStateProps) { + const content = CONTENT[mode]; + + return ( +
+
+
+ +
+
+

+ {content.title} +

+

+ {content.description} +

+

+ {content.hint} +

+
+ +
+
+ ); +} diff --git a/apps/monad/src/components/deposit/components/error-banner.tsx b/apps/monad/src/components/deposit/components/error-banner.tsx new file mode 100644 index 0000000..868615c --- /dev/null +++ b/apps/monad/src/components/deposit/components/error-banner.tsx @@ -0,0 +1,17 @@ +import { InfoIcon } from "./icons"; + +interface ErrorBannerProps { + message: string; + icon?: boolean; +} + +export function ErrorBanner({ message, icon = true }: ErrorBannerProps) { + return ( +
+ {icon && } + + {message} + +
+ ); +} diff --git a/apps/monad/src/components/deposit/components/icons.tsx b/apps/monad/src/components/deposit/components/icons.tsx new file mode 100644 index 0000000..14450c4 --- /dev/null +++ b/apps/monad/src/components/deposit/components/icons.tsx @@ -0,0 +1,505 @@ +import React from "react"; +import { cn } from "../utils"; + +interface IconProps { + className?: string; + size?: number; + onClick?: () => void; +} + +export function EthereumIcon({ className, size = 32 }: IconProps) { + return ( + + + + + + + + + ); +} + +export function SolanaIcon({ className, size = 32 }: IconProps) { + return ( + + + + + + ); +} + +export function UnplugIcon({ className, size = 21 }: IconProps) { + return ( + + + + ); +} + +export function PlugIcon({ className, size = 21 }: IconProps) { + return ( + + + + ); +} + +export function CloseIcon({ className, size = 24 }: IconProps) { + return ( + + + + ); +} + +export function LeftChevronIcon({ className, size = 24, onClick }: IconProps) { + return ( + + + + ); +} + +export function MetaMaskIcon({ className, size = 32 }: IconProps) { + return ( + + + + + + + + + + + + ); +} + +export function PhantomIcon({ className, size = 32 }: IconProps) { + return ( + + + + ); +} + +export function WalletIcon({ className, size = 32 }: IconProps) { + return ( + + + + ); +} + +export function CoinIcon({ className, size = 24 }: IconProps) { + return ( + + + + ); +} + +export function UpDownArrows({ className, size = 16 }: IconProps) { + return ( + + + + ); +} + +export function RightChevronIcon({ className, size = 24 }: IconProps) { + return ( + + + + ); +} + +export function ClockIcon({ className, size = 20 }: IconProps) { + return ( + + + + ); +} + +export function ArrowBoxUpRightIcon({ className, size = 16 }: IconProps) { + return ( + + + + ); +} + +export function ChevronDownIcon({ className, size = 16 }: IconProps) { + return ( + + + + ); +} + +export function ChevronUpIcon({ className, size = 16 }: IconProps) { + return ( + + + + ); +} + +export function GasPumpIcon({ className, size = 24 }: IconProps) { + return ( + + + + ); +} + +export function CheckIcon({ className, size = 24 }: IconProps) { + return ( + + + + ); +} + +export function AvailLogo({ className }: IconProps) { + return ( + + + + + + + + + + + + + + + + + + ); +} + +export function QrIcon({ className, size = 32 }: IconProps) { + return ( + + + + ); +} + +export function FiatIcon({ className, size = 32 }: IconProps) { + return ( + + + + ); +} + +export function InfoIcon({ className, size = 20 }: IconProps) { + return ( + + + + ); +} diff --git a/apps/monad/src/components/deposit/components/index.ts b/apps/monad/src/components/deposit/components/index.ts new file mode 100644 index 0000000..1c9581c --- /dev/null +++ b/apps/monad/src/components/deposit/components/index.ts @@ -0,0 +1,13 @@ +export { default as AmountContainer } from "./amount-container"; +export { default as ConfirmationContainer } from "./confirmation-container"; +export { default as ConfirmationLoading } from "./confirmation-loading"; +export { default as TransactionStatusContainer } from "./transaction-status-container"; +export { default as TransactionCompleteContainer } from "./transaction-complete-container"; +export { default as TransactionFailedContainer } from "./transaction-failed-container"; +export { default as AssetSelectionContainer } from "./asset-selection-container"; + +// Shared components +export { default as WidgetHeader } from "./widget-header"; +export { default as TokenRow } from "./token-row"; +export { ReceiveAmountDisplay } from "./receive-amount-display"; +export { AmountDisplay } from "./amount-display"; diff --git a/apps/monad/src/components/deposit/components/pay-using.tsx b/apps/monad/src/components/deposit/components/pay-using.tsx new file mode 100644 index 0000000..a284d43 --- /dev/null +++ b/apps/monad/src/components/deposit/components/pay-using.tsx @@ -0,0 +1,136 @@ +import { useMemo, useState, useEffect, useRef } from "react"; +import ButtonCard from "./button-card"; +import { RightChevronIcon, CoinIcon } from "./icons"; +import { Skeleton } from "../../ui/skeleton"; +import { LOADING_SKELETON_DELAY_MS } from "../constants/widget"; + +interface PayUsingProps { + onClick?: () => void; + selectedChainIds: Set; + amount?: string; + swapBalance: Array<{ + symbol: string; + decimals: number; + icon?: string; + breakdown?: Array<{ + chain: { id: number; name: string; logo?: string }; + balance: string; + balanceInFiat?: number; + contractAddress?: `0x${string}`; + }>; + }> | null; +} + +function PayUsing({ + onClick, + selectedChainIds, + amount, + swapBalance, +}: PayUsingProps) { + const [isLoading, setIsLoading] = useState(false); + const previousAmountRef = useRef(undefined); + const hasAmount = Boolean(amount && amount.trim() !== "" && amount !== "0"); + + useEffect(() => { + const hadAmount = Boolean( + previousAmountRef.current && previousAmountRef.current.trim() !== "", + ); + + if (hasAmount && !hadAmount) { + setIsLoading(true); + const timer = setTimeout(() => { + setIsLoading(false); + }, LOADING_SKELETON_DELAY_MS); + return () => clearTimeout(timer); + } + + previousAmountRef.current = amount; + }, [amount, hasAmount]); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { subtitle, selectedCount, totalUsdValue } = useMemo(() => { + const tokenCounts: Record = {}; + let total = 0; + + if (swapBalance) { + swapBalance.forEach((asset) => { + const selectedChains = + asset.breakdown?.filter((c) => + selectedChainIds.has(`${c.contractAddress}-${c.chain.id}`), + ) ?? []; + if (selectedChains.length > 0) { + tokenCounts[asset.symbol] = selectedChains.length; + selectedChains.forEach((c) => { + total += c.balanceInFiat ?? 0; + }); + } + }); + } + + const symbols = Object.keys(tokenCounts); + const count = Object.values(tokenCounts).reduce((a, b) => a + b, 0); + + let text: string; + if (count === 0) { + text = "No tokens selected"; + } else if (symbols.length <= 2) { + text = symbols.join(", "); + } else { + text = `${symbols.slice(0, 2).join(", ")} +${symbols.length - 2} more`; + } + + return { + subtitle: text, + selectedCount: count, + totalUsdValue: total, + }; + }, [selectedChainIds, swapBalance]); + + const renderSubtitle = () => { + if (!hasAmount) { + return ( + + Auto-selected based on amount + + ); + } + + if (isLoading) { + return ; + } + + return ( + + {subtitle} + + ); + }; + + const showEditControls = hasAmount && !isLoading; + + return ( + } + rightIcon={ + showEditControls ? ( +
+ + Edit + + +
+ ) : undefined + } + onClick={showEditControls ? onClick : undefined} + disabled={!showEditControls} + roundedBottom={false} + /> + ); +} + +export default PayUsing; diff --git a/apps/monad/src/components/deposit/components/percentage-selector.tsx b/apps/monad/src/components/deposit/components/percentage-selector.tsx new file mode 100644 index 0000000..935bac2 --- /dev/null +++ b/apps/monad/src/components/deposit/components/percentage-selector.tsx @@ -0,0 +1,59 @@ +"use client"; + +const PERCENTAGE_OPTIONS = [ + { label: "25%", value: 0.25 }, + { label: "50%", value: 0.5 }, + { label: "75%", value: 0.75 }, + { label: "MAX", value: 1 }, +] as const; + +interface PercentageButtonProps { + label: string; + onClick: () => void; + isFirst?: boolean; + isLast?: boolean; +} + +function PercentageButton({ + label, + onClick, + isFirst, + isLast, +}: PercentageButtonProps) { + return ( + + ); +} + +export interface PercentageSelectorProps { + onPercentageClick: (percentage: number) => void; +} + +export function PercentageSelector({ + onPercentageClick, +}: PercentageSelectorProps) { + return ( +
+
+
+ {PERCENTAGE_OPTIONS.map((option, index) => ( + onPercentageClick(option.value)} + isFirst={index === 0} + isLast={index === PERCENTAGE_OPTIONS.length - 1} + /> + ))} +
+
+ ); +} diff --git a/apps/monad/src/components/deposit/components/receive-amount-display.tsx b/apps/monad/src/components/deposit/components/receive-amount-display.tsx new file mode 100644 index 0000000..79748e1 --- /dev/null +++ b/apps/monad/src/components/deposit/components/receive-amount-display.tsx @@ -0,0 +1,68 @@ +import { TokenIcon } from "./token-icon"; +import { ClockIcon } from "./icons"; +import { DEPOSIT_WIDGET_ASSETS } from "../constants/assets"; +import { Skeleton } from "../../ui/skeleton"; +import { usdFormatter } from "../../common"; + +interface ReceiveAmountDisplayProps { + label?: string; + amount: string; + timeLabel?: string; + showUsdValue?: boolean; + showClockIcon?: boolean; + loading?: boolean; + destinationTokenLogo?: string; + depositTargetLogo?: string; +} + +export function ReceiveAmountDisplay({ + label = "You receive", + amount, + timeLabel, + showUsdValue = true, + showClockIcon = true, + loading = false, + destinationTokenLogo, + depositTargetLogo, +}: ReceiveAmountDisplayProps) { + return ( +
+ + {label} + +
+ + {loading ? ( + + ) : ( +

+ {amount} +

+ )} +
+ {(showUsdValue || showClockIcon) && ( +
+ {loading ? ( + + ) : ( +
+ {showUsdValue && `${usdFormatter.format(parseFloat(amount))} in`} + {showClockIcon && ( + + {timeLabel} + + + )} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/monad/src/components/deposit/components/summary-card.tsx b/apps/monad/src/components/deposit/components/summary-card.tsx new file mode 100644 index 0000000..ab5244a --- /dev/null +++ b/apps/monad/src/components/deposit/components/summary-card.tsx @@ -0,0 +1,89 @@ +import { ChevronDownIcon, ChevronUpIcon } from "./icons"; +import { Skeleton } from "../../ui/skeleton"; +import { usdFormatter } from "../../common"; + +interface SummaryCardProps { + icon: React.ReactNode; + title: string; + subtitle?: string; + value: string; + valueSuffix?: string; + showBreakdown?: boolean; + loading?: boolean; + expanded?: boolean; + onToggleExpand?: () => void; + children?: React.ReactNode; +} + +function SummaryCard({ + icon, + title, + subtitle, + value, + valueSuffix, + showBreakdown, + loading = false, + expanded = false, + onToggleExpand, + children, +}: SummaryCardProps) { + return ( +
+
+
+ {icon} +
+ + {title} + + {subtitle && ( + + {subtitle} + + )} +
+
+
+
+ {loading ? ( + + ) : ( + <> + + {valueSuffix === "USD" + ? usdFormatter.format(parseFloat(value)) + : value} + + {valueSuffix && ( + + {valueSuffix} + + )} + + )} +
+ {showBreakdown && ( + + )} +
+
+ {expanded && children && ( +
{children}
+ )} +
+ ); +} + +export default SummaryCard; diff --git a/apps/monad/src/components/deposit/components/token-icon.tsx b/apps/monad/src/components/deposit/components/token-icon.tsx new file mode 100644 index 0000000..a094bca --- /dev/null +++ b/apps/monad/src/components/deposit/components/token-icon.tsx @@ -0,0 +1,53 @@ +import Image from "next/image"; +import { cn } from "../utils"; + +type TokenIconSize = "sm" | "md" | "lg"; + +const SIZE_MAP: Record = { + sm: { token: 24, protocol: 16 }, + md: { token: 32, protocol: 16 }, + lg: { token: 40, protocol: 20 }, +}; + +interface TokenIconProps { + tokenSrc: string; + protocolSrc?: string; + tokenAlt?: string; + protocolAlt?: string; + size?: TokenIconSize; + className?: string; +} + +export function TokenIcon({ + tokenSrc, + protocolSrc, + tokenAlt = "Token", + protocolAlt = "Protocol", + size = "sm", + className, +}: TokenIconProps) { + const dimensions = SIZE_MAP[size]; + + return ( +
+ {tokenAlt} + {protocolSrc && ( + {protocolAlt} + )} +
+ ); +} diff --git a/apps/monad/src/components/deposit/components/token-row.tsx b/apps/monad/src/components/deposit/components/token-row.tsx new file mode 100644 index 0000000..f8f629b --- /dev/null +++ b/apps/monad/src/components/deposit/components/token-row.tsx @@ -0,0 +1,157 @@ +"use client"; + +import Image from "next/image"; +import { ChevronDownIcon } from "./icons"; +import type { Token } from "../types"; +import { getTokenCheckState } from "../utils/asset-helpers"; +import { Checkbox } from "../../ui/checkbox"; +import { usdFormatter } from "../../common"; +import { formatTokenBalance } from "@avail-project/nexus-core"; +import { TOKEN_IMAGES } from "../constants/assets"; +import { + CHAIN_ITEM_HEIGHT_PX, + VERTICAL_LINE_TOP_OFFSET_PX, +} from "../constants/widget"; + +interface TokenRowProps { + token: Token; + selectedChainIds: Set; + isExpanded: boolean; + onToggleExpand: () => void; + onToggleToken: () => void; + onToggleChain: (chainId: string) => void; + isFirst?: boolean; + isLast?: boolean; +} + +export function TokenRow({ + token, + selectedChainIds, + isExpanded, + onToggleExpand, + onToggleToken, + onToggleChain, + isFirst = false, + isLast = false, +}: TokenRowProps) { + const hasMultipleChains = token.chains.length > 1; + const tokenCheckState = getTokenCheckState(token, selectedChainIds); + + return ( +
+ {/* Main token row */} +
+
+ e.stopPropagation()} + /> +
+ {token.symbol} +
+ + {token.symbol} + + + {token.chainsLabel} + +
+
+
+
+
+ + {token.usdValue} + + + {token.amount} + +
+ {hasMultipleChains ? ( + + ) : ( +
+ )} +
+
+ + {/* Expanded chain list */} + {isExpanded && hasMultipleChains && ( +
+
+ {/* Vertical line */} +
+ {/* Chain items */} +
+ {token.chains.map((chain) => ( +
+ {/* Horizontal line */} +
+ {/* Chain content */} +
+
+ onToggleChain(chain.id)} + /> + + {chain.name} + +
+
+ + {usdFormatter.format(chain.usdValue)} + + + {formatTokenBalance(chain.amount, { + decimals: token.decimals, + symbol: token.symbol, + })} + +
+
+
+ ))} +
+
+
+ )} +
+ ); +} + +export default TokenRow; diff --git a/apps/monad/src/components/deposit/components/transaction-complete-container.tsx b/apps/monad/src/components/deposit/components/transaction-complete-container.tsx new file mode 100644 index 0000000..61addc2 --- /dev/null +++ b/apps/monad/src/components/deposit/components/transaction-complete-container.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useState } from "react"; +import WidgetHeader from "./widget-header"; +import { ReceiveAmountDisplay } from "./receive-amount-display"; +import type { DepositWidgetContextValue } from "../types"; +import { ArrowBoxUpRightIcon, ChevronDownIcon, ChevronUpIcon } from "./icons"; +import { CardContent, CardFooter } from "../../ui/card"; +import { Button } from "../../ui/button"; +import { usdFormatter } from "../../common"; + +function formatTimer(seconds: number): string { + const secs = Math.round(seconds); + return `${secs}s`; +} + +function truncateHash(hash: string): string { + if (hash.length <= 13) return hash; + return `${hash.slice(0, 6)}...${hash.slice(-4)}`; +} + +interface TransactionCompleteContainerProps { + widget: DepositWidgetContextValue; + heading?: string; + onClose?: () => void; +} + +const TransactionCompleteContainer = ({ + widget, + heading, + onClose, +}: TransactionCompleteContainerProps) => { + const [showSourceDetails, setShowSourceDetails] = useState(false); + + const handleNewDeposit = () => { + widget.reset(); + widget.goToStep("amount"); + }; + + const handleClose = () => { + widget.reset(); + onClose?.(); + }; + + // Use user's requested amount + const receiveAmountUsd = + widget.confirmationDetails?.receiveAmountAfterSwapUsd?.toFixed(2) ?? "0"; + const completionTime = formatTimer(widget.timer); + + const hasSourceSwaps = widget.sourceSwaps.length > 0; + + // Build deposit transaction URL + const depositTxUrl = + widget.destination.explorerUrl && widget.depositTxHash + ? `${widget.destination.explorerUrl}/tx/${widget.depositTxHash}` + : null; + + return ( + <> + + +
+
+ + + Transaction successful + +
+
+ {/* Collected on sources section - only show when swap was not skipped */} + {!widget.skipSwap && ( +
+
+ + Collected on sources + + +
+
+
+
+ {hasSourceSwaps + ? // Show individual source chain links + widget.sourceSwaps.map((swap, index) => ( + + {swap.chainName} + + + )) + : // No source swaps - show Nexus intent URL + widget.nexusIntentUrl && ( + + View on Nexus Explorer + + + )} +
+
+
+
+ )} + + {/* Deposit transaction */} + {depositTxUrl && ( + + )} +
+ + {/* Fees section */} +
+
+
+ + Total fees + +
+ + {usdFormatter.format( + widget.feeBreakdown.gasUsd + + (widget?.confirmationDetails?.totalFeeUsd ?? 0), + )} + USD + +
+
+
+
+
+ + +
+
+
+ + + ); +}; + +export default TransactionCompleteContainer; diff --git a/apps/monad/src/components/deposit/components/transaction-failed-container.tsx b/apps/monad/src/components/deposit/components/transaction-failed-container.tsx new file mode 100644 index 0000000..776002d --- /dev/null +++ b/apps/monad/src/components/deposit/components/transaction-failed-container.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { CardContent, CardFooter } from "../../ui/card"; +import { Button } from "../../ui/button"; +import WidgetHeader from "./widget-header"; +import { ReceiveAmountDisplay } from "./receive-amount-display"; +import type { DepositWidgetContextValue } from "../types"; + +interface TransactionFailedContainerProps { + widget: DepositWidgetContextValue; + heading?: string; + onClose?: () => void; +} + +const TransactionFailedContainer = ({ + widget, + heading, + onClose, +}: TransactionFailedContainerProps) => { + const handleRetry = () => { + widget.setTxError(null); + widget.reset(); + widget.goToStep("amount"); + }; + + const handleClose = () => { + widget.reset(); + onClose?.(); + }; + + return ( + <> + + +
+
+ +
+

+ {widget?.txError ?? + `It's not you, it's us. Everything seems to be in order from your + side, our engineers might have broken something.`} +

+

+ Retry in a bit? +

+
+
+
+ + +
+
+
+ + + ); +}; + +export default TransactionFailedContainer; diff --git a/apps/monad/src/components/deposit/components/transaction-status-container.tsx b/apps/monad/src/components/deposit/components/transaction-status-container.tsx new file mode 100644 index 0000000..ad02a50 --- /dev/null +++ b/apps/monad/src/components/deposit/components/transaction-status-container.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { CardContent, CardFooter } from "../../ui/card"; +import WidgetHeader from "./widget-header"; +import { AmountDisplay } from "./amount-display"; +import { TransactionSteps, type SimplifiedStep } from "./transaction-steps"; +import type { DepositWidgetContextValue } from "../types"; +import { useMemo } from "react"; +import { usdFormatter } from "../../common"; +import { useNexus } from "../../nexus/NexusProvider"; + +interface TransactionStatusContainerProps { + widget: DepositWidgetContextValue; + heading?: string; + onClose?: () => void; +} + +function TransferIndicator({ isProcessing }: { isProcessing: boolean }) { + const baseClasses = "w-2 h-2 transition-all duration-300"; + + if (isProcessing) { + return ( + <> +
+
+
+
+
+ + ); + } + + return ( + <> +
+
+
+
+
+ + ); +} + +const TransactionStatusContainer = ({ + widget, + heading, + onClose, +}: TransactionStatusContainerProps) => { + const { steps, confirmationDetails, activeIntent, isProcessing } = widget; + + const receiveAmount = confirmationDetails?.receiveAmountAfterSwap ?? "0"; + const receiveTokenSymbol = confirmationDetails?.receiveTokenSymbol ?? "USDC"; + const destinationChainName = + confirmationDetails?.destinationChainName ?? + activeIntent?.intent?.destination?.chain?.name ?? + "destination"; + const sourceCount = widget.skipSwap + ? 0 // No source assets when using existing balance + : (activeIntent?.intent?.sources?.length ?? 0); + const spendAmountUsd = widget?.confirmationDetails?.amountSpent ?? 0; + + // Derive simplified steps from actual SDK events + const simplifiedSteps = useMemo((): SimplifiedStep[] => { + // When swap is skipped, only show deposit transaction step + if (widget.skipSwap) { + return [ + { + id: "deposit-transaction", + label: "Deposit transaction", + completed: widget.isSuccess, + }, + ]; + } + + const hasRffId = steps.some((s) => s.step.type === "RFF_ID" && s.completed); + // Use SOURCE_SWAP_HASH for "Collecting on Source" step + const hasSourceSwapHash = steps.some( + (s) => s.step.type === "DESTINATION_SWAP_HASH" && s.completed, + ); + // Deposit transaction only completes when the entire transaction succeeds + const isTransactionComplete = widget.isSuccess; + + return [ + { + id: "intent-verification", + label: "Intent Verification", + completed: hasRffId, + }, + { + id: "collecting-on-source", + label: "Collecting on Source", + completed: hasSourceSwapHash, + }, + { + id: "deposit-transaction", + label: "Deposit transaction", + completed: isTransactionComplete, + }, + ]; + }, [steps, widget.isSuccess, widget.skipSwap]); + + // Calculate progress based on completed steps + const progress = useMemo(() => { + const completedCount = simplifiedSteps.filter((s) => s.completed).length; + const totalSteps = simplifiedSteps.length; + return Math.round((completedCount / totalSteps) * 100); + }, [simplifiedSteps]); + + const getStatusMessage = () => { + if (widget.isError && widget.txError) { + return {widget.txError}; + } + if (widget.isSuccess) return "Transaction complete"; + if (widget.isProcessing) return "Processing transaction..."; + return "Verifying intent"; + }; + + return ( + <> + + +
+
+
+ +
+ +
+ +
+
+
+
+
+
+ {getStatusMessage()} +
+ +
+ + + + ); +}; + +export default TransactionStatusContainer; diff --git a/apps/monad/src/components/deposit/components/transaction-steps.tsx b/apps/monad/src/components/deposit/components/transaction-steps.tsx new file mode 100644 index 0000000..8ace31c --- /dev/null +++ b/apps/monad/src/components/deposit/components/transaction-steps.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { AnimatedSpinner } from "../../ui/animated-spinner"; +import { CheckIcon } from "./icons"; + +export interface SimplifiedStep { + id: string; + label: string; + completed: boolean; + /** If true, this step will be grouped with the next step (no separator, only gap) */ + groupWithNext?: boolean; +} + +interface TransactionStepsProps { + steps: SimplifiedStep[]; +} + +type StepStatus = "completed" | "in-progress" | "pending"; + +function getStepStatus( + steps: SimplifiedStep[], + stepIndex: number, +): StepStatus { + const step = steps[stepIndex]; + if (step?.completed) return "completed"; + + // Find the first incomplete step + const firstIncompleteIndex = steps.findIndex((s) => !s.completed); + if (stepIndex === firstIncompleteIndex) return "in-progress"; + + return "pending"; +} + +export function TransactionSteps({ steps }: TransactionStepsProps) { + // Group steps based on groupWithNext property + const groupedSteps: SimplifiedStep[][] = []; + let currentGroup: SimplifiedStep[] = []; + + steps.forEach((step) => { + currentGroup.push(step); + if (!step.groupWithNext) { + groupedSteps.push(currentGroup); + currentGroup = []; + } + }); + if (currentGroup.length > 0) { + groupedSteps.push(currentGroup); + } + + return ( +
+ {groupedSteps.map((group, groupIndex) => { + const isLastGroup = groupIndex === groupedSteps.length - 1; + + return ( +
+ {group.map((step) => { + const stepIndex = steps.findIndex((s) => s.id === step.id); + const status = getStepStatus(steps, stepIndex); + + return ( +
+
+ {status === "completed" && ( + + )} + {status === "in-progress" && ( + + )} + {status === "pending" && ( +
+ )} +
+ + {step.label} + +
+ ); + })} +
+ ); + })} +
+ ); +} diff --git a/apps/monad/src/components/deposit/components/widget-header.tsx b/apps/monad/src/components/deposit/components/widget-header.tsx new file mode 100644 index 0000000..630bbae --- /dev/null +++ b/apps/monad/src/components/deposit/components/widget-header.tsx @@ -0,0 +1,52 @@ +"use client"; + +import Image from "next/image"; +import { CloseIcon, LeftChevronIcon } from "./icons"; + +interface WidgetHeaderProps { + title: string; + depositTargetLogo?: string; + onBack?: () => void; + onClose?: () => void; +} + +const WidgetHeader = ({ + title, + onBack, + onClose, + depositTargetLogo, +}: WidgetHeaderProps) => { + return ( +
+ {onBack ? ( + + ) : depositTargetLogo ? ( + + ) : ( +
+ )} +

+ {title} +

+ +
+ ); +}; + +export default WidgetHeader; diff --git a/apps/monad/src/components/deposit/constants/assets.ts b/apps/monad/src/components/deposit/constants/assets.ts new file mode 100644 index 0000000..90aca25 --- /dev/null +++ b/apps/monad/src/components/deposit/constants/assets.ts @@ -0,0 +1,44 @@ +export const DEPOSIT_WIDGET_ASSETS = { + tokens: { + USDC: "/usdc.svg", + ETH: "/ethereum.svg", + }, + protocols: { + aave: "/aave.svg", + }, + wallets: { + metamask: "/metamask.svg", + phantom: "/phantom.svg", + }, + // features now use React icon components instead of SVG files +} as const; + +export const TOKEN_IMAGES: Record = { + USDC: "https://coin-images.coingecko.com/coins/images/6319/large/usdc.png", + USDT: "https://coin-images.coingecko.com/coins/images/35023/large/USDT.png", + "USDâ‚®0": + "https://coin-images.coingecko.com/coins/images/35023/large/USDT.png", + WETH: "https://assets.coingecko.com/coins/images/279/large/ethereum.png?1595348880", + USDS: "https://assets.coingecko.com/coins/images/39926/standard/usds.webp?1726666683", + SOPH: "https://assets.coingecko.com/coins/images/38680/large/sophon_logo_200.png", + KAIA: "https://assets.coingecko.com/asset_platforms/images/9672/large/kaia.png", + BNB: "https://assets.coingecko.com/coins/images/825/large/bnb-icon2_2x.png", + // Add ETH as fallback for any ETH-related tokens + ETH: "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png?1696501628", + // Add common token fallbacks + POL: "https://coin-images.coingecko.com/coins/images/32440/standard/polygon.png", + AVAX: "https://assets.coingecko.com/coins/images/12559/standard/Avalanche_Circle_RedWhite_Trans.png", + FUEL: "https://coin-images.coingecko.com/coins/images/279/large/ethereum.png", + HYPE: "https://assets.coingecko.com/asset_platforms/images/243/large/hyperliquid.png", + // Popular swap tokens + DAI: "https://coin-images.coingecko.com/coins/images/9956/large/Badge_Dai.png?1696509996", + UNI: "https://coin-images.coingecko.com/coins/images/12504/large/uni.jpg?1696512319", + AAVE: "https://coin-images.coingecko.com/coins/images/12645/large/AAVE.png?1696512452", + LDO: "https://coin-images.coingecko.com/coins/images/13573/large/Lido_DAO.png?1696513326", + PEPE: "https://coin-images.coingecko.com/coins/images/29850/large/pepe-token.jpeg?1696528776", + OP: "https://coin-images.coingecko.com/coins/images/25244/large/Optimism.png?1696524385", + ZRO: "https://coin-images.coingecko.com/coins/images/28206/large/ftxG9_TJ_400x400.jpeg?1696527208", + OM: "https://assets.coingecko.com/coins/images/12151/standard/OM_Token.png?1696511991", + KAITO: + "https://assets.coingecko.com/coins/images/54411/standard/Qm4DW488_400x400.jpg", +}; diff --git a/apps/monad/src/components/deposit/constants/widget.ts b/apps/monad/src/components/deposit/constants/widget.ts new file mode 100644 index 0000000..b169323 --- /dev/null +++ b/apps/monad/src/components/deposit/constants/widget.ts @@ -0,0 +1,31 @@ +export const SCROLL_THRESHOLD_PX = 50; +export const PROGRESS_BAR_ANIMATION_DELAY_MS = 50; +export const PROGRESS_BAR_EXIT_DURATION_MS = 300; + +// Timing +export const LOADING_SKELETON_DELAY_MS = 600; +export const CHARACTER_ANIMATION_DURATION_MS = 400; +export const SHINE_ANIMATION_DURATION_MS = 500; +export const SIMULATION_POLL_INTERVAL_MS = 15000; + +// Safety & Calculations +export const BALANCE_SAFETY_MARGIN = 0.92; // Keep 8% as safety buffer +export const DEFAULT_TOKEN_DECIMALS = 6; + +// Layout +export const CHAIN_ITEM_HEIGHT_PX = 49; +export const VERTICAL_LINE_TOP_OFFSET_PX = 48; +export const MAX_INPUT_WIDTH_PX = 300; + +// Asset Selection +export const STABLECOIN_SYMBOLS = ["USDC", "USDT", "DAI", "TUSD", "USDP"] as const; + +// Animation classes (for reference, actual classes defined in CSS) +export const ANIMATION_CLASSES = { + slideInFromRight: "animate-slide-in-from-right", + slideInFromLeft: "animate-slide-in-from-left", + digitIn: "animate-digit-in", + glareShine: "animate-glare-shine", + transferWave: "animate-transfer-wave", + progress: "animate-progress", +} as const; diff --git a/apps/monad/src/components/deposit/hooks/use-asset-selection.ts b/apps/monad/src/components/deposit/hooks/use-asset-selection.ts new file mode 100644 index 0000000..e5d2e10 --- /dev/null +++ b/apps/monad/src/components/deposit/hooks/use-asset-selection.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +import type { AssetSelectionState } from "../types"; +import type { UserAsset } from "@avail-project/nexus-core"; + +/** + * Creates fresh initial asset selection state + */ +export const createInitialAssetSelection = (): AssetSelectionState => ({ + selectedChainIds: new Set(), + filter: "all", + expandedTokens: new Set(), +}); + +/** + * Hook for managing asset selection state in the deposit widget. + * Handles selection of tokens/chains for cross-chain swaps. + */ +export function useAssetSelection(swapBalance: UserAsset[] | null) { + const [assetSelection, setAssetSelectionState] = + useState(createInitialAssetSelection); + const hasUserModifiedSelection = useRef(false); + + // Extract primitive value for effect dependency (rerender-dependencies) + const selectedChainIdsCount = assetSelection.selectedChainIds.size; + + // Auto-select all assets when swapBalance first loads + useEffect(() => { + if ( + swapBalance && + selectedChainIdsCount === 0 && + !hasUserModifiedSelection.current + ) { + const allChainIds = new Set(); + swapBalance.forEach((asset) => { + if (asset.breakdown) { + asset.breakdown.forEach((b) => { + if (b.chain && b.balance) { + allChainIds.add(`${b.contractAddress}-${b.chain.id}`); + } + }); + } + }); + if (allChainIds.size > 0) { + setAssetSelectionState({ + selectedChainIds: allChainIds, + filter: "all", + expandedTokens: new Set(), + }); + } + } + }, [swapBalance, selectedChainIdsCount]); + + const setAssetSelection = useCallback( + (update: Partial) => { + hasUserModifiedSelection.current = true; + setAssetSelectionState((prev) => ({ ...prev, ...update })); + }, + [] + ); + + const resetAssetSelection = useCallback(() => { + hasUserModifiedSelection.current = false; + setAssetSelectionState(createInitialAssetSelection()); + }, []); + + return { + assetSelection, + setAssetSelection, + resetAssetSelection, + }; +} diff --git a/apps/monad/src/components/deposit/hooks/use-deposit-computed.ts b/apps/monad/src/components/deposit/hooks/use-deposit-computed.ts new file mode 100644 index 0000000..d8673ee --- /dev/null +++ b/apps/monad/src/components/deposit/hooks/use-deposit-computed.ts @@ -0,0 +1,434 @@ +"use client"; + +import { useMemo } from "react"; +import type { DestinationConfig, AssetSelectionState } from "../types"; +import type { + OnSwapIntentHookData, + NexusSDK, + UserAsset, +} from "@avail-project/nexus-core"; +import { CHAIN_METADATA, formatTokenBalance } from "@avail-project/nexus-core"; +import { usdFormatter } from "../../common"; +import type { SwapSkippedData } from "./use-deposit-state"; + +const NATIVE_TOKEN_PLACEHOLDER_ADDRESS = + "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +function normalizeAddress(address?: string | null): string { + return (address ?? "").toLowerCase(); +} + +function isNativeLikeAddress(address?: string | null): boolean { + const normalized = normalizeAddress(address); + return ( + normalized === NATIVE_TOKEN_PLACEHOLDER_ADDRESS || + normalized === ZERO_ADDRESS + ); +} + +function resolvePricingSymbol(params: { + chainId: number; + contractAddress?: string | null; + fallbackSymbol: string; +}): string { + const { chainId, contractAddress, fallbackSymbol } = params; + if (!isNativeLikeAddress(contractAddress)) { + return fallbackSymbol; + } + + const nativeSymbol = + CHAIN_METADATA[chainId as keyof typeof CHAIN_METADATA]?.nativeCurrency + ?.symbol; + return nativeSymbol ?? fallbackSymbol; +} + +interface UseDepositComputedProps { + swapBalance: UserAsset[] | null; + assetSelection: AssetSelectionState; + activeIntent: OnSwapIntentHookData | null; + destination: DestinationConfig; + inputAmount: string | undefined; + exchangeRate: Record | null; + getFiatValue: (amount: number, symbol: string) => number; + actualGasFeeUsd: number | null; + swapSkippedData: SwapSkippedData | null; + skipSwap: boolean; + nexusSDK: NexusSDK | null; +} + +/** + * Available asset item from swap balance + */ +export interface AvailableAsset { + chainId: number; + tokenAddress: `0x${string}`; + decimals: number; + symbol: string; + balance: string; + balanceInFiat?: number; + tokenLogo?: string; + chainLogo?: string; + chainName?: string; +} + +/** + * Hook for computing derived values from deposit widget state. + * Separates computation logic from main hook for better maintainability. + */ +export function useDepositComputed(props: UseDepositComputedProps) { + const { + swapBalance, + assetSelection, + activeIntent, + destination, + inputAmount, + exchangeRate, + getFiatValue, + actualGasFeeUsd, + swapSkippedData, + skipSwap, + nexusSDK, + } = props; + + /** + * Flatten swap balance into a sorted list of available assets + */ + const availableAssets = useMemo(() => { + if (!swapBalance) return []; + const items: AvailableAsset[] = []; + + for (const asset of swapBalance) { + if (!asset?.breakdown?.length) continue; + for (const breakdown of asset.breakdown) { + if (!breakdown?.chain?.id || !breakdown.balance) continue; + const numericBalance = Number.parseFloat(breakdown.balance); + if (!Number.isFinite(numericBalance) || numericBalance <= 0) continue; + + items.push({ + chainId: breakdown.chain.id, + tokenAddress: breakdown.contractAddress as `0x${string}`, + decimals: breakdown.decimals ?? asset.decimals, + symbol: asset.symbol, + balance: breakdown.balance, + balanceInFiat: breakdown.balanceInFiat, + tokenLogo: asset.icon, + chainLogo: breakdown.chain.logo, + chainName: breakdown.chain.name, + }); + } + } + return items.toSorted( + (a, b) => (b.balanceInFiat ?? 0) - (a.balanceInFiat ?? 0), + ); + }, [swapBalance]); + + /** + * Total USD value of selected assets + */ + const totalSelectedBalance = useMemo( + () => + availableAssets.reduce((sum, asset) => { + const key = `${asset.tokenAddress}-${asset.chainId}`; + if (assetSelection.selectedChainIds.has(key)) { + return sum + (asset.balanceInFiat ?? 0); + } + return sum; + }, 0), + [availableAssets, assetSelection.selectedChainIds], + ); + + /** + * Total balance across all assets + */ + const totalBalance = useMemo(() => { + const balance = + swapBalance?.reduce( + (acc, balance) => acc + parseFloat(balance.balance), + 0, + ) ?? 0; + const usdBalance = + swapBalance?.reduce((acc, balance) => acc + balance.balanceInFiat, 0) ?? + 0; + return { balance, usdBalance }; + }, [swapBalance]); + + /** + * User's existing balance on destination chain + */ + const destinationBalance = useMemo(() => { + if (!nexusSDK || !swapBalance || !destination) return undefined; + return swapBalance + ?.find((token) => token.symbol === destination.tokenSymbol) + ?.breakdown?.find((chain) => chain.chain?.id === destination.chainId); + }, [swapBalance, nexusSDK, destination]); + + /** + * Confirmation screen details computed from intent or skipped swap data + */ + const confirmationDetails = useMemo(() => { + // Handle swap skipped case - compute from swapSkippedData + if (swapSkippedData && skipSwap) { + const { destination: destData, gas } = swapSkippedData; + + // Format the token amount from raw units + const rawAmount = Number.parseFloat(destData.amount); + const tokenAmount = rawAmount / Math.pow(10, destData.token.decimals); + const receiveAmountUsd = getFiatValue(tokenAmount, destData.token.symbol); + + // Format for display + const receiveAmountAfterSwap = `${tokenAmount.toFixed(2)} ${destData.token.symbol}`; + + // Gas fee calculation from swapSkippedData + const estimatedFeeWei = Number.parseFloat(gas.estimatedFee); + const estimatedFeeEth = estimatedFeeWei / 1e18; + const gasFeeUsd = getFiatValue( + estimatedFeeEth, + destination.gasTokenSymbol ?? "ETH", + ); + + return { + sourceLabel: destination.label ?? "Deposit", + sources: [], + gasTokenSymbol: destination.gasTokenSymbol, + estimatedTime: destination.estimatedTime ?? "~30s", + amountSpent: receiveAmountUsd, + totalFeeUsd: gasFeeUsd, + receiveTokenSymbol: destData.token.symbol, + receiveAmountAfterSwapUsd: receiveAmountUsd, + receiveAmountAfterSwap, + receiveTokenLogo: destination.tokenLogo, + receiveTokenChain: destData.chain.id, + destinationChainName: destData.chain.name, + }; + } + + if (!activeIntent || !nexusSDK) return null; + + // Use user's requested amount (from input), not SDK's optimized bridge amount + const receiveAmountUsd = inputAmount + ? parseFloat(inputAmount.replace(/,/g, "")) + : 0; + + // Convert USD amount to token amount for display + const tokenExchangeRate = exchangeRate?.[destination.tokenSymbol] ?? 1; + const receiveTokenAmount = receiveAmountUsd / tokenExchangeRate; + + const receiveAmountAfterSwap = formatTokenBalance( + receiveTokenAmount.toString(), + { + symbol: destination.tokenSymbol, + decimals: destination.tokenDecimals, + }, + ); + + // Build sources array from intent sources + const sources: Array<{ + chainId: number; + tokenAddress: `0x${string}`; + decimals: number; + symbol: string; + balance: string; + balanceInFiat?: number; + tokenLogo?: string; + chainLogo?: string; + chainName?: string; + isDestinationBalance?: boolean; + }> = []; + + activeIntent.intent.sources.forEach((source) => { + const sourcePricingSymbol = resolvePricingSymbol({ + chainId: source.chain.id, + contractAddress: source.token.contractAddress, + fallbackSymbol: source.token.symbol, + }); + const sourceAmount = Number.parseFloat(source.amount); + const sourceAmountUsd = Number.isFinite(sourceAmount) + ? getFiatValue(sourceAmount, sourcePricingSymbol) + : 0; + + const matchingAsset = availableAssets.find( + (asset) => + asset.chainId === source.chain.id && + (normalizeAddress(asset.tokenAddress) === + normalizeAddress(source.token.contractAddress) || + asset.symbol.toUpperCase() === source.token.symbol.toUpperCase()), + ); + + if (matchingAsset) { + sources.push({ + ...matchingAsset, + symbol: sourcePricingSymbol, + balance: source.amount, + balanceInFiat: sourceAmountUsd, + isDestinationBalance: false, + }); + } else { + sources.push({ + chainId: source.chain.id, + tokenAddress: source.token.contractAddress as `0x${string}`, + decimals: source.token.decimals, + symbol: sourcePricingSymbol, + balance: source.amount, + balanceInFiat: sourceAmountUsd, + chainLogo: source.chain.logo, + chainName: source.chain.name, + isDestinationBalance: false, + }); + } + }); + + // Calculate total spent from cross-chain sources + const totalAmountSpentUsd = activeIntent.intent.sources?.reduce( + (acc, source) => { + const sourcePricingSymbol = resolvePricingSymbol({ + chainId: source.chain.id, + contractAddress: source.token.contractAddress, + fallbackSymbol: source.token.symbol, + }); + const amount = Number.parseFloat(source.amount); + const usdAmount = Number.isFinite(amount) + ? getFiatValue(amount, sourcePricingSymbol) + : 0; + return acc + usdAmount; + }, + 0, + ); + + // Get the actual amount arriving on destination (AFTER fees) + const destinationAmount = Number.parseFloat( + activeIntent.intent.destination?.amount ?? "0", + ); + const destinationPricingSymbol = resolvePricingSymbol({ + chainId: + activeIntent.intent.destination?.chain?.id ?? destination.chainId, + contractAddress: activeIntent.intent.destination?.token?.contractAddress, + fallbackSymbol: + activeIntent.intent.destination?.token?.symbol ?? + destination.tokenSymbol, + }); + const destinationAmountUsd = getFiatValue( + destinationAmount, + destinationPricingSymbol, + ); + + // Calculate bridge/protocol fees + const totalFeeUsd = Math.max(0, totalAmountSpentUsd - destinationAmountUsd); + + // Calculate destination balance used + const usedFromDestinationUsd = Math.max( + 0, + receiveAmountUsd - destinationAmountUsd, + ); + + if (usedFromDestinationUsd > 0.01 && destinationBalance) { + const usedTokenAmount = usedFromDestinationUsd / tokenExchangeRate; + const chainMeta = + CHAIN_METADATA[destination.chainId as keyof typeof CHAIN_METADATA]; + + sources.push({ + chainId: destination.chainId, + tokenAddress: destination.tokenAddress, + decimals: destination.tokenDecimals, + symbol: destination.tokenSymbol, + balance: usedTokenAmount.toString(), + balanceInFiat: usedFromDestinationUsd, + tokenLogo: destination.tokenLogo, + chainLogo: chainMeta?.logo, + chainName: chainMeta?.name, + isDestinationBalance: true, + }); + } + + const actualAmountSpent = totalAmountSpentUsd + usedFromDestinationUsd; + + return { + sourceLabel: destination.label ?? "Deposit", + sources, + gasTokenSymbol: destination.gasTokenSymbol, + estimatedTime: destination.estimatedTime ?? "~30s", + amountSpent: actualAmountSpent, + totalFeeUsd, + receiveTokenSymbol: destination.tokenSymbol, + receiveAmountAfterSwapUsd: receiveAmountUsd, + receiveAmountAfterSwap, + receiveTokenLogo: destination.tokenLogo, + receiveTokenChain: destination.chainId, + destinationChainName: activeIntent.intent.destination?.chain?.name, + }; + }, [ + activeIntent, + nexusSDK, + destination, + availableAssets, + inputAmount, + exchangeRate, + getFiatValue, + destinationBalance, + swapSkippedData, + skipSwap, + ]); + + /** + * Gas fee breakdown for display + */ + const feeBreakdown = useMemo(() => { + // Use actual gas fee from receipt if available + if (actualGasFeeUsd !== null) { + const gasFormatted = usdFormatter.format(actualGasFeeUsd); + return { + totalGasFee: actualGasFeeUsd, + gasUsd: actualGasFeeUsd, + gasFormatted, + }; + } + + // Use gas from swapSkippedData when swap is skipped + if (swapSkippedData && skipSwap) { + const { gas } = swapSkippedData; + const estimatedFeeWei = Number.parseFloat(gas.estimatedFee); + const estimatedFeeEth = estimatedFeeWei / 1e18; + const gasUsd = getFiatValue( + estimatedFeeEth, + destination.gasTokenSymbol ?? "ETH", + ); + const gasFormatted = usdFormatter.format(gasUsd); + return { totalGasFee: gasUsd, gasUsd, gasFormatted }; + } + + // Otherwise use estimated gas from intent + if (!activeIntent?.intent?.destination?.gas) { + return { totalGasFee: 0, gasUsd: 0, gasFormatted: "0" }; + } + + const gas = activeIntent.intent.destination.gas; + const gasAmount = parseFloat(gas.amount); + const gasSymbol = resolvePricingSymbol({ + chainId: + activeIntent.intent.destination?.chain?.id ?? destination.chainId, + contractAddress: gas.token?.contractAddress, + fallbackSymbol: gas.token?.symbol ?? destination.gasTokenSymbol ?? "ETH", + }); + const gasUsd = getFiatValue(gasAmount, gasSymbol); + const gasFormatted = usdFormatter.format(gasUsd); + + return { totalGasFee: gasUsd, gasUsd, gasFormatted }; + }, [ + activeIntent, + getFiatValue, + actualGasFeeUsd, + swapSkippedData, + skipSwap, + destination.chainId, + destination.gasTokenSymbol, + ]); + + return { + availableAssets, + totalSelectedBalance, + totalBalance, + destinationBalance, + confirmationDetails, + feeBreakdown, + }; +} diff --git a/apps/monad/src/components/deposit/hooks/use-deposit-state.ts b/apps/monad/src/components/deposit/hooks/use-deposit-state.ts new file mode 100644 index 0000000..75e0249 --- /dev/null +++ b/apps/monad/src/components/deposit/hooks/use-deposit-state.ts @@ -0,0 +1,233 @@ +"use client"; + +import { useReducer } from "react"; +import type { + WidgetStep, + TransactionStatus, + DepositInputs, + NavigationDirection, +} from "../types"; +import type { OnSwapIntentHookData } from "@avail-project/nexus-core"; + +/** + * Source swap info collected during transaction execution + */ +export interface SourceSwapInfo { + chainId: number; + chainName: string; + explorerUrl: string; +} + +/** + * Data from SDK when swap is skipped (using existing destination balance) + */ +export interface SwapSkippedData { + destination: { + amount: string; + chain: { id: number; name: string }; + token: { + contractAddress: `0x${string}`; + decimals: number; + symbol: string; + }; + }; + input: { + amount: string; + token: { + contractAddress: `0x${string}`; + decimals: number; + symbol: string; + }; + }; + gas: { + required: string; + price: string; + estimatedFee: string; + }; +} + +/** + * Core deposit widget state + */ +export interface DepositState { + step: WidgetStep; + inputs: DepositInputs; + status: TransactionStatus; + explorerUrls: { + sourceExplorerUrl: string | null; + destinationExplorerUrl: string | null; + }; + sourceSwaps: SourceSwapInfo[]; + nexusIntentUrl: string | null; + depositTxHash: string | null; + actualGasFeeUsd: number | null; + error: string | null; + lastResult: unknown; + navigationDirection: NavigationDirection; + simulation: { + swapIntent: OnSwapIntentHookData; + } | null; + simulationLoading: boolean; + receiveAmount: string | null; + skipSwap: boolean; + intentReady: boolean; + swapSkippedData: SwapSkippedData | null; +} + +/** + * Action types for state reducer + */ +export type DepositAction = + | { + type: "setStep"; + payload: { step: WidgetStep; direction: NavigationDirection }; + } + | { type: "setInputs"; payload: Partial } + | { type: "setStatus"; payload: TransactionStatus } + | { + type: "setExplorerUrls"; + payload: Partial; + } + | { type: "setError"; payload: string | null } + | { type: "setLastResult"; payload: unknown } + | { + type: "setSimulation"; + payload: { + swapIntent: OnSwapIntentHookData; + }; + } + | { type: "setSimulationLoading"; payload: boolean } + | { type: "setReceiveAmount"; payload: string | null } + | { type: "setSkipSwap"; payload: boolean } + | { type: "setIntentReady"; payload: boolean } + | { type: "setSwapSkippedData"; payload: SwapSkippedData | null } + | { type: "addSourceSwap"; payload: SourceSwapInfo } + | { type: "setNexusIntentUrl"; payload: string | null } + | { type: "setDepositTxHash"; payload: string | null } + | { type: "setActualGasFeeUsd"; payload: number | null } + | { type: "reset" }; + +/** + * Step history for back navigation + */ +export const STEP_HISTORY: Record = { + amount: null, + confirmation: "amount", + "transaction-status": null, + "transaction-complete": null, + "transaction-failed": null, + "asset-selection": "amount", +} as const; + +/** + * Creates fresh initial state + */ +export const createInitialState = (): DepositState => ({ + step: "amount", + inputs: { + amount: undefined, + selectedToken: "USDC", + }, + status: "idle", + explorerUrls: { + sourceExplorerUrl: null, + destinationExplorerUrl: null, + }, + sourceSwaps: [], + nexusIntentUrl: null, + depositTxHash: null, + actualGasFeeUsd: null, + error: null, + lastResult: null, + navigationDirection: null, + simulation: null, + simulationLoading: false, + receiveAmount: null, + skipSwap: false, + intentReady: false, + swapSkippedData: null, +}); + +/** + * State reducer for deposit widget + */ +function depositReducer(state: DepositState, action: DepositAction): DepositState { + switch (action.type) { + case "setStep": + return { + ...state, + step: action.payload.step, + navigationDirection: action.payload.direction, + }; + case "setInputs": { + const newInputs = { ...state.inputs, ...action.payload }; + let newStatus = state.status; + if ( + state.status === "idle" && + newInputs.amount && + Number.parseFloat(newInputs.amount) > 0 + ) { + newStatus = "previewing"; + } + if ( + state.status === "previewing" && + (!newInputs.amount || Number.parseFloat(newInputs.amount) <= 0) + ) { + newStatus = "idle"; + } + // Clear error when user changes inputs + return { ...state, inputs: newInputs, status: newStatus, error: null }; + } + case "setStatus": + return { ...state, status: action.payload }; + case "setExplorerUrls": + return { + ...state, + explorerUrls: { ...state.explorerUrls, ...action.payload }, + }; + case "setError": + return { ...state, error: action.payload }; + case "setLastResult": + return { ...state, lastResult: action.payload }; + case "setSimulation": + return { + ...state, + simulation: action.payload, + }; + case "setSimulationLoading": + return { ...state, simulationLoading: action.payload }; + case "setReceiveAmount": + return { ...state, receiveAmount: action.payload }; + case "setSkipSwap": + return { ...state, skipSwap: action.payload }; + case "setIntentReady": + return { ...state, intentReady: action.payload }; + case "setSwapSkippedData": + return { ...state, swapSkippedData: action.payload }; + case "addSourceSwap": + return { ...state, sourceSwaps: [...state.sourceSwaps, action.payload] }; + case "setNexusIntentUrl": + return { ...state, nexusIntentUrl: action.payload }; + case "setDepositTxHash": + return { ...state, depositTxHash: action.payload }; + case "setActualGasFeeUsd": + return { ...state, actualGasFeeUsd: action.payload }; + case "reset": + return createInitialState(); + default: + return state; + } +} + +/** + * Hook for managing deposit widget state via reducer + */ +export function useDepositState() { + const [state, dispatch] = useReducer( + depositReducer, + undefined, + createInitialState + ); + + return { state, dispatch }; +} diff --git a/apps/monad/src/components/deposit/hooks/use-deposit-widget.ts b/apps/monad/src/components/deposit/hooks/use-deposit-widget.ts new file mode 100644 index 0000000..ce109f0 --- /dev/null +++ b/apps/monad/src/components/deposit/hooks/use-deposit-widget.ts @@ -0,0 +1,637 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + WidgetStep, + DepositWidgetContextValue, + DepositInputs, + DestinationConfig, +} from "../types"; +import { + NEXUS_EVENTS, + CHAIN_METADATA, + type SwapStepType, + type ExecuteParams, + type SwapAndExecuteParams, + type SwapAndExecuteResult, + parseUnits, +} from "@avail-project/nexus-core"; +import { + SWAP_EXPECTED_STEPS, + useNexusError, + usePolling, + useStopwatch, + useTransactionSteps, +} from "../../common"; +import { type Address, type Hex, formatEther } from "viem"; +import { useAccount } from "wagmi"; +import { useNexus } from "../../nexus/NexusProvider"; +import { SIMULATION_POLL_INTERVAL_MS } from "../constants/widget"; + +// Import extracted hooks +import { + useDepositState, + STEP_HISTORY, + type SwapSkippedData, +} from "./use-deposit-state"; +import { useAssetSelection } from "./use-asset-selection"; +import { useDepositComputed } from "./use-deposit-computed"; + +interface UseDepositProps { + executeDeposit: ( + tokenSymbol: string, + tokenAddress: `0x${string}`, + amount: bigint, + chainId: number, + user: Address, + ) => Omit; + destination: DestinationConfig; + onSuccess?: () => void; + onError?: (error: string) => void; +} + +/** + * Main deposit widget hook that orchestrates state, SDK integration, + * and computed values via smaller focused hooks. + */ +export function useDepositWidget( + props: UseDepositProps, +): DepositWidgetContextValue { + const { executeDeposit, destination, onSuccess, onError } = props; + + // External dependencies + const { + nexusSDK, + swapIntent, + swapBalance, + fetchSwapBalance, + getFiatValue, + exchangeRate, + } = useNexus(); + const { address } = useAccount(); + const handleNexusError = useNexusError(); + + // Core state management + const { state, dispatch } = useDepositState(); + const [pollingEnabled, setPollingEnabled] = useState(false); + + // Asset selection state + const { assetSelection, setAssetSelection, resetAssetSelection } = + useAssetSelection(swapBalance); + + // Refs for tracking + const hasAutoSelected = useRef(false); + const initialSimulationDone = useRef(false); + const determiningSwapComplete = useRef(false); + const lastSimulationTime = useRef(0); + + const denyActiveSwapIntent = useCallback(() => { + try { + swapIntent.current?.deny(); + } catch (error) { + console.error("Failed to deny active swap intent", error); + } finally { + swapIntent.current = null; + } + }, [swapIntent]); + + // Transaction steps tracking + const { + seed, + onStepComplete, + reset: resetSteps, + steps, + } = useTransactionSteps(); + + // Stopwatch for timing + const stopwatch = useStopwatch({ + running: + state.status === "executing" || + (state.status === "previewing" && determiningSwapComplete.current), + intervalMs: 100, + }); + + // Derived state + const isProcessing = state.status === "executing"; + const isSuccess = state.status === "success"; + const isError = state.status === "error"; + const activeIntent = state.simulation?.swapIntent ?? swapIntent.current; + + // Computed values + const { + availableAssets, + totalSelectedBalance, + totalBalance, + confirmationDetails, + feeBreakdown, + } = useDepositComputed({ + swapBalance, + assetSelection, + activeIntent, + destination, + inputAmount: state.inputs.amount, + exchangeRate, + getFiatValue, + actualGasFeeUsd: state.actualGasFeeUsd, + swapSkippedData: state.swapSkippedData, + skipSwap: state.skipSwap, + nexusSDK, + }); + + // Action callbacks + const setInputs = useCallback( + (next: Partial) => { + dispatch({ type: "setInputs", payload: next }); + }, + [dispatch], + ); + + const setTxError = useCallback( + (error: string | null) => { + dispatch({ type: "setError", payload: error }); + }, + [dispatch], + ); + + /** + * Start the swap and execute flow with the SDK + */ + const start = useCallback( + (inputs: SwapAndExecuteParams) => { + if (!nexusSDK || !inputs || isProcessing) return; + + seed(SWAP_EXPECTED_STEPS); + + // Build source list from selected assets + const fromSources: Array<{ tokenAddress: Hex; chainId: number }> = []; + assetSelection.selectedChainIds.forEach((key) => { + const lastDashIndex = key.lastIndexOf("-"); + const tokenAddress = key.substring(0, lastDashIndex) as Hex; + const chainId = parseInt(key.substring(lastDashIndex + 1), 10); + fromSources.push({ tokenAddress, chainId }); + }); + + const inputsWithSources = { + ...inputs, + fromSources: fromSources.length > 0 ? fromSources : undefined, + }; + + nexusSDK + .swapAndExecute(inputsWithSources, { + onEvent: (event) => { + if (event.name === NEXUS_EVENTS.SWAP_STEP_COMPLETE) { + const step = event.args as SwapStepType & { + completed?: boolean; + data?: SwapSkippedData; + }; + + // Handle SWAP_SKIPPED - go directly to transaction-status + if (step?.type === "SWAP_SKIPPED") { + dispatch({ type: "setSkipSwap", payload: true }); + dispatch({ + type: "setSwapSkippedData", + payload: step.data ?? null, + }); + dispatch({ type: "setStatus", payload: "executing" }); + dispatch({ + type: "setStep", + payload: { step: "transaction-status", direction: "forward" }, + }); + stopwatch.start(); + } + + if (step?.type === "DETERMINING_SWAP" && step?.completed) { + determiningSwapComplete.current = true; + stopwatch.start(); + dispatch({ type: "setIntentReady", payload: true }); + } + onStepComplete(step); + } + }, + }) + .then((data: SwapAndExecuteResult) => { + // Extract source swaps from the result + const sourceSwapsFromResult = data.swapResult?.sourceSwaps ?? []; + sourceSwapsFromResult.forEach((sourceSwap) => { + const chainMeta = + CHAIN_METADATA[sourceSwap.chainId as keyof typeof CHAIN_METADATA]; + const baseUrl = chainMeta?.blockExplorerUrls?.[0] ?? ""; + const explorerUrl = baseUrl + ? `${baseUrl}/tx/${sourceSwap.txHash}` + : ""; + dispatch({ + type: "addSourceSwap", + payload: { + chainId: sourceSwap.chainId, + chainName: chainMeta?.name ?? `Chain ${sourceSwap.chainId}`, + explorerUrl, + }, + }); + }); + + // Set explorer URLs from the result + if (sourceSwapsFromResult.length > 0) { + const firstSourceSwap = sourceSwapsFromResult[0]; + const chainMeta = + CHAIN_METADATA[ + firstSourceSwap.chainId as keyof typeof CHAIN_METADATA + ]; + const baseUrl = chainMeta?.blockExplorerUrls?.[0] ?? ""; + const sourceExplorerUrl = baseUrl + ? `${baseUrl}/tx/${firstSourceSwap.txHash}` + : ""; + dispatch({ + type: "setExplorerUrls", + payload: { sourceExplorerUrl }, + }); + } + + // Destination explorer URL + const destChainMeta = + CHAIN_METADATA[destination.chainId as keyof typeof CHAIN_METADATA]; + const destBaseUrl = destChainMeta?.blockExplorerUrls?.[0] ?? ""; + const destinationExplorerUrl = + data.swapResult?.explorerURL ?? + (data.executeResponse?.txHash && destBaseUrl + ? `${destBaseUrl}/tx/${data.executeResponse.txHash}` + : null); + + if (destinationExplorerUrl) { + dispatch({ + type: "setExplorerUrls", + payload: { destinationExplorerUrl }, + }); + } + + // Store Nexus intent URL and deposit tx hash + dispatch({ + type: "setNexusIntentUrl", + payload: data.swapResult?.explorerURL ?? null, + }); + dispatch({ + type: "setDepositTxHash", + payload: data.executeResponse?.txHash ?? null, + }); + + // Calculate actual gas fee from receipt + const receipt = data.executeResponse?.receipt; + if (receipt?.gasUsed && receipt?.effectiveGasPrice) { + const gasUsed = BigInt(receipt.gasUsed); + const effectiveGasPrice = BigInt(receipt.effectiveGasPrice); + const gasCostWei = gasUsed * effectiveGasPrice; + const gasCostNative = parseFloat(formatEther(gasCostWei)); + const gasTokenSymbol = destination.gasTokenSymbol ?? "ETH"; + const gasCostUsd = getFiatValue(gasCostNative, gasTokenSymbol); + dispatch({ + type: "setActualGasFeeUsd", + payload: gasCostUsd, + }); + } + + dispatch({ + type: "setReceiveAmount", + payload: swapIntent.current?.intent?.destination?.amount ?? "", + }); + onSuccess?.(); + dispatch({ type: "setStatus", payload: "success" }); + dispatch({ + type: "setStep", + payload: { step: "transaction-complete", direction: "forward" }, + }); + }) + .catch((error) => { + const { message } = handleNexusError(error); + dispatch({ type: "setError", payload: message }); + dispatch({ type: "setStatus", payload: "error" }); + + if (initialSimulationDone.current) { + dispatch({ + type: "setStep", + payload: { step: "transaction-failed", direction: "forward" }, + }); + } else { + dispatch({ + type: "setStep", + payload: { step: "amount", direction: "backward" }, + }); + } + onError?.(message); + }) + .finally(async () => { + await fetchSwapBalance(); + }); + }, + [ + nexusSDK, + isProcessing, + seed, + onStepComplete, + swapIntent, + onSuccess, + onError, + handleNexusError, + assetSelection.selectedChainIds, + destination, + getFiatValue, + fetchSwapBalance, + dispatch, + stopwatch, + ], + ); + + /** + * Handle amount input continue - starts simulation + */ + const beginAmountSimulation = useCallback( + (totalAmountUsd: number) => { + if (!nexusSDK) { + dispatch({ type: "setError", payload: "Nexus SDK is not initialized." }); + dispatch({ type: "setStatus", payload: "error" }); + return false; + } + if (!address) { + dispatch({ type: "setError", payload: "Connect your wallet to continue." }); + dispatch({ type: "setStatus", payload: "error" }); + return false; + } + const destinationRate = exchangeRate?.[destination.tokenSymbol]; + if (!destinationRate || !Number.isFinite(destinationRate) || destinationRate <= 0) { + dispatch({ + type: "setError", + payload: `Unable to fetch pricing for ${destination.tokenSymbol}. Please try again.`, + }); + dispatch({ type: "setStatus", payload: "error" }); + return false; + } + + // Reset state and refs for a fresh simulation + dispatch({ type: "setError", payload: null }); + dispatch({ type: "setIntentReady", payload: false }); + initialSimulationDone.current = false; + determiningSwapComplete.current = false; + denyActiveSwapIntent(); + + const tokenAmount = totalAmountUsd / destinationRate; + const tokenAmountStr = tokenAmount.toFixed(destination.tokenDecimals); + const parsed = parseUnits(tokenAmountStr, destination.tokenDecimals); + + const executeParams = executeDeposit( + destination.tokenSymbol, + destination.tokenAddress, + parsed, + destination.chainId, + address, + ); + + const newInputs: SwapAndExecuteParams = { + toChainId: destination.chainId, + toTokenAddress: destination.tokenAddress, + toAmount: parsed, + execute: { + to: executeParams.to, + value: executeParams.value, + data: executeParams.data, + gasPrice: executeParams.gasPrice, + tokenApproval: executeParams.tokenApproval as { + token: `0x${string}`; + amount: bigint; + spender: Hex; + }, + gas: BigInt(300_000), + }, + }; + + dispatch({ + type: "setInputs", + payload: { amount: totalAmountUsd.toString() }, + }); + dispatch({ type: "setStatus", payload: "simulation-loading" }); + dispatch({ type: "setSimulationLoading", payload: true }); + start(newInputs); + return true; + }, + [ + nexusSDK, + address, + exchangeRate, + destination, + executeDeposit, + start, + denyActiveSwapIntent, + dispatch, + ], + ); + + const handleAmountContinue = useCallback( + (totalAmountUsd: number) => { + beginAmountSimulation(totalAmountUsd); + }, + [beginAmountSimulation], + ); + + /** + * Handle order confirmation - allow intent to execute + */ + const handleConfirmOrder = useCallback(() => { + if (!activeIntent) return; + dispatch({ type: "setStatus", payload: "executing" }); + dispatch({ + type: "setStep", + payload: { step: "transaction-status", direction: "forward" }, + }); + activeIntent.allow(); + }, [activeIntent, dispatch]); + + /** + * Navigate to a specific step + */ + const goToStep = useCallback( + (newStep: WidgetStep) => { + if (state.step === "amount" && newStep === "confirmation") { + const amount = state.inputs.amount; + if (amount) { + const totalAmountUsd = parseFloat(amount.replace(/,/g, "")); + if (totalAmountUsd > 0) { + const started = beginAmountSimulation(totalAmountUsd); + if (started) { + dispatch({ + type: "setStep", + payload: { step: newStep, direction: "forward" }, + }); + } + return; + } + } + } + dispatch({ + type: "setStep", + payload: { step: newStep, direction: "forward" }, + }); + }, + [state.step, state.inputs.amount, beginAmountSimulation, dispatch], + ); + + /** + * Navigate back to previous step + */ + const goBack = useCallback(async () => { + const previousStep = STEP_HISTORY[state.step]; + if (previousStep) { + dispatch({ type: "setError", payload: null }); + dispatch({ + type: "setStep", + payload: { step: previousStep, direction: "backward" }, + }); + denyActiveSwapIntent(); + initialSimulationDone.current = false; + lastSimulationTime.current = 0; + setPollingEnabled(false); + stopwatch.stop(); + stopwatch.reset(); + await fetchSwapBalance(); + } + }, [state.step, stopwatch, dispatch, denyActiveSwapIntent, fetchSwapBalance]); + + /** + * Reset widget to initial state + */ + const reset = useCallback(async () => { + dispatch({ type: "reset" }); + resetAssetSelection(); + resetSteps(); + denyActiveSwapIntent(); + initialSimulationDone.current = false; + lastSimulationTime.current = 0; + setPollingEnabled(false); + stopwatch.stop(); + stopwatch.reset(); + await fetchSwapBalance(); + }, [ + resetSteps, + stopwatch, + dispatch, + resetAssetSelection, + denyActiveSwapIntent, + fetchSwapBalance, + ]); + + /** + * Refresh simulation data + */ + const refreshSimulation = useCallback(async () => { + const timeSinceLastSimulation = Date.now() - lastSimulationTime.current; + if (timeSinceLastSimulation < 5000) { + return; + } + + try { + dispatch({ type: "setSimulationLoading", payload: true }); + const updated = await swapIntent.current?.refresh(); + if (updated) { + swapIntent.current!.intent = updated; + dispatch({ + type: "setSimulation", + payload: { + swapIntent: swapIntent.current!, + }, + }); + } + } catch (e) { + console.error(e); + } finally { + dispatch({ type: "setSimulationLoading", payload: false }); + stopwatch.reset(); + lastSimulationTime.current = Date.now(); + } + }, [stopwatch, swapIntent, dispatch]); + + const startTransaction = useCallback(() => { + if (isProcessing) return; + dispatch({ type: "setError", payload: null }); + }, [isProcessing, dispatch]); + + // Effect: Handle swap intent when it arrives + useEffect(() => { + if (!state.intentReady || initialSimulationDone.current) { + return; + } + + if (!swapIntent.current) { + return; + } + + initialSimulationDone.current = true; + dispatch({ + type: "setSimulation", + payload: { swapIntent: swapIntent.current! }, + }); + dispatch({ type: "setSimulationLoading", payload: false }); + dispatch({ type: "setStatus", payload: "previewing" }); + lastSimulationTime.current = Date.now(); + setPollingEnabled(true); + }, [state.intentReady, swapIntent, dispatch]); + + // Effect: Fetch swap balance on mount + useEffect(() => { + if (!nexusSDK) return; + + if (!swapBalance) { + void fetchSwapBalance(); + return; + } + + if (!hasAutoSelected.current && availableAssets.length > 0) { + hasAutoSelected.current = true; + } + }, [nexusSDK, swapBalance, availableAssets, fetchSwapBalance]); + + // Polling for simulation refresh + usePolling( + pollingEnabled && + state.status === "previewing" && + Boolean(swapIntent.current) && + !state.simulationLoading, + async () => { + await refreshSimulation(); + }, + SIMULATION_POLL_INTERVAL_MS, + ); + + // Return the full context value + return { + step: state.step, + inputs: state.inputs, + setInputs, + status: state.status, + explorerUrls: state.explorerUrls, + sourceSwaps: state.sourceSwaps, + nexusIntentUrl: state.nexusIntentUrl, + depositTxHash: state.depositTxHash, + destination, + isProcessing, + isSuccess, + isError, + txError: state.error, + setTxError, + goToStep, + goBack, + reset, + navigationDirection: state.navigationDirection, + startTransaction, + lastResult: state.lastResult, + assetSelection, + setAssetSelection, + swapBalance, + activeIntent, + confirmationDetails, + feeBreakdown, + steps, + timer: stopwatch.seconds, + handleConfirmOrder, + handleAmountContinue, + totalSelectedBalance, + skipSwap: state.skipSwap, + simulationLoading: state.simulationLoading, + totalBalance, + }; +} diff --git a/apps/monad/src/components/deposit/nexus-deposit.tsx b/apps/monad/src/components/deposit/nexus-deposit.tsx new file mode 100644 index 0000000..6170926 --- /dev/null +++ b/apps/monad/src/components/deposit/nexus-deposit.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { cn } from "./utils"; +import { useDepositWidget } from "./hooks/use-deposit-widget"; +import { + AmountContainer, + ConfirmationContainer, + TransactionStatusContainer, + TransactionCompleteContainer, + TransactionFailedContainer, + AssetSelectionContainer, +} from "./components"; +import type { + WidgetStep, + DepositWidgetProps, + NavigationDirection, +} from "./types"; +import { Card } from "../ui/card"; +import { Dialog, DialogContent, DialogTrigger } from "../ui/dialog"; +import { Button } from "../ui/button"; +import { WidgetErrorBoundary } from "../common"; + +const ANIMATION_CLASSES: Record, string> = { + forward: "animate-slide-in-from-right", + backward: "animate-slide-in-from-left", +}; + +const getAnimationClass = (direction: NavigationDirection): string => + direction ? ANIMATION_CLASSES[direction] : ""; + +type ScreenRenderer = ( + widget: ReturnType, + heading?: string, + onClose?: () => void, +) => React.ReactNode; + +const SCREENS: Record = { + amount: (widget, heading, onClose) => ( + + ), + confirmation: (widget, heading, onClose) => ( + + ), + "transaction-status": (widget, heading, onClose) => ( + + ), + "transaction-complete": (widget, heading, onClose) => ( + + ), + "transaction-failed": (widget, heading, onClose) => ( + + ), + "asset-selection": (widget, heading, onClose) => ( + + ), +}; + +const NexusDeposit = ({ + heading = "Deposit USDC", + embed = false, + className, + onClose, + onSuccess, + onError, + executeDeposit, + destination, + open: controlledOpen, + onOpenChange, + defaultOpen = false, + children, +}: DepositWidgetProps & { children: React.ReactNode }) => { + console.log("DepositWidgetProps", { + heading, + destination, + }); + const widget = useDepositWidget({ + executeDeposit, + destination, + onSuccess, + onError, + }); + const [internalOpen, setInternalOpen] = useState(defaultOpen); + + // Use controlled or uncontrolled open state + const isControlled = controlledOpen !== undefined; + const isOpen = isControlled ? controlledOpen : internalOpen; + const resetWidget = widget.reset; + const shouldPreventDialogDismiss = widget.isProcessing; + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open && shouldPreventDialogDismiss) { + return; + } + if (!isControlled) { + setInternalOpen(open); + } + onOpenChange?.(open); + if (!open) { + onClose?.(); + resetWidget(); + } + }, + [ + isControlled, + onOpenChange, + onClose, + shouldPreventDialogDismiss, + resetWidget, + ], + ); + + const handleClose = useCallback(() => { + handleOpenChange(false); + }, [handleOpenChange]); + + const animationClass = getAnimationClass(widget.navigationDirection); + + // Embed mode: render as inline Card + if (embed) { + return ( + + +
+ {SCREENS[widget.step](widget, heading)} +
+
+
+ ); + } + + return ( + + {children} + + +
+ {SCREENS[widget.step](widget, heading, handleClose)} +
+
+
+
+ ); +}; + +export default NexusDeposit; + +// Re-export types and hooks for consumers +export type { + WidgetStep, + DepositWidgetContextValue, + DepositWidgetProps, + BaseDepositWidgetProps, + DestinationConfig, + ExecuteDepositParams, + ExecuteDepositResult, + UseDepositWidgetProps, + TransactionStatus, + AssetFilterType, + DepositInputs, + AssetSelectionState, +} from "./types"; +export { useDepositWidget } from "./hooks/use-deposit-widget"; diff --git a/apps/monad/src/components/deposit/types.ts b/apps/monad/src/components/deposit/types.ts new file mode 100644 index 0000000..d116331 --- /dev/null +++ b/apps/monad/src/components/deposit/types.ts @@ -0,0 +1,230 @@ +import type { + SUPPORTED_CHAINS_IDS, + ExecuteParams, + OnSwapIntentHookData, + SwapStepType, + UserAsset, +} from "@avail-project/nexus-core"; +import type { Address } from "viem"; + +export type WidgetStep = + | "amount" + | "confirmation" + | "transaction-status" + | "transaction-complete" + | "transaction-failed" + | "asset-selection"; + +export type TransactionStatus = + | "idle" + | "previewing" + | "simulation-loading" + | "executing" + | "success" + | "error"; + +export type NavigationDirection = "forward" | "backward" | null; + +export type AssetFilterType = "all" | "stablecoins" | "native" | "custom"; + +export type TokenCategory = "stablecoin" | "native" | "memecoin"; + +export interface ChainItem { + id: string; + tokenAddress: `0x${string}`; + chainId: number; + name: string; + usdValue: number; + amount: number; +} + +export interface Token { + id: string; + symbol: string; + decimals: number; + chainsLabel: string; + usdValue: string; + amount: string; + logo: string; + category: TokenCategory; + chains: ChainItem[]; +} + +export interface DepositInputs { + amount?: string; + selectedToken: string; + toChainId?: number; + toTokenAddress?: `0x${string}`; + toAmount?: bigint; +} + +export interface AssetSelectionState { + selectedChainIds: Set; + filter: AssetFilterType; + expandedTokens: Set; +} + +export interface DestinationConfig { + chainId: SUPPORTED_CHAINS_IDS; + depositTargetLogo?: string; + tokenAddress: `0x${string}`; + tokenSymbol: string; + tokenDecimals: number; + tokenLogo?: string; + label?: string; + estimatedTime?: string; + gasTokenSymbol?: string; + explorerUrl?: string; +} + +export interface ExecuteDepositParams { + tokenSymbol: string; + tokenAddress: string; + amount: bigint; + chainId: number; + user: Address; +} + +export type ExecuteDepositResult = Omit; + +export interface UseDepositWidgetProps { + executeDeposit: ( + tokenSymbol: string, + tokenAddress: `0x${string}`, + amount: bigint, + chainId: number, + user: Address, + ) => Omit; + destination: DestinationConfig; + onSuccess?: () => void; + onError?: (error: string) => void; +} + +export interface DepositWidgetContextValue { + // Core state + step: WidgetStep; + inputs: DepositInputs; + status: TransactionStatus; + + // Input management + setInputs: (inputs: Partial) => void; + + // Explorer URLs + explorerUrls: { + sourceExplorerUrl: string | null; + destinationExplorerUrl: string | null; + }; + + // Source swap transactions (from BRIDGE_DEPOSIT events) + sourceSwaps: Array<{ + chainId: number; + chainName: string; + explorerUrl: string; + }>; + + // Transaction result data + nexusIntentUrl: string | null; + depositTxHash: string | null; + + // Destination config (for building explorer URLs) + destination: DestinationConfig; + + // Derived state + isProcessing: boolean; + isSuccess: boolean; + isError: boolean; + + // Error handling + txError: string | null; + setTxError: (error: string | null) => void; + + // Navigation + goToStep: (step: WidgetStep) => void; + goBack: () => void; + reset: () => void; + navigationDirection: NavigationDirection; + + // Transaction actions + startTransaction: () => void; + + // Results + lastResult: unknown; + + // Asset selection + assetSelection: AssetSelectionState; + setAssetSelection: (selection: Partial) => void; + + // SDK integration + swapBalance: UserAsset[] | null; + activeIntent: OnSwapIntentHookData | null; + confirmationDetails: { + sourceLabel: string; + sources: Array< + | { + chainId: number; + tokenAddress: `0x${string}`; + decimals: number; + symbol: string; + balance: string; + balanceInFiat?: number; + tokenLogo?: string; + chainLogo?: string; + chainName?: string; + isDestinationBalance?: boolean; + } + | undefined + >; + gasTokenSymbol?: string; + estimatedTime?: string; + amountSpent: number; + totalFeeUsd: number; + receiveTokenSymbol: string; + receiveAmountAfterSwap: string; + receiveAmountAfterSwapUsd: number; + receiveTokenLogo?: string; + receiveTokenChain: number; + destinationChainName?: string; + } | null; + feeBreakdown: { + totalGasFee: number; + gasUsd: number; + gasFormatted: string; + }; + steps: Array<{ + id: number; + completed: boolean; + step: SwapStepType; + }>; + timer: number; + handleConfirmOrder: () => void; + handleAmountContinue: (totalAmountUsd: number) => void; + totalSelectedBalance: number; + skipSwap: boolean; + simulationLoading: boolean; + totalBalance: + | { + balance: number; + usdBalance: number; + } + | undefined; +} + +export interface BaseDepositWidgetProps { + onSuccess?: () => void; + onError?: (error: string) => void; +} + +export interface DepositWidgetProps + extends UseDepositWidgetProps, + BaseDepositWidgetProps { + heading?: string; + embed?: boolean; + className?: string; + onClose?: () => void; + /** Control the dialog open state (non-embed mode only) */ + open?: boolean; + /** Callback when dialog open state changes (non-embed mode only) */ + onOpenChange?: (open: boolean) => void; + /** Default open state for uncontrolled usage (non-embed mode only) */ + defaultOpen?: boolean; +} diff --git a/apps/monad/src/components/deposit/utils.ts b/apps/monad/src/components/deposit/utils.ts new file mode 100644 index 0000000..13adb69 --- /dev/null +++ b/apps/monad/src/components/deposit/utils.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +/** + * Parse currency input by removing $ and commas, keeping only numbers and decimal + */ +export function parseCurrencyInput(input: string): string { + return input.replace(/[^0-9.]/g, ""); +} \ No newline at end of file diff --git a/apps/monad/src/components/deposit/utils/asset-helpers.ts b/apps/monad/src/components/deposit/utils/asset-helpers.ts new file mode 100644 index 0000000..291f88a --- /dev/null +++ b/apps/monad/src/components/deposit/utils/asset-helpers.ts @@ -0,0 +1,105 @@ +import type { Token, AssetFilterType } from "../types"; +import { STABLECOIN_SYMBOLS } from "../constants/widget"; +import { CHAIN_METADATA } from "@avail-project/nexus-core"; + +export function isStablecoin(symbol: string): boolean { + return STABLECOIN_SYMBOLS.includes( + symbol as (typeof STABLECOIN_SYMBOLS)[number], + ); +} + +export function isNative(symbol: string): boolean { + return Object.values(CHAIN_METADATA).some( + (chain) => chain.nativeCurrency.symbol === symbol, + ); +} + +/** + * Get checkbox state for a token based on selected chains + */ +export function getTokenCheckState( + token: Token, + selectedChainIds: Set, +): boolean | "indeterminate" { + const selectedChainCount = token.chains.filter((c) => + selectedChainIds.has(c.id), + ).length; + + if (selectedChainCount === 0) return false; + if (selectedChainCount === token.chains.length) return true; + return "indeterminate"; +} + +/** + * Check if current selection matches a preset filter + * Returns the matching filter type or "custom" + */ +export function checkIfMatchesPreset( + tokens: Token[], + selectedChainIds: Set, +): AssetFilterType { + if (selectedChainIds.size === 0) return "custom"; + + const allIds = new Set(); + const stableIds = new Set(); + const nativeIds = new Set(); + + tokens.forEach((token) => { + token.chains.forEach((chain) => { + allIds.add(chain.id); + if (isStablecoin(token.symbol)) { + stableIds.add(chain.id); + } + if (isNative(token.symbol)) { + nativeIds.add(chain.id); + } + }); + }); + + const setsEqual = (a: Set, b: Set) => + a.size === b.size && [...a].every((id) => b.has(id)); + + if (setsEqual(selectedChainIds, allIds)) return "all"; + if (setsEqual(selectedChainIds, stableIds)) return "stablecoins"; + if (setsEqual(selectedChainIds, nativeIds)) return "native"; + return "custom"; +} + +/** + * Get chain IDs for a preset filter + */ +export function getChainIdsForFilter( + tokens: Token[], + filter: "all" | "stablecoins" | "native", +): Set { + const ids = new Set(); + tokens.forEach((token) => { + const shouldInclude = + filter === "all" || + (filter === "stablecoins" && isStablecoin(token.symbol)) || + (filter === "native" && isNative(token.symbol)); + + if (shouldInclude) { + token.chains.forEach((chain) => ids.add(chain.id)); + } + }); + return ids; +} + +/** + * Calculate total USD value for selected chain IDs + */ +export function calculateSelectedAmount( + tokens: Token[], + selectedChainIds: Set, +): number { + let total = 0; + tokens.forEach((token) => { + token.chains.forEach((chain) => { + if (selectedChainIds.has(chain.id)) { + total += chain.usdValue; + } + }); + }); + return total; +} diff --git a/apps/monad/src/components/deposit/utils/index.ts b/apps/monad/src/components/deposit/utils/index.ts new file mode 100644 index 0000000..6c7f66e --- /dev/null +++ b/apps/monad/src/components/deposit/utils/index.ts @@ -0,0 +1 @@ +export { getTokenCheckState, checkIfMatchesPreset, getChainIdsForFilter, calculateSelectedAmount, isStablecoin, isNative } from "./asset-helpers"; diff --git a/apps/monad/src/components/fast-bridge/components/fee-breakdown.tsx b/apps/monad/src/components/fast-bridge/components/fee-breakdown.tsx index 93655ba..61df326 100644 --- a/apps/monad/src/components/fast-bridge/components/fee-breakdown.tsx +++ b/apps/monad/src/components/fast-bridge/components/fee-breakdown.tsx @@ -18,8 +18,6 @@ interface FeeBreakdownProps { const FeeBreakdown: FC = ({ intent, isLoading = false }) => { const { nexusSDK } = useNexus(); - const feeSymbol = - intent.token?.displaySymbol ?? intent.token?.symbol ?? "USDC"; const feeRows = [ { @@ -64,7 +62,7 @@ const FeeBreakdown: FC = ({ intent, isLoading = false }) => { ) : (

{nexusSDK?.utils?.formatTokenBalance(intent.fees?.total, { - symbol: feeSymbol, + symbol: intent.token?.symbol, decimals: intent?.token?.decimals, })}

@@ -81,7 +79,7 @@ const FeeBreakdown: FC = ({ intent, isLoading = false }) => {
{feeRows.map(({ key, label, value, description }) => { - // if (Number.parseFloat(value ?? "0") <= 0) return null; + if (Number.parseFloat(value ?? "0") <= 0) return null; return (
@@ -96,7 +94,7 @@ const FeeBreakdown: FC = ({ intent, isLoading = false }) => { ) : (

{nexusSDK?.utils?.formatTokenBalance(value, { - symbol: feeSymbol, + symbol: intent.token?.symbol, decimals: intent?.token?.decimals, })}

diff --git a/apps/monad/src/components/fast-bridge/components/recipient-address.tsx b/apps/monad/src/components/fast-bridge/components/recipient-address.tsx index 5a7fa6f..ddb52a0 100644 --- a/apps/monad/src/components/fast-bridge/components/recipient-address.tsx +++ b/apps/monad/src/components/fast-bridge/components/recipient-address.tsx @@ -19,17 +19,6 @@ const RecipientAddress: FC = ({ }) => { const { nexusSDK } = useNexus(); const [isEditing, setIsEditing] = useState(false); - const fallbackTruncate = (value: string, head = 6, tail = 6) => { - if (!value) return ""; - if (value.length <= head + tail) return value; - return `${value.slice(0, head)}...${value.slice(-tail)}`; - }; - const displayAddress = - address && nexusSDK?.utils?.truncateAddress - ? nexusSDK.utils.truncateAddress(address, 6, 6) - : address - ? fallbackTruncate(address, 6, 6) - : ""; return (
{isEditing ? ( @@ -55,7 +44,9 @@ const RecipientAddress: FC = ({

Recipient Address

{address && ( -

{displayAddress}

+

+ {nexusSDK?.utils?.truncateAddress(address, 6, 6)} +

)} )} @@ -445,8 +242,8 @@ const FastBridge: FC = ({ {txError && ( -
- {txError} +
+ {txError} + ) : ( + refetchBalance()} + executeDeposit={( + symbol, + address, + amount, + chainId, + userAddress, + ) => { + console.log(symbol, address, amount, chainId, userAddress); + const args = t.params!.map((p) => { + switch (p) { + case "$user": + return userAddress; + case "$amount": { + return amount; + } + default: + return p; + } + }); + + const data = encodeFunctionData({ + abi: t.abi!, + functionName: t.functionName!, + args, + }); + return { + to: t.to as `0x${string}`, + data, + tokenApproval: { + token: token.address, + amount: approval.amount === "input" ? amount : maxUint256, + spender: approval.spender as `0x${string}`, + }, + }; + }} + > + + + )} +
+ {/* */} +
+
+
+ ); +} diff --git a/apps/monad/src/components/opportunities/OpportunityList.tsx b/apps/monad/src/components/opportunities/OpportunityList.tsx new file mode 100644 index 0000000..c5bd371 --- /dev/null +++ b/apps/monad/src/components/opportunities/OpportunityList.tsx @@ -0,0 +1,54 @@ +"use client"; + +import type { Opportunity } from "@/lib/types/opportunity"; +import { OpportunityCard } from "./OpportunityCard"; + +interface OpportunityListProps { + opportunities: Opportunity[]; + onOpportunityClick?: (opportunity: Opportunity) => void; +} + +export function OpportunityList({ + opportunities, + onOpportunityClick, +}: OpportunityListProps) { + if (opportunities.length === 0) { + return ( +
+
+ + + +
+

+ No opportunities available +

+

+ Check back later for new DeFi opportunities +

+
+ ); + } + + return ( +
+ {opportunities.map((opportunity) => ( + + ))} +
+ ); +} diff --git a/apps/monad/src/components/opportunities/WithdrawModal.tsx b/apps/monad/src/components/opportunities/WithdrawModal.tsx new file mode 100644 index 0000000..be54396 --- /dev/null +++ b/apps/monad/src/components/opportunities/WithdrawModal.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { formatUnits, parseUnits } from "viem"; +import { + useWriteContract, + useWaitForTransactionReceipt, + useAccount, + useSwitchChain, +} from "wagmi"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Loader2 } from "lucide-react"; +import type { Opportunity } from "@/lib/types/opportunity"; + +interface WithdrawModalProps { + opportunity: Opportunity; + balance: bigint; + decimals: number; + userAddress: string; + primaryColor: string; + chainId: number; + onSuccess?: () => void; +} + +export function WithdrawModal({ + opportunity, + balance, + decimals, + userAddress, + primaryColor, + chainId, + onSuccess, +}: WithdrawModalProps) { + const [open, setOpen] = useState(false); + const [amount, setAmount] = useState(""); + + const withdrawLogic = + opportunity.withdraw?.logics?.[0]?.preBridge?.transaction; + + const { chainId: currentChainId } = useAccount(); + const { switchChainAsync } = useSwitchChain(); + const { writeContract, isPending, data: hash, error } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ + hash, + }); + + useEffect(() => { + if (isSuccess && onSuccess) { + onSuccess(); + } + }, [isSuccess, onSuccess]); + + console.log("Withdraw Modal Balance Data:", { + balance, + formatUnits: formatUnits(balance, decimals), + }); + + const handleWithdraw = async () => { + console.log("Withdraw Initiate:", { withdrawLogic, amount, decimals }); + if (!withdrawLogic) return; + if (!amount || isNaN(Number(amount))) return; + + const parsedAmount = parseUnits(amount, decimals); + if (parsedAmount === 0n) return; + + const args = withdrawLogic.params?.map((p) => { + switch (p) { + case "$user": + return userAddress; + case "$amount": + return parsedAmount; + default: + return p; + } + }); + + const txPayload = { + chainId, + address: withdrawLogic.to.startsWith("0x") + ? (withdrawLogic.to as `0x${string}`) + : (`0x${withdrawLogic.to}` as `0x${string}`), + abi: withdrawLogic.abi as any, + functionName: withdrawLogic.functionName as string, + args, + }; + + console.log("Submitting Write Contract Payload:", txPayload); + + try { + if (currentChainId !== chainId) { + console.log(`Switching chain from ${currentChainId} to ${chainId}...`); + await switchChainAsync({ chainId }); + } + + writeContract(txPayload, { + onError: (err) => { + console.error("writeContract failed:", err); + }, + onSuccess: (txHash) => { + console.log("Transaction successfully sent! Hash:", txHash); + }, + }); + } catch (err: any) { + console.error("Chain switch failed:", err); + } + }; + + return ( +
e.stopPropagation()}> + + + + + + + Withdraw {opportunity.token.symbol} + +
+
+
+ Amount + + Balance: {formatUnits(balance, decimals)}{" "} + {opportunity.token.symbol} + +
+
+ setAmount(e.target.value)} + className="pr-16" + /> + +
+
+ + {error && ( +
+ {error.message || "An error occurred during transaction"} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/monad/src/components/ui/animated-spinner.tsx b/apps/monad/src/components/ui/animated-spinner.tsx new file mode 100644 index 0000000..dc5afc5 --- /dev/null +++ b/apps/monad/src/components/ui/animated-spinner.tsx @@ -0,0 +1,93 @@ +"use client"; + +export function AnimatedSpinner({ className = "" }: { className?: string }) { + return ( +
+ + {/* Top spike */} + + + {/* Top-right spike */} + + + {/* Right spike */} + + + {/* Bottom-right spike */} + + + {/* Bottom spike */} + + + {/* Bottom-left spike */} + + + {/* Left spike */} + + + {/* Top-left spike */} + + + + +
+ ); +} diff --git a/apps/monad/src/components/ui/animated-tabs.tsx b/apps/monad/src/components/ui/animated-tabs.tsx new file mode 100644 index 0000000..fc44b14 --- /dev/null +++ b/apps/monad/src/components/ui/animated-tabs.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { motion, LayoutGroup } from "framer-motion"; +import { cn } from "@/lib/utils"; +import config from "../../../config"; + +interface Tab { + id: string; + label: string; +} + +interface AnimatedTabsProps { + tabs: Tab[]; + activeTab: string; + onTabChange: (tabId: string) => void; + className?: string; +} + +export function AnimatedTabs({ + tabs, + activeTab, + onTabChange, + className, +}: AnimatedTabsProps) { + const [selectedTab, setSelectedTab] = useState(activeTab); + + // Sync with external activeTab when it changes (e.g., browser back/forward) + useEffect(() => { + setSelectedTab(activeTab); + }, [activeTab]); + + const handleTabClick = (tabId: string) => { + setSelectedTab(tabId); + // Delay navigation slightly to allow animation to start smoothly + requestAnimationFrame(() => { + onTabChange(tabId); + }); + }; + + return ( + +
+ {tabs.map((tab) => { + const isActive = selectedTab === tab.id; + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/monad/src/components/ui/checkbox.tsx b/apps/monad/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..cb0b07b --- /dev/null +++ b/apps/monad/src/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { CheckIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/apps/monad/src/components/ui/dialog.tsx b/apps/monad/src/components/ui/dialog.tsx index d9ccec9..11cb859 100644 --- a/apps/monad/src/components/ui/dialog.tsx +++ b/apps/monad/src/components/ui/dialog.tsx @@ -50,9 +50,13 @@ function DialogContent({ className, children, showCloseButton = true, + dismissible = true, + onInteractOutside, + onEscapeKeyDown, ...props }: React.ComponentProps & { showCloseButton?: boolean + dismissible?: boolean }) { return ( @@ -63,10 +67,22 @@ function DialogContent({ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", className )} + onInteractOutside={(event) => { + if (!dismissible) { + event.preventDefault() + } + onInteractOutside?.(event) + }} + onEscapeKeyDown={(event) => { + if (!dismissible) { + event.preventDefault() + } + onEscapeKeyDown?.(event) + }} {...props} > {children} - {showCloseButton && ( + {showCloseButton && dismissible && ( ) { + return ( + + ); +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/apps/monad/src/components/view-history/hooks/useViewHistory.ts b/apps/monad/src/components/view-history/hooks/useViewHistory.ts index ea4cece..6c76c94 100644 --- a/apps/monad/src/components/view-history/hooks/useViewHistory.ts +++ b/apps/monad/src/components/view-history/hooks/useViewHistory.ts @@ -27,28 +27,25 @@ const useViewHistory = () => { setSentinelNode(node); }, []); - const fetchIntentHistory = useCallback(async () => { - if (!nexusSDK) return; - + const fetchIntentHistory = async () => { try { - const fetchedHistory = await nexusSDK.getMyIntents(); - const normalizedHistory = fetchedHistory ?? []; - - setHistory(normalizedHistory); - setDisplayedHistory(normalizedHistory.slice(0, ITEMS_PER_PAGE)); - setPage(0); - setHasMore(normalizedHistory.length > ITEMS_PER_PAGE); - setIsLoadingMore(false); + const history = await nexusSDK?.getMyIntents(); + if (history) { + setHistory(history); + const firstPage = history.slice(0, ITEMS_PER_PAGE); + setDisplayedHistory(firstPage); + setHasMore(history.length > ITEMS_PER_PAGE); + } } catch (error) { console.error("Error fetching intent history:", error); } - }, [nexusSDK]); + }; useEffect(() => { if (!history) { - void fetchIntentHistory(); + fetchIntentHistory(); } - }, [history, fetchIntentHistory]); + }, [history]); const loadMore = useCallback(() => { if (!history || isLoadingMore || !hasMore) return; @@ -85,7 +82,7 @@ const useViewHistory = () => { loadMore(); } }, - { threshold: 0.1, root: rootElement ?? null }, + { threshold: 0.1, root: rootElement ?? null } ); observer.observe(sentinelNode); @@ -117,7 +114,6 @@ const useViewHistory = () => { observerTarget, ITEMS_PER_PAGE, formatExpiryDate, - fetchIntentHistory, }; }; diff --git a/apps/monad/src/components/view-history/view-history.tsx b/apps/monad/src/components/view-history/view-history.tsx index b58dd82..a8ce6ab 100644 --- a/apps/monad/src/components/view-history/view-history.tsx +++ b/apps/monad/src/components/view-history/view-history.tsx @@ -1,6 +1,5 @@ "use client"; -import { useEffect } from "react"; import { Dialog, DialogContent, @@ -9,14 +8,13 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Clock, LoaderPinwheel, SquareArrowOutUpRight } from "lucide-react"; -import { type RFF } from "@avail-project/nexus-core"; +import { TOKEN_METADATA, type RFF } from "@avail-project/nexus-core"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import useViewHistory from "./hooks/useViewHistory"; -import { TOKEN_IMAGES } from "../common"; const SourceChains = ({ sources }: { sources: RFF["sources"] }) => { return ( @@ -26,7 +24,7 @@ const SourceChains = ({ sources }: { sources: RFF["sources"] }) => { key={source?.chain?.id} className={cn( "rounded-full transition-transform hover:scale-110", - index > 0 && "-ml-2", + index > 0 && "-ml-2" )} style={{ zIndex: sources.length - index }} > @@ -77,13 +75,13 @@ const DestinationToken = ({ key={dest.token.symbol} className={cn( "rounded-full transition-transform hover:scale-110", - index > 0 && "-ml-2", + index > 0 && "-ml-2" )} style={{ zIndex: destination.length - index }} > {dest.token.symbol} { const { history, @@ -112,14 +108,8 @@ const ViewHistory = ({ observerTarget, ITEMS_PER_PAGE, formatExpiryDate, - fetchIntentHistory, } = useViewHistory(); - useEffect(() => { - if (!refreshNonce) return; - void fetchIntentHistory(); - }, [refreshNonce, fetchIntentHistory]); - const renderHistoryContent = () => { if (displayedHistory.length > 0) { return ( diff --git a/apps/monad/src/components/walletConnect.tsx b/apps/monad/src/components/walletConnect.tsx index 2dcb18f..fd5a343 100644 --- a/apps/monad/src/components/walletConnect.tsx +++ b/apps/monad/src/components/walletConnect.tsx @@ -1,161 +1,59 @@ "use client"; import * as React from "react"; -import type { EthereumProvider } from "@avail-project/nexus-core"; +import { LoaderPinwheel } from "lucide-react"; import { useAccount } from "wagmi"; import { useNexus } from "./nexus/NexusProvider"; -import { toast } from "sonner"; +import config from "../../config"; interface PreviewPanelProps { children: React.ReactNode; } +/** + * PreviewPanel - UI wrapper that displays loading state or children. + * Nexus initialization is handled by NexusInitializer at App level. + * This component only checks if Nexus is ready, no local state that resets on remount. + */ export function PreviewPanel({ children }: Readonly) { - const [loading, setLoading] = React.useState(false); - const [initError, setInitError] = React.useState(null); - const { status, connector, address } = useAccount(); - const { nexusSDK, handleInit, deinitializeNexus, setIntent, setAllowance } = - useNexus(); - const prevAddressRef = React.useRef(address); + const { status } = useAccount(); + const { nexusSDK, loading: nexusLoading } = useNexus(); - const initializeNexus = React.useCallback(async () => { - if (loading || nexusSDK) return; // Prevent multiple calls - - console.log("[Nexus Init] Starting initialization..."); - console.log("[Nexus Init] Connector:", connector); - console.log("[Nexus Init] Connector name:", connector?.name); - console.log("[Nexus Init] Connector type:", connector?.type); - - setLoading(true); - setInitError(null); - - // Create a timeout promise - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error("Nexus initialization timed out after 30 seconds")); - }, 30000); // 30 second timeout - }); - - try { - if (!connector) { - throw new Error("No connector available"); - } - - console.log("[Nexus Init] Getting provider from connector..."); - const provider = (await connector.getProvider()) as EthereumProvider; - - console.log("[Nexus Init] Provider:", provider); - console.log("[Nexus Init] Provider type:", typeof provider); - console.log( - "[Nexus Init] Provider has request:", - typeof provider?.request === "function", - ); - - if (!provider) { - throw new Error("No provider available from connector"); - } - - if (typeof provider.request !== "function") { - throw new Error( - "Provider does not have a request method (not EIP-1193 compliant)", - ); - } - - console.log("[Nexus Init] Provider validated, calling handleInit..."); - - // Race between initialization and timeout - await Promise.race([handleInit(provider), timeoutPromise]); - - console.log("[Nexus Init] Initialization successful!"); - } catch (error) { - console.error("[Nexus Init] Initialization failed:", error); - const errorMessage = (error as Error)?.message || "Unknown error"; - setInitError(errorMessage); - toast.error(`Failed to initialize Nexus: ${errorMessage}`); - } finally { - setLoading(false); - } - }, [connector, handleInit, loading, nexusSDK]); - - // Handle wallet disconnection - clear Nexus state and balances - React.useEffect(() => { - if (status === "disconnected" && nexusSDK) { - deinitializeNexus(); - setIntent(null); - setAllowance(null); - prevAddressRef.current = undefined; - } - if (status === "disconnected") { - setInitError(null); - } - }, [status, nexusSDK, deinitializeNexus, setIntent, setAllowance]); - - // Handle account change - reinitialize Nexus when account address changes - React.useEffect(() => { - if ( - status === "connected" && - address && - address !== prevAddressRef.current - ) { - const previousAddress = prevAddressRef.current; - const currentAddress = address; - prevAddressRef.current = address; - - // If account changed and Nexus is initialized, reinitialize with new account - if (nexusSDK && previousAddress !== undefined) { - // Account changed - deinitialize and reinitialize - deinitializeNexus().then(() => { - // Small delay to ensure deinit completes, then reinitialize - setTimeout(() => { - // Check if still connected and address hasn't changed again - if ( - currentAddress === prevAddressRef.current && - !loading && - !initError - ) { - initializeNexus(); - } - }, 100); - }); - } - } else if (status === "connected" && address && !prevAddressRef.current) { - // First connection - set the address - prevAddressRef.current = address; - } - }, [ - status, - nexusSDK, - address, - initError, - initializeNexus, - deinitializeNexus, - loading, - ]); - - // Auto-initialize Nexus when wallet is connected and address is available - React.useEffect(() => { - if ( - status === "connected" && - !nexusSDK && - !loading && - !initError && - address && - connector - ) { - initializeNexus(); - } - }, [ - status, - nexusSDK, - initError, - address, - connector, - initializeNexus, - loading, - ]); + // Only show loading if wallet is connected but Nexus is still loading + // Don't show loading if nexusSDK already exists (already initialized) + const showLoading = status === "connected" && !nexusSDK && nexusLoading; return ( -
- {children} +
+ {showLoading ? ( +
+ + Initializing Avail Nexus... +
+ + You may need to sign a message in your wallet to continue. + +
+
+ ) : status === "connected" && nexusSDK ? ( + <>{children} + ) : status !== "connected" ? ( +
+ {config.chainGifAlt} +
+ {config.heroText} +
+

+ Please connect your wallet to use the fast bridge. +

+
+ ) : null}
); } diff --git a/apps/monad/src/lib/opportunities-data.ts b/apps/monad/src/lib/opportunities-data.ts new file mode 100644 index 0000000..75cfde9 --- /dev/null +++ b/apps/monad/src/lib/opportunities-data.ts @@ -0,0 +1,444 @@ +import type { Opportunity } from "./types/opportunity"; + +// Sample opportunities data - this would typically come from an API +// Note: APY values are indicative and may change. These are third-party platforms. +export const sampleOpportunities: Opportunity[] = [ + { + id: "curvance-usdc-wmon-market", + logo: "https://files.availproject.org/fastbridge/monad/curvance-logomark.svg", + tags: ["Curvance", "USDC", "WMON"], + title: "Trade USDC to WMON on Curvance", + description: + "Trade USDC to WMON on Curvance and earn upto 7.82% APY on the deposited USDC. Access Curvance directly, deposit once, earn continuously.", + proceedText: "Trade", + apy: "7.82%", + requiresCA: true, + features: [], + display: [], + label: "on Curvance (Monad)", + banner: "https://files.availproject.org/fastbridge/monad/curvance.svg", + token: { + icon: "https://imagedelivery.net/cBNDGgkrsEA-b_ixIp9SkQ/usdc.png/public", + symbol: "USDC", + decimals: 6, + address: "0x754704bc059f8c67012fed69bc8a327a5aafb603", + }, + url: "https://app.curvance.com/", + logic: { + logics: [ + { + postBridge: { + universe: "evm", + approval: { + tokenAddress: "0x754704Bc059F8C67012fEd69BC8A327a5aafb603", + spender: "0x8EE9FC28B8Da872c38A496e9dDB9700bb7261774", + amount: "input", + }, + transaction: { + to: "0x8EE9FC28B8Da872c38A496e9dDB9700bb7261774", + abi: [ + { + inputs: [ + { internalType: 'uint256', name: 'assets', type: 'uint256' }, + { internalType: 'address', name: 'receiver', type: 'address' } + ], + name: 'depositAsCollateral', + outputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' + }, + ], + functionName: "depositAsCollateral", + params: [ + "$amount", + "$user", + ], + paramsTypes: ["bigint", "string"], + }, + }, + } + ], + }, + withdraw: { + withdrawalAmount: { + abi: [ + { + inputs: [ + { internalType: "address", name: "user", type: "address" }, + ], + name: "balanceOf", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + ], + to: "0x8EE9FC28B8Da872c38A496e9dDB9700bb7261774", + functionName: "balanceOf", + params: ["$user"], + paramsTypes: ["string"], + returnTypes: ["bigint"], + }, + logics: [ + { + preBridge: { + universe: "evm", + transaction: { + to: "0x8EE9FC28B8Da872c38A496e9dDB9700bb7261774", + abi: [ + { + inputs: [ + { internalType: 'uint256', name: 'shares', type: 'uint256' }, + { internalType: 'address', name: 'receiver', type: 'address' }, + { internalType: 'address', name: 'owner', type: 'address' } + ], + name: 'redeem', + outputs: [{ internalType: 'uint256', name: 'assets', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' + }, + ], + functionName: "redeem", + params: [ + "$amount", + "$user", + "$user" + ], + paramsTypes: ["bigint", "string", "string"], + }, + }, + }, + ], + } + }, + { + id: "neverland-usdc-supply", + logo: "https://files.availproject.org/fastbridge/monad/neverland-logomark.svg", + tags: ["Neverland", "USDC"], + title: "Earn yield on USDC on Neverland", + description: + "Lend USDC on Neverland and earn upto 13.28% APY on the deposited USDC. Access Neverland directly, deposit once, earn continuously.", + proceedText: "Stake", + apy: "13.28%", + requiresCA: true, + features: [], + display: [], + label: "on Neverland (Monad)", + banner: "https://files.availproject.org/fastbridge/monad/neverland2.png", + token: { + icon: "https://imagedelivery.net/cBNDGgkrsEA-b_ixIp9SkQ/usdc.png/public", + symbol: "USDC", + decimals: 6, + address: "0x754704bc059f8c67012fed69bc8a327a5aafb603", + }, + url: "https://neverland.io", + logic: { + logics: [ + { + postBridge: { + universe: "evm", + approval: { + tokenAddress: "0x754704Bc059F8C67012fEd69BC8A327a5aafb603", + spender: "0x80F00661b13CC5F6ccd3885bE7b4C9c67545D585", + amount: "input", + }, + transaction: { + to: "0x80F00661b13CC5F6ccd3885bE7b4C9c67545D585", + abi: [ + { + inputs: [ + { internalType: "address", name: "asset", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + { internalType: "address", name: "onBehalfOf", type: "address" }, + { internalType: "uint16", name: "referralCode", type: "uint16" }, + ], + name: "supply", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + ], + functionName: "supply", + params: [ + "0x754704Bc059F8C67012fEd69BC8A327a5aafb603", + "$amount", + "$user", + "0", + ], + paramsTypes: ["string", "bigint", "string", "number"], + }, + }, + } + ], + }, + withdraw: { + withdrawalAmount: { + abi: [ + { + inputs: [ + { internalType: "address", name: "user", type: "address" }, + ], + name: "balanceOf", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + ], + to: "0x38648958836eA88b368b4ac23b86Ad44B0fe7508", + functionName: "balanceOf", + params: ["$user"], + paramsTypes: ["string"], + returnTypes: ["bigint"], + }, + logics: [ + { + preBridge: { + universe: "evm", + transaction: { + to: "0x80F00661b13CC5F6ccd3885bE7b4C9c67545D585", + abi: [ + { + inputs: [ + { internalType: "address", name: "asset", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + { internalType: "address", name: "to", type: "address" }, + ], + name: "withdraw", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "nonpayable", + type: "function", + }, + ], + functionName: "withdraw", + params: [ + "0x38648958836eA88b368b4ac23b86Ad44B0fe7508", + "$amount", + "$user", + ], + paramsTypes: ["string", "bigint", "string"], + }, + }, + }, + ], + } + }, + { + id: "neverland-usdt-supply", + logo: "https://files.availproject.org/fastbridge/monad/neverland-logomark.svg", + tags: ["Neverland", "USDT0"], + title: "Earn yield on USDT0 on Neverland", + description: + "Lend USDT0 on Neverland and earn upto 12.69% APY on the deposited USDT0. Access Neverland directly, deposit once, earn continuously.", + proceedText: "Lend", + apy: "12.69%", + requiresCA: true, + features: [], + display: [], + label: "on Neverland (Monad)", + banner: "https://files.availproject.org/fastbridge/monad/neverland2.png", + token: { + icon: "https://imagedelivery.net/cBNDGgkrsEA-b_ixIp9SkQ/usdt.png/public", + symbol: "USDT", + decimals: 6, + address: "0xe7cd86e13ac4309349f30b3435a9d337750fc82d", + }, + url: "https://neverland.io", + logic: { + logics: [ + { + postBridge: { + universe: "evm", + approval: { + tokenAddress: "0xe7cd86e13AC4309349F30B3435a9d337750fC82D", + spender: "0x80F00661b13CC5F6ccd3885bE7b4C9c67545D585", + amount: "input", + }, + transaction: { + to: "0x80F00661b13CC5F6ccd3885bE7b4C9c67545D585", + abi: [ + { + inputs: [ + { internalType: "address", name: "asset", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + { internalType: "address", name: "onBehalfOf", type: "address" }, + { internalType: "uint16", name: "referralCode", type: "uint16" }, + ], + name: "supply", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + ], + functionName: "supply", + params: [ + "0xe7cd86e13AC4309349F30B3435a9d337750fC82D", + "$amount", + "$user", + "0", + ], + paramsTypes: ["string", "bigint", "string", "number"], + }, + }, + } + ], + }, + withdraw: { + withdrawalAmount: { + abi: [ + { + inputs: [ + { internalType: "address", name: "user", type: "address" }, + ], + name: "balanceOf", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + ], + to: "0x38648958836eA88b368b4ac23b86Ad44B0fe7508", + functionName: "balanceOf", + params: ["$user"], + paramsTypes: ["string"], + returnTypes: ["bigint"], + }, + logics: [ + { + preBridge: { + universe: "evm", + transaction: { + to: "0x39F901c32b2E0d25AE8DEaa1ee115C748f8f6bDf", + abi: [ + { + inputs: [ + { internalType: "address", name: "asset", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + { internalType: "address", name: "to", type: "address" }, + ], + name: "withdraw", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "nonpayable", + type: "function", + }, + ], + functionName: "withdraw", + params: [ + "0x39F901c32b2E0d25AE8DEaa1ee115C748f8f6bDf", + "$amount", + "$user", + ], + paramsTypes: ["string", "bigint", "string"], + }, + }, + }, + ], + } + }, + { + id: "gearbox-usdc-edge-ultrayeild", + logo: "https://files.availproject.org/fastbridge/monad/gearbox-logomark.svg", + tags: ["Gearbox", "USDC", "Ultrayeild"], + title: "Earn ultrayeild on USDC on Gearbox", + description: + "Deposit USDC on Gearbox and earn upto 6.48% APUY on the deposited USDC. Access Gearbox directly, deposit once, earn continuously.", + proceedText: "Earn", + apy: "6.48%", + requiresCA: true, + features: [], + display: [], + label: "on Gearbox (Monad)", + banner: "https://files.availproject.org/fastbridge/monad/gearbox.svg", + token: { + icon: "https://imagedelivery.net/cBNDGgkrsEA-b_ixIp9SkQ/usdc.png/public", + symbol: "USDC", + decimals: 6, + address: "0x754704bc059f8c67012fed69bc8a327a5aafb603", + }, + url: "https://app.gearbox.finance/pools", + logic: { + logics: [ + { + postBridge: { + universe: "evm", + approval: { + tokenAddress: "0x754704Bc059F8C67012fEd69BC8A327a5aafb603", + spender: "0x6B343F7B797f1488AA48C49d540690F2b2c89751", + amount: "input", + }, + transaction: { + to: "0x6B343F7B797f1488AA48C49d540690F2b2c89751", + abi: [ + { + inputs: [ + { internalType: 'uint256', name: 'assets', type: 'uint256' }, + { internalType: 'address', name: 'receiver', type: 'address' }, + { internalType: 'uint256', name: 'referralCode', type: 'uint256' } + ], + name: 'depositWithReferral', + outputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' + }, + ], + functionName: "depositWithReferral", + params: [ + "$amount", + "$user", + "0", + ], + paramsTypes: ["bigint", "string", "number"], + }, + }, + } + ], + }, + withdraw: { + withdrawalAmount: { + abi: [ + { + inputs: [ + { internalType: "address", name: "user", type: "address" }, + ], + name: "balanceOf", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + ], + to: "0x6B343F7B797f1488AA48C49d540690F2b2c89751", + functionName: "balanceOf", + params: ["$user"], + paramsTypes: ["string"], + returnTypes: ["bigint"], + }, + logics: [ + { + preBridge: { + universe: "evm", + transaction: { + to: "0x6B343F7B797f1488AA48C49d540690F2b2c89751", + abi: [ + { + inputs: [ + { internalType: 'uint256', name: 'shares', type: 'uint256' }, + { internalType: 'address', name: 'receiver', type: 'address' }, + { internalType: 'address', name: 'owner', type: 'address' } + ], + name: 'redeem', + outputs: [{ internalType: 'uint256', name: 'assets', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' + }, + ], + functionName: "redeem", + params: [ + "$amount", + "$user", + "$user" + ], + paramsTypes: ["bigint", "string", "string"], + }, + }, + }, + ], + } + }, +]; diff --git a/apps/monad/src/lib/positions-data.ts b/apps/monad/src/lib/positions-data.ts new file mode 100644 index 0000000..26f8f71 --- /dev/null +++ b/apps/monad/src/lib/positions-data.ts @@ -0,0 +1,4 @@ +import type { Position } from "./types/position"; + +// Positions data - will be populated programmatically from contract queries +export const samplePositions: Position[] = []; diff --git a/apps/monad/src/lib/types/opportunity.ts b/apps/monad/src/lib/types/opportunity.ts new file mode 100644 index 0000000..2209548 --- /dev/null +++ b/apps/monad/src/lib/types/opportunity.ts @@ -0,0 +1,192 @@ +// Types for DeFi opportunities + +type OppImage = { + type: "image"; + url: string; + dimensions?: { + width?: number; + height?: number; + }; + alt?: string; +}; + +type OppVideo = { + type: "video"; + url: string; + dimensions?: { + width?: number; + height?: number; + aspectRatio?: number; + }; + format: "video/mp4" | "video/webm"; + autoplay?: boolean; + thumbnailUrl?: string; +}; + +type OppChart = { + type: "chart"; + data: { + labels: string[]; + data: number[]; + }; +}; + +type OppDisplay = OppImage | OppVideo | OppChart; + +type OpportunityTextInput = { + type: "text"; + id: string; + label: string; + validation?: { + pattern?: string; + minLength?: string; + maxLength?: string; + }[]; +}; + +type OpportunityNumberInput = { + type: "number"; + label: string; + range?: boolean; + max?: "unifiedBalance" | number; + suffix?: { + icon: string; + text: string; + }; +}; + +type OpportunityInput = OpportunityTextInput | OpportunityNumberInput; + +type CommonOps = { + universe: "evm"; + approval?: { + tokenAddress?: string; + spender: string; + amount: "max" | "input"; + maxConfirmations?: number; + }; + transaction?: { + contract?: string; + to: string; + abi?: any[]; + functionName?: string; + params?: string[]; + paramsTypes: ( + | "string" + | "bigint" + | "float" + | "integer" + | "number" + | "boolean" + | "tuple" + )[]; + maxConfirmations?: number; + }; + signature?: { + data: any; + domain?: any; + types?: any; + api?: { + method: "GET" | "POST" | "PUT" | "WS"; + url: string; + dataStructure: any; + }; + request: + | "sign" + | "signMessage" + | "signTransaction" + | "personal_sign" + | "eth_signTypedDataV4"; + }; + api?: { + method: "GET" | "POST" | "PUT" | "WS"; + url: string; + dataStructure: any; + }; +}; + +type OppLogic = { + preBridge?: CommonOps; + postBridge?: CommonOps; +}; + +interface OpportunityLogic { + // token: { + // symbol: string; + // icon: string; + // decimals: number; + // address: string; + // chain: { + // universe: "evm"; + // id: number | string; + // network: string; + // }; + // }; + logics: OppLogic[]; +} + +interface Opportunity { + id: string; + logo?: string; + tags?: string[]; + title: string; + description: string; + banner?: string; + apy?: string; // e.g., "7.82%" + features: { + key: string; + value: string; + }[]; + display: OppDisplay[]; + requiresCA: boolean; + proceedText: string; + token: { + icon: string; + symbol: string; + decimals: number; + address: string; + }; + logic: OpportunityLogic; + label: string; + withdraw?: { + withdrawalAmount: { + abi?: any[]; + functionName?: string; + params?: string[]; + paramsTypes?: ( + | "string" + | "bigint" + | "float" + | "integer" + | "number" + | "boolean" + | "tuple" + )[]; + returnTypes?: ( + | "string" + | "bigint" + | "float" + | "integer" + | "number" + | "boolean" + | "tuple" + )[]; + isNative?: boolean; + to: string; + }; + logics: OppLogic[]; + }; + url?: string; +} + +export type { + Opportunity, + OpportunityLogic, + CommonOps, + OppDisplay, + OppImage, + OppVideo, + OppChart, + OppLogic, + OpportunityInput, +}; diff --git a/apps/monad/src/lib/types/position.ts b/apps/monad/src/lib/types/position.ts new file mode 100644 index 0000000..31153af --- /dev/null +++ b/apps/monad/src/lib/types/position.ts @@ -0,0 +1,27 @@ +// Types for user positions (invested opportunities) + +export interface PositionInterestRate { + rate: string; + period: string; // e.g., "7 Days", "30 Days", "360 Days" +} + +export interface Position { + id: string; + opportunityId: string; + protocol: string; + chain: string; + token: { + symbol: string; + icon: string; + decimals: number; + }; + currentValue: string; + totalDeposits: string; + depositedUsd?: string; + currentValueUsd?: string; + returnRate: string; // e.g., "8.6%" + returnType: string; // e.g., "XIRR", "APY", "APR" + interestRates?: PositionInterestRate[]; + category: "lending" | "staking" | "borrowing" | "all"; + createdAt?: string; +} diff --git a/apps/monad/src/pages/Opportunities.tsx b/apps/monad/src/pages/Opportunities.tsx new file mode 100644 index 0000000..b63fc9c --- /dev/null +++ b/apps/monad/src/pages/Opportunities.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { OpportunityList } from "@/components/opportunities/OpportunityList"; +import { sampleOpportunities } from "@/lib/opportunities-data"; + +export default function Opportunities() { + return ( +
+ {/* Header */} +
+

+ DeFi Opportunities +

+

+ Explore yield-generating opportunities across multiple chains. Deposit + once, earn continuously. +

+

+ ⚠️ These are third-party platforms. APY rates are indicative and may + change based on market conditions. +

+
+ + {/* Opportunities List */} + + + {/* Powered by footer */} +
+

+ Powered by +

+ + Avail Logo + +
+
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dff0182..920e3ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,11 +133,14 @@ importers: apps/megaeth: dependencies: '@avail-project/nexus-core': - specifier: github:availproject/nexus-sdk#cca99c1a570a8598334e3e89453d39a27d70ede7 - version: https://codeload.github.com/availproject/nexus-sdk/tar.gz/cca99c1a570a8598334e3e89453d39a27d70ede7(@opentelemetry/api@1.9.0)(bufferutil@4.1.0)(google-protobuf@3.21.4)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + specifier: ^1.1.1 + version: 1.1.1(@opentelemetry/api@1.9.0)(bufferutil@4.1.0)(google-protobuf@3.21.4)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + '@radix-ui/react-checkbox': + specifier: ^1.3.3 + version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) @@ -148,8 +151,11 @@ importers: specifier: ^2.2.6 version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) '@radix-ui/react-slot': - specifier: ^1.2.3 + specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.10)(react@19.2.2) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) @@ -183,6 +189,9 @@ importers: motion: specifier: ^12.29.2 version: 12.30.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + next: + specifier: ^16.1.6 + version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.2(react@19.2.2))(react@19.2.2) @@ -195,23 +204,26 @@ importers: react-dom: specifier: 19.2.2 version: 19.2.2(react@19.2.2) + react-router-dom: + specifier: ^7.13.0 + version: 7.13.0(react-dom@19.2.2(react@19.2.2))(react@19.2.2) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.2(react@19.2.2))(react@19.2.2) tailwind-merge: - specifier: ^3.3.1 + specifier: ^3.4.0 version: 3.4.0 tailwindcss: specifier: ^4.1.13 version: 4.1.18 viem: - specifier: ^2.37.9 + specifier: ^2.45.1 version: 2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) vite-plugin-node-polyfills: specifier: ^0.24.0 version: 0.24.0(rollup@4.57.1)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)) wagmi: - specifier: ^2.17.5 + specifier: ^2.19.5 version: 2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) devDependencies: '@eslint/js': @@ -254,29 +266,41 @@ importers: apps/monad: dependencies: '@avail-project/nexus-core': - specifier: github:availproject/nexus-sdk#cca99c1a570a8598334e3e89453d39a27d70ede7 - version: https://codeload.github.com/availproject/nexus-sdk/tar.gz/cca99c1a570a8598334e3e89453d39a27d70ede7(@opentelemetry/api@1.9.0)(bufferutil@4.1.0)(google-protobuf@3.21.4)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + specifier: github:availproject/nexus-sdk#014065adde74b2c8d635f52486a475dfd150204f + version: https://codeload.github.com/availproject/nexus-sdk/tar.gz/014065adde74b2c8d635f52486a475dfd150204f(@opentelemetry/api@1.9.0)(bufferutil@4.1.0)(google-protobuf@3.21.4)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + '@radix-ui/react-checkbox': + specifier: ^1.3.3 + version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + '@radix-ui/react-collapsible': + specifier: ^1.1.12 + version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) '@radix-ui/react-label': specifier: ^2.1.7 version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) '@radix-ui/react-select': specifier: ^2.2.6 version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) '@radix-ui/react-slot': - specifier: ^1.2.3 + specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.10)(react@19.2.2) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) '@rainbow-me/rainbowkit': specifier: ^2.2.8 - version: 2.2.10(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)(typescript@5.8.3)(viem@2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6)) + version: 2.2.10(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)(typescript@5.8.3)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6)) '@tailwindcss/vite': specifier: ^4.1.16 version: 4.1.18(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)) @@ -294,13 +318,19 @@ importers: version: 2.1.1 connectkit: specifier: ^1.9.1 - version: 1.9.1(@babel/core@7.29.0)(@tanstack/react-query@5.90.20(react@19.2.2))(react-dom@19.2.2(react@19.2.2))(react-is@16.13.1)(react@19.2.2)(viem@2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6)) + version: 1.9.1(@babel/core@7.29.0)(@tanstack/react-query@5.90.20(react@19.2.2))(react-dom@19.2.2(react@19.2.2))(react-is@16.13.1)(react@19.2.2)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6)) decimal.js: specifier: ^10.6.0 version: 10.6.0 + framer-motion: + specifier: ^12.34.1 + version: 12.34.1(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.2.2) + next: + specifier: ^16.1.6 + version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.2(react@19.2.2))(react@19.2.2) @@ -313,24 +343,27 @@ importers: react-dom: specifier: 19.2.2 version: 19.2.2(react@19.2.2) + react-router-dom: + specifier: ^7.13.0 + version: 7.13.0(react-dom@19.2.2(react@19.2.2))(react@19.2.2) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.2(react@19.2.2))(react@19.2.2) tailwind-merge: - specifier: ^3.3.1 + specifier: ^3.4.0 version: 3.4.0 tailwindcss: specifier: ^4.1.13 version: 4.1.18 viem: - specifier: ^2.37.9 - version: 2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + specifier: ^2.46.2 + version: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) vite-plugin-node-polyfills: specifier: ^0.24.0 version: 0.24.0(rollup@4.57.1)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)) wagmi: - specifier: ^2.17.5 - version: 2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) + specifier: ^2.19.5 + version: 2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) devDependencies: '@eslint/js': specifier: ^9.36.0 @@ -448,6 +481,17 @@ packages: resolution: {integrity: sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==} hasBin: true + '@avail-project/ca-common@1.0.0': + resolution: {integrity: sha512-mKRWv/eT2jxZk7PGTeUOFy/QAqEzGHOyF7tVSJhB1wewlbKAZJ3+rXB7QaoyLAA3BY3P6aeMkOi48EfZxEExSA==} + peerDependencies: + '@cosmjs/proto-signing': ^0.34.0 + '@cosmjs/stargate': ^0.34.0 + axios: ^1.10.0 + decimal.js: ^10.6.0 + long: ^5.3.2 + msgpackr: ^1.11.4 + viem: ^2.31.7 + '@avail-project/ca-common@https://codeload.github.com/availproject/ca-common/tar.gz/91801f57abd83929bc4d9af0a39815b9038234b5': resolution: {tarball: https://codeload.github.com/availproject/ca-common/tar.gz/91801f57abd83929bc4d9af0a39815b9038234b5} version: 1.0.0-beta.17 @@ -460,6 +504,15 @@ packages: msgpackr: ^1.11.4 viem: ^2.31.7 + '@avail-project/nexus-core@1.1.1': + resolution: {integrity: sha512-cowGe9//VEJZiAP1k+V3LegBNkmFiWi6uU5v2y7OXGLxwzvxqm3zcYayN6pUTAmHtE/rYEtJ4J524SRVoZKOTQ==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + + '@avail-project/nexus-core@https://codeload.github.com/availproject/nexus-sdk/tar.gz/014065adde74b2c8d635f52486a475dfd150204f': + resolution: {tarball: https://codeload.github.com/availproject/nexus-sdk/tar.gz/014065adde74b2c8d635f52486a475dfd150204f} + version: 1.1.2 + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + '@avail-project/nexus-core@https://codeload.github.com/availproject/nexus-sdk/tar.gz/cca99c1a570a8598334e3e89453d39a27d70ede7': resolution: {tarball: https://codeload.github.com/availproject/nexus-sdk/tar.gz/cca99c1a570a8598334e3e89453d39a27d70ede7} version: 1.0.0 @@ -672,6 +725,9 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@emotion/hash@0.9.2': resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} @@ -945,6 +1001,143 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@improbable-eng/grpc-web-node-http-transport@0.15.0': resolution: {integrity: sha512-HLgJfVolGGpjc9DWPhmMmXJx8YGzkek7jcCFO1YYkSOoO81MWRZentPOd/JiKiZuU08wtc4BG+WNuGzsQB5jZA==} peerDependencies: @@ -1162,6 +1355,57 @@ packages: resolution: {integrity: sha512-7G0Uf0yK3f2bjElBLGHIQzgRgMESczOMyYVasq1XK8P5HaXtlW4eQhz9MBL+TQILZLaruq+ClGId+hH0w4jvWw==} engines: {node: '>=18'} + '@next/env@16.1.6': + resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} + + '@next/swc-darwin-arm64@16.1.6': + resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.1.6': + resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.1.6': + resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.1.6': + resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.1.6': + resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.1.6': + resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.1.6': + resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.1.6': + resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@noble/ciphers@1.2.1': resolution: {integrity: sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA==} engines: {node: ^14.21.3 || >=16} @@ -2693,6 +2937,9 @@ packages: '@starkware-industries/starkware-crypto-utils@0.2.1': resolution: {integrity: sha512-rA5O9b53zaoBOQwQxBd0cbumFbQoBm9NH/vfu+o0Cq3oouEbNPALneLlLjOmFEId2/WOJ5ecC64rFLI/PwuIPQ==} + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.18': resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} @@ -3395,6 +3642,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} @@ -4097,6 +4347,20 @@ packages: react-dom: optional: true + framer-motion@12.34.1: + resolution: {integrity: sha512-kcZyNaYQfvE2LlH6+AyOaJAQV4rGp5XbzfhsZpiSZcwDMfZUHhuxLWeyRzf5I7jip3qKRpuimPA9pXXfr111kQ==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + framer-motion@6.5.1: resolution: {integrity: sha512-o1BGqqposwi7cgDrtg0dNONhkmPsUFDaLcKXigzuTFC5x58mE8iyTazxSudFzmT6MEyJKfjjU8ItoMe3W+3fiw==} peerDependencies: @@ -4786,6 +5050,9 @@ packages: motion-dom@12.30.0: resolution: {integrity: sha512-p6Mp+lxm+mK4O86YVyL6KAlFDVCIqpmcBt+uMVapMBqltPXpwZ5Wj2crnN2VE7lwsas0ONCPIW9YVpMigu4F5g==} + motion-dom@12.34.1: + resolution: {integrity: sha512-SC7ZC5dRcGwku2g7EsPvI4q/EzHumUbqsDNumBmZTLFg+goBO5LTJvDu9MAxx+0mtX4IA78B2be/A3aRjY0jnw==} + motion-utils@12.29.2: resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} @@ -4854,6 +5121,27 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@16.1.6: + resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + node-addon-api@2.0.2: resolution: {integrity: sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==} @@ -4991,6 +5279,14 @@ packages: typescript: optional: true + ox@0.12.4: + resolution: {integrity: sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + ox@0.6.7: resolution: {integrity: sha512-17Gk/eFsFRAZ80p5eKqv89a57uXjd3NgIf1CaXojATPBuujVc/fQSVhBeAU9JCRB+k7J50WQAyWTxK19T9GgbA==} peerDependencies: @@ -5180,6 +5476,10 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -5353,6 +5653,23 @@ packages: '@types/react': optional: true + react-router-dom@7.13.0: + resolution: {integrity: sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.13.0: + resolution: {integrity: sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -5509,6 +5826,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -5520,6 +5842,9 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -5542,6 +5867,10 @@ packages: shallowequal@1.1.0: resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -5692,6 +6021,19 @@ packages: react-dom: '>= 16.8.0' react-is: '>= 16.8.0' + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + superstruct@1.0.4: resolution: {integrity: sha512-7JpaAoX2NGyoFlI9NBh66BQXGONc+uE+MRS5i2iOBKuS4e+ccgMDjATgZldkah+33DakBxDHiss9kvUcGAO8UQ==} engines: {node: '>=14.0.0'} @@ -6084,6 +6426,14 @@ packages: typescript: optional: true + viem@2.46.2: + resolution: {integrity: sha512-w8Qv5Vyo7TfXcH3vgmxRa1NRvzJCDy2aSGSRsJn3503nC/qVbgEQ+n3aj/CkqWXbloudZh97h5o5aQrQSVGy0w==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + vite-plugin-node-polyfills@0.24.0: resolution: {integrity: sha512-GA9QKLH+vIM8NPaGA+o2t8PDfFUl32J8rUp1zQfMKVJQiNkOX4unE51tR6ppl6iKw5yOrDAdSH7r/UIFLCVhLw==} peerDependencies: @@ -6385,7 +6735,7 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@avail-project/ca-common@https://codeload.github.com/availproject/ca-common/tar.gz/91801f57abd83929bc4d9af0a39815b9038234b5(@cosmjs/proto-signing@0.34.1)(@cosmjs/stargate@0.34.1(bufferutil@4.1.0)(utf-8-validate@5.0.10))(axios@1.13.4)(decimal.js@10.6.0)(google-protobuf@3.21.4)(long@5.3.2)(msgpackr@1.11.8)(viem@2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))': + '@avail-project/ca-common@1.0.0(@cosmjs/proto-signing@0.34.1)(@cosmjs/stargate@0.34.1(bufferutil@4.1.0)(utf-8-validate@5.0.10))(axios@1.13.4)(decimal.js@10.6.0)(google-protobuf@3.21.4)(long@5.3.2)(msgpackr@1.11.8)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))': dependencies: '@bufbuild/protobuf': 2.11.0 '@cosmjs/proto-signing': 0.34.1 @@ -6399,13 +6749,31 @@ snapshots: long: 5.3.2 msgpackr: 1.11.8 tslib: 2.8.1 - viem: 2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: - google-protobuf - '@avail-project/nexus-core@https://codeload.github.com/availproject/nexus-sdk/tar.gz/cca99c1a570a8598334e3e89453d39a27d70ede7(@opentelemetry/api@1.9.0)(bufferutil@4.1.0)(google-protobuf@3.21.4)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)': + '@avail-project/ca-common@https://codeload.github.com/availproject/ca-common/tar.gz/91801f57abd83929bc4d9af0a39815b9038234b5(@cosmjs/proto-signing@0.34.1)(@cosmjs/stargate@0.34.1(bufferutil@4.1.0)(utf-8-validate@5.0.10))(axios@1.13.4)(decimal.js@10.6.0)(google-protobuf@3.21.4)(long@5.3.2)(msgpackr@1.11.8)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))': + dependencies: + '@bufbuild/protobuf': 2.11.0 + '@cosmjs/proto-signing': 0.34.1 + '@cosmjs/stargate': 0.34.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@improbable-eng/grpc-web': 0.15.0(google-protobuf@3.21.4) + '@improbable-eng/grpc-web-node-http-transport': 0.15.0(@improbable-eng/grpc-web@0.15.0(google-protobuf@3.21.4)) + axios: 1.13.4 + browser-headers: 0.4.1 + decimal.js: 10.6.0 + es-toolkit: 1.44.0 + long: 5.3.2 + msgpackr: 1.11.8 + tslib: 2.8.1 + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + transitivePeerDependencies: + - google-protobuf + + '@avail-project/nexus-core@1.1.1(@opentelemetry/api@1.9.0)(bufferutil@4.1.0)(google-protobuf@3.21.4)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: - '@avail-project/ca-common': https://codeload.github.com/availproject/ca-common/tar.gz/91801f57abd83929bc4d9af0a39815b9038234b5(@cosmjs/proto-signing@0.34.1)(@cosmjs/stargate@0.34.1(bufferutil@4.1.0)(utf-8-validate@5.0.10))(axios@1.13.4)(decimal.js@10.6.0)(google-protobuf@3.21.4)(long@5.3.2)(msgpackr@1.11.8)(viem@2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@avail-project/ca-common': 1.0.0(@cosmjs/proto-signing@0.34.1)(@cosmjs/stargate@0.34.1(bufferutil@4.1.0)(utf-8-validate@5.0.10))(axios@1.13.4)(decimal.js@10.6.0)(google-protobuf@3.21.4)(long@5.3.2)(msgpackr@1.11.8)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)) '@cosmjs/proto-signing': 0.34.1 '@cosmjs/stargate': 0.34.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) '@metamask/safe-event-emitter': 3.1.2 @@ -6425,7 +6793,7 @@ snapshots: posthog-js: 1.336.4 tronweb: 6.1.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) tslib: 2.8.1 - viem: 2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: - '@opentelemetry/api' - bufferutil @@ -6436,18 +6804,84 @@ snapshots: - utf-8-validate - zod - '@babel/code-frame@7.29.0': + '@avail-project/nexus-core@https://codeload.github.com/availproject/nexus-sdk/tar.gz/014065adde74b2c8d635f52486a475dfd150204f(@opentelemetry/api@1.9.0)(bufferutil@4.1.0)(google-protobuf@3.21.4)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.0': {} + '@avail-project/ca-common': 1.0.0(@cosmjs/proto-signing@0.34.1)(@cosmjs/stargate@0.34.1(bufferutil@4.1.0)(utf-8-validate@5.0.10))(axios@1.13.4)(decimal.js@10.6.0)(google-protobuf@3.21.4)(long@5.3.2)(msgpackr@1.11.8)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@cosmjs/proto-signing': 0.34.1 + '@cosmjs/stargate': 0.34.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@metamask/safe-event-emitter': 3.1.2 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@starkware-industries/starkware-crypto-utils': 0.2.1 + '@tronweb3/tronwallet-abstract-adapter': 1.1.10(bufferutil@4.1.0)(utf-8-validate@5.0.10) + axios: 1.13.4 + buffer: 6.0.3 + decimal.js: 10.6.0 + es-toolkit: 1.44.0 + it-ws: 6.1.5(bufferutil@4.1.0)(utf-8-validate@5.0.10) + long: 5.3.2 + msgpackr: 1.11.8 + posthog-js: 1.336.4 + tronweb: 6.1.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + tslib: 2.8.1 + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + transitivePeerDependencies: + - '@opentelemetry/api' + - bufferutil + - debug + - encoding + - google-protobuf + - typescript + - utf-8-validate + - zod - '@babel/core@7.29.0': + '@avail-project/nexus-core@https://codeload.github.com/availproject/nexus-sdk/tar.gz/cca99c1a570a8598334e3e89453d39a27d70ede7(@opentelemetry/api@1.9.0)(bufferutil@4.1.0)(google-protobuf@3.21.4)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.0 + '@avail-project/ca-common': https://codeload.github.com/availproject/ca-common/tar.gz/91801f57abd83929bc4d9af0a39815b9038234b5(@cosmjs/proto-signing@0.34.1)(@cosmjs/stargate@0.34.1(bufferutil@4.1.0)(utf-8-validate@5.0.10))(axios@1.13.4)(decimal.js@10.6.0)(google-protobuf@3.21.4)(long@5.3.2)(msgpackr@1.11.8)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@cosmjs/proto-signing': 0.34.1 + '@cosmjs/stargate': 0.34.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@metamask/safe-event-emitter': 3.1.2 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@starkware-industries/starkware-crypto-utils': 0.2.1 + '@tronweb3/tronwallet-abstract-adapter': 1.1.10(bufferutil@4.1.0)(utf-8-validate@5.0.10) + axios: 1.13.4 + buffer: 6.0.3 + decimal.js: 10.6.0 + es-toolkit: 1.44.0 + it-ws: 6.1.5(bufferutil@4.1.0)(utf-8-validate@5.0.10) + long: 5.3.2 + msgpackr: 1.11.8 + posthog-js: 1.336.4 + tronweb: 6.1.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + tslib: 2.8.1 + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + transitivePeerDependencies: + - '@opentelemetry/api' + - bufferutil + - debug + - encoding + - google-protobuf + - typescript + - utf-8-validate + - zod + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.0 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helpers': 7.28.6 @@ -6647,7 +7081,7 @@ snapshots: idb-keyval: 6.2.1 ox: 0.6.9(typescript@5.8.3)(zod@4.3.6) preact: 10.24.2 - viem: 2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) zustand: 5.0.3(@types/react@19.2.10)(react@19.2.2)(use-sync-external-store@1.4.0(react@19.2.2)) transitivePeerDependencies: - '@types/react' @@ -6676,7 +7110,7 @@ snapshots: jose: 6.1.3 md5: 2.3.0 uncrypto: 0.1.3 - viem: 2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) zod: 3.25.76 transitivePeerDependencies: - bufferutil @@ -6708,7 +7142,7 @@ snapshots: idb-keyval: 6.2.1 ox: 0.6.9(typescript@5.8.3)(zod@4.3.6) preact: 10.24.2 - viem: 2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) zustand: 5.0.3(@types/react@19.2.10)(react@19.2.2)(use-sync-external-store@1.4.0(react@19.2.2)) transitivePeerDependencies: - '@types/react' @@ -6825,6 +7259,11 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emotion/hash@0.9.2': {} '@emotion/is-prop-valid@0.8.8': @@ -7014,6 +7453,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@gemini-wallet/core@0.3.2(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))': + dependencies: + '@metamask/rpc-errors': 7.0.2 + eventemitter3: 5.0.1 + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@hono/node-server@1.19.9(hono@4.11.7)': dependencies: hono: 4.11.7 @@ -7029,6 +7476,103 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.0.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.8.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@improbable-eng/grpc-web-node-http-transport@0.15.0(@improbable-eng/grpc-web@0.15.0(google-protobuf@3.21.4))': dependencies: '@improbable-eng/grpc-web': 0.15.0(google-protobuf@3.21.4) @@ -7249,7 +7793,7 @@ snapshots: '@ethereumjs/tx': 4.2.0 '@types/debug': 4.1.12 debug: 4.4.3(supports-color@5.5.0) - semver: 7.7.3 + semver: 7.7.4 superstruct: 1.0.4 transitivePeerDependencies: - supports-color @@ -7366,6 +7910,32 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 + '@next/env@16.1.6': {} + + '@next/swc-darwin-arm64@16.1.6': + optional: true + + '@next/swc-darwin-x64@16.1.6': + optional: true + + '@next/swc-linux-arm64-gnu@16.1.6': + optional: true + + '@next/swc-linux-arm64-musl@16.1.6': + optional: true + + '@next/swc-linux-x64-gnu@16.1.6': + optional: true + + '@next/swc-linux-x64-musl@16.1.6': + optional: true + + '@next/swc-win32-arm64-msvc@16.1.6': + optional: true + + '@next/swc-win32-x64-msvc@16.1.6': + optional: true + '@noble/ciphers@1.2.1': {} '@noble/ciphers@1.3.0': {} @@ -8323,11 +8893,30 @@ snapshots: - babel-plugin-macros - typescript + '@rainbow-me/rainbowkit@2.2.10(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)(typescript@5.8.3)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6))': + dependencies: + '@tanstack/react-query': 5.90.20(react@19.2.2) + '@vanilla-extract/css': 1.17.3 + '@vanilla-extract/dynamic': 2.1.4 + '@vanilla-extract/sprinkles': 1.6.4(@vanilla-extract/css@1.17.3) + clsx: 2.1.1 + cuer: 0.0.3(react-dom@19.2.2(react@19.2.2))(react@19.2.2)(typescript@5.8.3) + react: 19.2.2 + react-dom: 19.2.2(react@19.2.2) + react-remove-scroll: 2.6.2(@types/react@19.2.10)(react@19.2.2) + ua-parser-js: 1.0.41 + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + wagmi: 2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) + transitivePeerDependencies: + - '@types/react' + - babel-plugin-macros + - typescript + '@reown/appkit-common@1.7.8(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.22.4)': dependencies: big.js: 6.2.2 dayjs: 1.11.13 - viem: 2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.22.4) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.22.4) transitivePeerDependencies: - bufferutil - typescript @@ -8338,7 +8927,7 @@ snapshots: dependencies: big.js: 6.2.2 dayjs: 1.11.13 - viem: 2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: - bufferutil - typescript @@ -8351,7 +8940,7 @@ snapshots: '@reown/appkit-wallet': 1.7.8(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10) '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) valtio: 1.13.2(@types/react@19.2.10)(react@19.2.2) - viem: 2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -8501,7 +9090,7 @@ snapshots: '@walletconnect/logger': 2.1.2 '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) valtio: 1.13.2(@types/react@19.2.10)(react@19.2.2) - viem: 2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -8555,7 +9144,7 @@ snapshots: '@walletconnect/universal-provider': 2.21.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) bs58: 6.0.0 valtio: 1.13.2(@types/react@19.2.10)(react@19.2.2) - viem: 2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -8690,7 +9279,7 @@ snapshots: '@safe-global/safe-apps-sdk@9.1.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: '@safe-global/safe-gateway-typescript-sdk': 3.23.1 - viem: 2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) transitivePeerDependencies: - bufferutil - typescript @@ -8717,7 +9306,7 @@ snapshots: '@scure/bip32@1.7.0': dependencies: - '@noble/curves': 1.9.1 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 @@ -9199,7 +9788,7 @@ snapshots: dependencies: '@babel/runtime': 7.28.6 '@noble/curves': 1.9.7 - '@noble/hashes': 1.4.0 + '@noble/hashes': 1.8.0 '@solana/buffer-layout': 4.0.1 '@solana/codecs-numbers': 2.3.0(typescript@5.8.3) agentkeepalive: 4.6.0 @@ -9237,6 +9826,10 @@ snapshots: minimalistic-crypto-utils: 1.0.1 stream-browserify: 3.0.0 + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + '@swc/helpers@0.5.18': dependencies: tslib: 2.8.1 @@ -9597,6 +10190,59 @@ snapshots: - wagmi - zod + '@wagmi/connectors@6.2.0(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6))(zod@4.3.6)': + dependencies: + '@base-org/account': 2.4.0(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(utf-8-validate@5.0.10)(zod@4.3.6) + '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(utf-8-validate@5.0.10)(zod@4.3.6) + '@gemini-wallet/core': 0.3.2(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@metamask/sdk': 0.33.1(bufferutil@4.1.0)(utf-8-validate@5.0.10) + '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + cbw-sdk: '@coinbase/wallet-sdk@3.9.3' + porto: 0.2.35(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6)) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@tanstack/react-query' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - debug + - encoding + - expo-auth-session + - expo-crypto + - expo-web-browser + - fastestsmallesttextencoderdecoder + - immer + - ioredis + - react + - react-native + - supports-color + - uploadthing + - use-sync-external-store + - utf-8-validate + - wagmi + - zod + '@wagmi/core@2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(viem@2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))': dependencies: eventemitter3: 5.0.1 @@ -9612,6 +10258,21 @@ snapshots: - react - use-sync-external-store + '@wagmi/core@2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))': + dependencies: + eventemitter3: 5.0.1 + mipd: 0.0.7(typescript@5.8.3) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + zustand: 5.0.0(@types/react@19.2.10)(react@19.2.2)(use-sync-external-store@1.4.0(react@19.2.2)) + optionalDependencies: + '@tanstack/query-core': 5.90.20 + typescript: 5.8.3 + transitivePeerDependencies: + - '@types/react' + - immer + - react + - use-sync-external-store + '@walletconnect/core@2.21.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)': dependencies: '@walletconnect/heartbeat': 1.2.2 @@ -10514,6 +11175,8 @@ snapshots: cli-width@4.1.0: {} + client-only@0.0.1: {} + cliui@6.0.0: dependencies: string-width: 4.2.3 @@ -10572,6 +11235,26 @@ snapshots: - '@babel/core' - react-is + connectkit@1.9.1(@babel/core@7.29.0)(@tanstack/react-query@5.90.20(react@19.2.2))(react-dom@19.2.2(react@19.2.2))(react-is@16.13.1)(react@19.2.2)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6)): + dependencies: + '@tanstack/react-query': 5.90.20(react@19.2.2) + buffer: 6.0.3 + detect-browser: 5.3.0 + family: 0.1.6(react-dom@19.2.2(react@19.2.2))(react@19.2.2)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6)) + framer-motion: 6.5.1(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + qrcode: 1.5.4 + react: 19.2.2 + react-dom: 19.2.2(react@19.2.2) + react-transition-state: 1.1.5(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + react-use-measure: 2.1.7(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + resize-observer-polyfill: 1.5.1 + styled-components: 5.3.11(@babel/core@7.29.0)(react-dom@19.2.2(react@19.2.2))(react-is@16.13.1)(react@19.2.2) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + wagmi: 2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) + transitivePeerDependencies: + - '@babel/core' + - react-is + console-browserify@1.2.0: {} constants-browserify@1.0.0: {} @@ -11197,6 +11880,13 @@ snapshots: viem: 2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) wagmi: 2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) + family@0.1.6(react-dom@19.2.2(react@19.2.2))(react@19.2.2)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6)): + optionalDependencies: + react: 19.2.2 + react-dom: 19.2.2(react@19.2.2) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + wagmi: 2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -11306,6 +11996,16 @@ snapshots: react: 19.2.2 react-dom: 19.2.2(react@19.2.2) + framer-motion@12.34.1(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2): + dependencies: + motion-dom: 12.34.1 + motion-utils: 12.29.2 + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.4.0 + react: 19.2.2 + react-dom: 19.2.2(react@19.2.2) + framer-motion@6.5.1(react-dom@19.2.2(react@19.2.2))(react@19.2.2): dependencies: '@motionone/dom': 10.12.0 @@ -11915,6 +12615,10 @@ snapshots: dependencies: motion-utils: 12.29.2 + motion-dom@12.34.1: + dependencies: + motion-utils: 12.29.2 + motion-utils@12.29.2: {} motion@12.30.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2): @@ -11988,6 +12692,31 @@ snapshots: react: 19.2.2 react-dom: 19.2.2(react@19.2.2) + next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.2(react@19.2.2))(react@19.2.2): + dependencies: + '@next/env': 16.1.6 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001767 + postcss: 8.4.31 + react: 19.2.2 + react-dom: 19.2.2(react@19.2.2) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.2) + optionalDependencies: + '@next/swc-darwin-arm64': 16.1.6 + '@next/swc-darwin-x64': 16.1.6 + '@next/swc-linux-arm64-gnu': 16.1.6 + '@next/swc-linux-arm64-musl': 16.1.6 + '@next/swc-linux-x64-gnu': 16.1.6 + '@next/swc-linux-x64-musl': 16.1.6 + '@next/swc-win32-arm64-msvc': 16.1.6 + '@next/swc-win32-x64-msvc': 16.1.6 + '@opentelemetry/api': 1.9.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + node-addon-api@2.0.2: {} node-addon-api@5.1.0: {} @@ -12150,7 +12879,22 @@ snapshots: outvariant@1.4.3: {} - ox@0.11.3(typescript@5.8.3)(zod@3.22.4): + ox@0.11.3(typescript@5.8.3)(zod@4.3.6): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.8.3)(zod@4.3.6) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - zod + + ox@0.12.4(typescript@5.8.3)(zod@3.22.4): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -12165,7 +12909,7 @@ snapshots: transitivePeerDependencies: - zod - ox@0.11.3(typescript@5.8.3)(zod@3.25.76): + ox@0.12.4(typescript@5.8.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -12180,7 +12924,7 @@ snapshots: transitivePeerDependencies: - zod - ox@0.11.3(typescript@5.8.3)(zod@4.3.6): + ox@0.12.4(typescript@5.8.3)(zod@4.3.6): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -12198,11 +12942,11 @@ snapshots: ox@0.6.7(typescript@5.8.3)(zod@4.3.6): dependencies: '@adraffy/ens-normalize': 1.11.1 - '@noble/curves': 1.8.1 - '@noble/hashes': 1.7.1 - '@scure/bip32': 1.6.2 - '@scure/bip39': 1.5.4 - abitype: 1.0.8(typescript@5.8.3)(zod@4.3.6) + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.8.3)(zod@4.3.6) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.8.3 @@ -12374,6 +13118,26 @@ snapshots: - immer - use-sync-external-store + porto@0.2.35(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6)): + dependencies: + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + hono: 4.11.7 + idb-keyval: 6.2.2 + mipd: 0.0.7(typescript@5.8.3) + ox: 0.9.17(typescript@5.8.3)(zod@4.3.6) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + zod: 4.3.6 + zustand: 5.0.11(@types/react@19.2.10)(react@19.2.2)(use-sync-external-store@1.4.0(react@19.2.2)) + optionalDependencies: + '@tanstack/react-query': 5.90.20(react@19.2.2) + react: 19.2.2 + typescript: 5.8.3 + wagmi: 2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) + transitivePeerDependencies: + - '@types/react' + - immer + - use-sync-external-store + possible-typed-array-names@1.1.0: {} postcss-selector-parser@7.1.1: @@ -12383,6 +13147,12 @@ snapshots: postcss-value-parser@4.2.0: {} + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -12626,6 +13396,20 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 + react-router-dom@7.13.0(react-dom@19.2.2(react@19.2.2))(react@19.2.2): + dependencies: + react: 19.2.2 + react-dom: 19.2.2(react@19.2.2) + react-router: 7.13.0(react-dom@19.2.2(react@19.2.2))(react@19.2.2) + + react-router@7.13.0(react-dom@19.2.2(react@19.2.2))(react@19.2.2): + dependencies: + cookie: 1.1.1 + react: 19.2.2 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.2(react@19.2.2) + react-style-singleton@2.2.3(@types/react@19.2.10)(react@19.2.2): dependencies: get-nonce: 1.0.1 @@ -12760,7 +13544,7 @@ snapshots: '@types/uuid': 8.3.4 '@types/ws': 8.18.1 buffer: 6.0.3 - eventemitter3: 5.0.1 + eventemitter3: 5.0.4 uuid: 8.3.2 ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) optionalDependencies: @@ -12803,6 +13587,8 @@ snapshots: semver@7.7.3: {} + semver@7.7.4: {} + send@1.2.1: dependencies: debug: 4.4.3(supports-color@5.5.0) @@ -12830,6 +13616,8 @@ snapshots: set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -12895,6 +13683,38 @@ snapshots: shallowequal@1.1.0: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -13063,6 +13883,13 @@ snapshots: transitivePeerDependencies: - '@babel/core' + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.2): + dependencies: + client-only: 0.0.1 + react: 19.2.2 + optionalDependencies: + '@babel/core': 7.29.0 + superstruct@1.0.4: {} superstruct@2.0.2: {} @@ -13370,7 +14197,24 @@ snapshots: - utf-8-validate - zod - viem@2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.22.4): + viem@2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.8.3)(zod@4.3.6) + isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) + ox: 0.11.3(typescript@5.8.3)(zod@4.3.6) + ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.22.4): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 @@ -13378,7 +14222,7 @@ snapshots: '@scure/bip39': 1.6.0 abitype: 1.2.3(typescript@5.8.3)(zod@3.22.4) isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - ox: 0.11.3(typescript@5.8.3)(zod@3.22.4) + ox: 0.12.4(typescript@5.8.3)(zod@3.22.4) ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.8.3 @@ -13387,7 +14231,7 @@ snapshots: - utf-8-validate - zod - viem@2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76): + viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 @@ -13395,7 +14239,7 @@ snapshots: '@scure/bip39': 1.6.0 abitype: 1.2.3(typescript@5.8.3)(zod@3.25.76) isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - ox: 0.11.3(typescript@5.8.3)(zod@3.25.76) + ox: 0.12.4(typescript@5.8.3)(zod@3.25.76) ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.8.3 @@ -13404,7 +14248,7 @@ snapshots: - utf-8-validate - zod - viem@2.45.1(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6): + viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 @@ -13412,7 +14256,7 @@ snapshots: '@scure/bip39': 1.6.0 abitype: 1.2.3(typescript@5.8.3)(zod@4.3.6) isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10)) - ox: 0.11.3(typescript@5.8.3)(zod@4.3.6) + ox: 0.12.4(typescript@5.8.3)(zod@4.3.6) ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.8.3 @@ -13490,6 +14334,51 @@ snapshots: - utf-8-validate - zod + wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6): + dependencies: + '@tanstack/react-query': 5.90.20(react@19.2.2) + '@wagmi/connectors': 6.2.0(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(wagmi@2.19.5(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@19.2.2))(@types/react@19.2.10)(bufferutil@4.1.0)(react@19.2.2)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6))(zod@4.3.6) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.20)(@types/react@19.2.10)(react@19.2.2)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.2))(viem@2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + react: 19.2.2 + use-sync-external-store: 1.4.0(react@19.2.2) + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.3.6) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@tanstack/query-core' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - debug + - encoding + - expo-auth-session + - expo-crypto + - expo-web-browser + - fastestsmallesttextencoderdecoder + - immer + - ioredis + - react-native + - supports-color + - uploadthing + - utf-8-validate + - zod + web-streams-polyfill@3.3.3: {} web-vitals@5.1.0: {} diff --git a/temp_app.tsx b/temp_app.tsx new file mode 100644 index 0000000..cc2b642 --- /dev/null +++ b/temp_app.tsx @@ -0,0 +1,149 @@ +import { BrowserRouter, useLocation, useNavigate } from "react-router-dom"; +import Web3Provider from "@/providers/web3Provider"; +import { Toaster } from "@/components/ui/sonner"; +import Navbar from "@/components/navbar"; +import { AnimatedTabs } from "@/components/ui/animated-tabs"; +import NexusProvider from "@/components/nexus/NexusProvider"; +import config from "../config"; +import { type NexusNetwork } from "@avail-project/nexus-core"; +import Home from "@/pages/Home"; +import Opportunities from "@/pages/Opportunities"; +import Positions from "@/pages/Positions"; + +import { NexusInitializer } from "@/components/NexusInitializer"; +import { fromBytes, fromHex, toBytes, toHex } from "viem"; +import Decimal from "decimal.js"; +import { useAccount } from "wagmi"; + +// @ts-expect-error not intended +window.nexus = { + fromHex, + Decimal, + fromBytes, + toBytes, + toHex, +}; + +function LandingPage() { + return ( +
+ +
+
+
+
+ {config.chainGifAlt} +
+ {config.heroText} +
+

+ Please connect your wallet to use the fast bridge. +

+
+
+
+
+ ); +} + +function AppContent() { + const location = useLocation(); + const navigate = useNavigate(); + const { isConnected } = useAccount(); + + const getCurrentTab = () => { + if (location.pathname === "/opportunities") return "opportunities"; + if (location.pathname === "/positions") return "positions"; + return "fastbridge"; + }; + + const currentTab = getCurrentTab(); + + const handleTabChange = (tabId: string) => { + if (tabId === "opportunities") navigate("/opportunities"); + else if (tabId === "positions") navigate("/positions"); + else navigate("/"); + }; + + // Show landing page if wallet is not connected + if (!isConnected) { + return ; + } + + return ( +
+ +
+
+
+
+ +
+ {/* Keep all components mounted, hide inactive ones */} +
+ +
+
+ +
+
+ +
+
+
+
+ ); +} + +export default function App() { + return ( + + + + + + + + + + + ); +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..b1eb481 --- /dev/null +++ b/vercel.json @@ -0,0 +1,28 @@ +{ + "rewrites": [ + { + "source": "/monad", + "destination": "/monad/index.html" + }, + { + "source": "/monad/:path((?!.*\\.).*)", + "destination": "/monad/index.html" + }, + { + "source": "/citrea", + "destination": "/citrea/index.html" + }, + { + "source": "/citrea/:path((?!.*\\.).*)", + "destination": "/citrea/index.html" + }, + { + "source": "/megaeth", + "destination": "/megaeth/index.html" + }, + { + "source": "/megaeth/:path((?!.*\\.).*)", + "destination": "/megaeth/index.html" + } + ] +}