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