diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..a1931c8 --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,23 @@ +import { Flex, Spinner, Text } from "@chakra-ui/react"; + +// Next.js App Router special file (issue #242): shown as the Suspense +// fallback for the root segment — the initial app boot, and any route +// transition slow enough to suspend — instead of a blank/unstyled screen +// while the client bundle and providers initialize. +export default function Loading() { + return ( + + + + Loading SmartDrop… + + + ); +} diff --git a/src/components/AppShell/AppShell.tsx b/src/components/AppShell/AppShell.tsx index 379acd4..31c6084 100644 --- a/src/components/AppShell/AppShell.tsx +++ b/src/components/AppShell/AppShell.tsx @@ -4,6 +4,7 @@ import ConnectWalletButton from "@/components/ConnectWalletButton/ConnectWalletB import Footer from "@/components/Footer/Footer"; import Navbar from "@/components/Navbar/Navbar"; import NetworkMismatchBanner from "@/components/NetworkMismatchBanner/NetworkMismatchBanner"; +import RpcUnreachableBanner from "@/components/RpcUnreachableBanner/RpcUnreachableBanner"; import ContextProvider from "@/context"; import { OwnConnectButtonProvider, @@ -40,6 +41,7 @@ function LayoutWrapper({ children }: { children: React.ReactNode }) { color="app.text" > + {isConnected ? ( <> diff --git a/src/components/RpcUnreachableBanner/RpcUnreachableBanner.tsx b/src/components/RpcUnreachableBanner/RpcUnreachableBanner.tsx new file mode 100644 index 0000000..d29c93d --- /dev/null +++ b/src/components/RpcUnreachableBanner/RpcUnreachableBanner.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useRpcHealth } from "@/hooks/useSorobanQuery"; +import { Alert, AlertIcon } from "@chakra-ui/react"; + +// Issue #248: when the Soroban RPC is down, every page independently shows +// its own query error instead of one unified, immediate signal. A single +// global banner is a clearer, faster way for a user to understand "this is +// a whole-app connectivity issue," not something wrong with one page. +export default function RpcUnreachableBanner() { + const { isUnreachable } = useRpcHealth(); + + if (!isUnreachable) return null; + + return ( + + + Unable to reach the Stellar network. Some data may be unavailable + until the connection is restored. + + ); +} diff --git a/src/components/UnlockModal/UnlockModal.tsx b/src/components/UnlockModal/UnlockModal.tsx index 501660c..75bc247 100644 --- a/src/components/UnlockModal/UnlockModal.tsx +++ b/src/components/UnlockModal/UnlockModal.tsx @@ -15,7 +15,7 @@ import { getContractErrorMessage, type UserPosition, } from "@/lib/soroban"; -import { QUERY_KEYS } from "@/hooks/useSorobanQuery"; +import { QUERY_KEYS, useUnlockAssetsFeePreview } from "@/hooks/useSorobanQuery"; import { useFarmStore } from "@/store/farmStore"; import { unlockAvailableAt } from "@/types/farm"; import { useQueryClient } from "@tanstack/react-query"; @@ -86,6 +86,12 @@ export default function UnlockModal() { !!position && numericAmount <= position.lockedAmount; + const feePreview = useUnlockAssetsFeePreview({ + publicKey, + poolContractId: selectedPoolContractId, + amount: amountValid ? amount : "", + }); + // Reset transient state whenever the modal opens for a (new) position. useEffect(() => { if (isUnlock && position) { @@ -431,6 +437,14 @@ export default function UnlockModal() { "New daily rate", `${newDailyRate.toFixed(6)} credits/day`, )} + {infoRow( + "Estimated Soroban fee", + feePreview.isFetching + ? "Simulating..." + : feePreview.data + ? `${feePreview.data.feePreview} stroops` + : "Unavailable", + )} )} diff --git a/src/hooks/useSorobanQuery.ts b/src/hooks/useSorobanQuery.ts index 731c6f9..7fbea36 100644 --- a/src/hooks/useSorobanQuery.ts +++ b/src/hooks/useSorobanQuery.ts @@ -7,7 +7,9 @@ import { useEffect, useRef, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { getStellarBalance, + rpcServer, simulateLockAssets, + simulateUnlockAssets, sorobanService, type UserPosition, type TransactionResult, @@ -164,6 +166,56 @@ export const useLockAssetsFeePreview = (args: { return { ...query, isFetching: query.isFetching || isDebouncing }; }; +/** + * Same live, debounced fee-preview pattern as useLockAssetsFeePreview, for + * unlock_assets — issue #240: UnlockModal submitted transactions with no fee + * estimate shown before the Freighter signing prompt. + */ +export const useUnlockAssetsFeePreview = (args: { + publicKey?: string | null; + poolContractId?: string | null; + amount?: string; +}) => { + const amount = args.amount?.trim() ?? ''; + const [debouncedAmount, setDebouncedAmount] = useState(''); + + useEffect(() => { + const id = setTimeout( + () => setDebouncedAmount(amount), + LOCK_ASSETS_FEE_PREVIEW_DEBOUNCE_MS, + ); + return () => clearTimeout(id); + }, [amount]); + + const numericAmount = Number(debouncedAmount); + const isDebouncing = amount !== debouncedAmount; + + const query = useQuery({ + queryKey: [ + 'unlockAssetsFeePreview', + args.publicKey, + args.poolContractId, + debouncedAmount, + ], + queryFn: () => + simulateUnlockAssets({ + publicKey: args.publicKey!, + poolContractId: args.poolContractId!, + amount: debouncedAmount, + }), + enabled: + !!args.publicKey && + !!args.poolContractId && + !!debouncedAmount && + Number.isFinite(numericAmount) && + numericAmount > 0, + staleTime: 10000, + retry: 1, + }); + + return { ...query, isFetching: query.isFetching || isDebouncing }; +}; + /** * Hook to lock assets in a pool. * @@ -560,3 +612,25 @@ export function usePlatformStats(initialData?: UIPlatformStats) { initialData: initialData }); } + +/** + * Global Soroban RPC connectivity check (issue #248). When the RPC endpoint + * is unreachable, individual pages each show their own query error with no + * indication it's a shared, RPC-wide outage rather than a one-off failure. + * A single, cheap getHealth() poll gives a global "is the chain reachable" + * signal a top-level banner can react to. + */ +export function useRpcHealth() { + const query = useQuery({ + queryKey: ['rpcHealth'], + queryFn: () => rpcServer.getHealth(), + staleTime: 15000, + refetchInterval: 30000, + retry: 1, + // Never let this surface a spinner/blank state anywhere it's used — + // it's a background signal, not something a page should block on. + refetchOnWindowFocus: true, + }); + + return { isUnreachable: query.isError }; +} diff --git a/src/lib/soroban.ts b/src/lib/soroban.ts index 1f68e49..6d0250f 100644 --- a/src/lib/soroban.ts +++ b/src/lib/soroban.ts @@ -305,6 +305,51 @@ export async function simulateLockAssets( }; } +/** + * Read-only unlock_assets simulation for a fee preview (issue #240) — no + * signing, no submission. Mirrors buildLockAssetsTransaction/simulateLockAssets. + */ +export async function buildUnlockAssetsTransaction( + args: BuildLockAssetsTransactionArgs, + rpcOverride?: LockAssetsRpc, +) { + const server = rpcOverride ?? rpcServer; + const account = await server.getAccount(args.publicKey); + const contract = new Contract(args.poolContractId); + const operation = contract.call( + 'unlock_assets', + Address.fromString(args.publicKey).toScVal(), + nativeToScVal(amountToStroops(args.amount), { type: 'i128' }), + ); + + return new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(operation) + .setTimeout(300) + .build(); +} + +export async function simulateUnlockAssets( + args: BuildLockAssetsTransactionArgs, + rpcOverride?: LockAssetsRpc, +) { + const server = rpcOverride ?? rpcServer; + const transaction = await buildUnlockAssetsTransaction(args, server); + const simulation = await server.simulateTransaction(transaction); + + if ('error' in simulation) { + throw new Error(`Simulation failed: ${simulation.error}`); + } + + return { + transaction, + simulation, + feePreview: String(simulation.minResourceFee ?? '0'), + }; +} + /** * Unwraps Freighter's signTransaction response, verifying that the account * which actually signed (`result.signerAddress`) is the account SmartDrop