Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/app/loading.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Flex
direction="column"
align="center"
justify="center"
gap={4}
minH="100vh"
bg="app.bg"
>
<Spinner size="xl" color="app.accent" thickness="3px" />
<Text color="app.muted" fontSize="sm">
Loading SmartDrop…
</Text>
</Flex>
);
}
2 changes: 2 additions & 0 deletions src/components/AppShell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -40,6 +41,7 @@ function LayoutWrapper({ children }: { children: React.ReactNode }) {
color="app.text"
>
<Navbar />
<RpcUnreachableBanner />
<NetworkMismatchBanner />
{isConnected ? (
<>
Expand Down
31 changes: 31 additions & 0 deletions src/components/RpcUnreachableBanner/RpcUnreachableBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Alert
status="error"
position="sticky"
top={{ base: "auto", md: "80px" }}
zIndex={10}
borderRadius={0}
justifyContent="center"
bg="app.errorBg"
color="app.errorFg"
>
<AlertIcon color="app.errorFg" />
Unable to reach the Stellar network. Some data may be unavailable
until the connection is restored.
</Alert>
);
}
16 changes: 15 additions & 1 deletion src/components/UnlockModal/UnlockModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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",
)}
</Box>
)}

Expand Down
74 changes: 74 additions & 0 deletions src/hooks/useSorobanQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 };
}
45 changes: 45 additions & 0 deletions src/lib/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading