diff --git a/public/sw.js b/public/sw.js index da8deafc..ad16c1b5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -21,13 +21,15 @@ */ -const SHELL_CACHE = 'stellar-shell-v3'; -const API_CACHE = 'stellar-api-v3'; +const SHELL_CACHE = 'stellar-shell-v4'; +const API_CACHE = 'stellar-api-v4'; const OLD_CACHES = [ 'stellar-shell-v1', 'stellar-shell-v2', + 'stellar-shell-v3', 'stellar-api-v1', 'stellar-api-v2', + 'stellar-api-v3', ]; // ─── App shell assets ────────────────────────────────────────────────────────── @@ -86,13 +88,31 @@ function isCacheableApi(url) { } /** - * Wrap a Response with a custom expiry timestamp header so we can - * validate TTL when reading from the SW cache. + * True when a URL targets a Horizon/Soroban account endpoint. + * Account data is the primary surface for offline read-only mode. + */ +function isAccountUrl(url) { + try { + return /\/accounts\/[A-Za-z0-9]+/.test(new URL(url).pathname); + } catch { return false; } +} + +/** + * Wrap a Response with stamped headers: + * x-sw-expires — numeric epoch expiry (existing) + * x-sw-data-source — 'network' | 'cache' | 'cache-stale' + * x-sw-cached-at — epoch ms when the entry was first stored + * x-sw-is-account — '1' for account endpoints */ function stampedResponse(response, ttlMs = API_CACHE_TTL_MS) { const expires = Date.now() + ttlMs; const headers = new Headers(response.headers); headers.set('x-sw-expires', String(expires)); + headers.set('x-sw-data-source', 'network'); + headers.set('x-sw-cached-at', String(Date.now())); + if (isAccountUrl(response.url)) { + headers.set('x-sw-is-account', '1'); + } return new Response(response.body, { status: response.status, statusText: response.statusText, @@ -100,6 +120,20 @@ function stampedResponse(response, ttlMs = API_CACHE_TTL_MS) { }); } +/** + * Copy a cached Response and re-stamp its data-source header so callers can + * tell this payload came from cache rather than the live network. + */ +function restampCachedResponse(cached, fresh = false) { + const headers = new Headers(cached.headers); + headers.set('x-sw-data-source', fresh ? 'cache' : 'cache-stale'); + return new Response(cached.body, { + status: cached.status, + statusText: cached.statusText, + headers, + }); +} + /** * Return true if a cached Response has not yet passed its sw-expires header. */ @@ -175,9 +209,10 @@ self.addEventListener('fetch', (event) => { caches.open(API_CACHE).then(async (apiCache) => { // Try L3 cache first const cached = await apiCache.match(request); - if (cached && isFreshResponse(cached)) { + const freshHit = cached && isFreshResponse(cached); + if (freshHit) { stats.apiHits++; - return cached; + return restampCachedResponse(cached, true); } // Network fetch with TTL stamp @@ -193,7 +228,7 @@ self.addEventListener('fetch', (event) => { } catch { stats.networkErrors++; // Return stale data if available, even if expired - if (cached) return cached; + if (cached) return restampCachedResponse(cached, false); throw new Error('Network unavailable and no cached response'); } }), @@ -290,6 +325,9 @@ self.addEventListener('message', (event) => { headers: { 'Content-Type': 'application/json', 'x-sw-expires': String(Date.now() + (msg.ttl || API_CACHE_TTL_MS)), + 'x-sw-data-source': 'cache', + 'x-sw-cached-at': String(Date.now()), + ...(isAccountUrl(msg.url) ? { 'x-sw-is-account': '1' } : {}), }, }); cache.put(msg.url, response); diff --git a/src/components/dashboard/Account.tsx b/src/components/dashboard/Account.tsx index f271d573..4cf0037c 100644 --- a/src/components/dashboard/Account.tsx +++ b/src/components/dashboard/Account.tsx @@ -1,16 +1,22 @@ -import React, { useEffect, useState, useMemo } from 'react' -import { format } from 'date-fns' -import type { Horizon } from '@stellar/stellar-sdk' -import { useStore } from '../../lib/store' -import { shortAddress, formatXLM, fetchAccountCreationDate, fetchAccountOffers, calculateAccountReserves } from '../../lib/stellar' -import CopyableValue from './CopyableValue' -import useAssetUsdEstimates, { formatEstimatedUsd } from '../../hooks/useAssetUsdEstimates' -import AddressLabelBadge from '../addressLabels/AddressLabelBadge' -import type { AccountOffer, ReservesInfo, InfoRowProps } from './types' +import React, { useEffect, useState, useMemo } from 'react'; +import { format } from 'date-fns'; +import type { Horizon } from '@stellar/stellar-sdk'; +import { useStore } from '../../lib/store'; +import { + shortAddress, + formatXLM, + fetchAccountCreationDate, + fetchAccountOffers, + calculateAccountReserves, +} from '../../lib/stellar'; +import CopyableValue from './CopyableValue'; +import useAssetUsdEstimates, { formatEstimatedUsd } from '../../hooks/useAssetUsdEstimates'; +import AddressLabelBadge from '../addressLabels/AddressLabelBadge'; +import type { AccountOffer, ReservesInfo, InfoRowProps } from './types'; function formatAsset(assetType: string, assetCode?: string): string { - if (assetType === 'native') return 'XLM' - return assetCode || 'Unknown' + if (assetType === 'native') return 'XLM'; + return assetCode || 'Unknown'; } function InfoRow({ label, value, mono = true, accent, copyValue, secondaryValue }: InfoRowProps) { @@ -20,19 +26,39 @@ function InfoRow({ label, value, mono = true, accent, copyValue, secondaryValue fontFamily: mono ? 'var(--font-mono)' : 'var(--font-display)', wordBreak: 'break-all', textAlign: 'right', - } + }; return ( -
- {label} -
+
+ + {label} + +
{copyValue ? ( {value ?? '—'} @@ -41,100 +67,197 @@ function InfoRow({ label, value, mono = true, accent, copyValue, secondaryValue {value ?? '—'} )} {secondaryValue && ( - + {secondaryValue} )}
- ) + ); } export default function Account() { - const { accountData, connectedAddress, network, networkStats } = useStore() - const [offers, setOffers] = useState([]) - const [offersLoading, setOffersLoading] = useState(false) - const [offersError, setOffersError] = useState(null) - const [createdAt, setCreatedAt] = useState(null) - const [createdAtLoading, setCreatedAtLoading] = useState(false) + const { accountData, connectedAddress, network, networkStats } = useStore(); + const [offers, setOffers] = useState([]); + const [offersLoading, setOffersLoading] = useState(false); + const [offersError, setOffersError] = useState(null); + const [createdAt, setCreatedAt] = useState(null); + const [createdAtLoading, setCreatedAtLoading] = useState(false); const reserves = useMemo(() => { - if (!accountData) return null - return calculateAccountReserves(accountData, networkStats, offers.length) - }, [accountData, networkStats, offers.length]) + if (!accountData) return null; + return calculateAccountReserves(accountData, networkStats, offers.length); + }, [accountData, networkStats, offers.length]); useEffect(() => { if (!connectedAddress) { - setOffers([]) - setOffersLoading(false) - setOffersError(null) - setCreatedAt(null) - setCreatedAtLoading(false) - return + setOffers([]); + setOffersLoading(false); + setOffersError(null); + setCreatedAt(null); + setCreatedAtLoading(false); + return; } - let isActive = true + let isActive = true; - setOffersLoading(true) - setOffersError(null) - setCreatedAtLoading(true) - setCreatedAt(null) + setOffersLoading(true); + setOffersError(null); + setCreatedAtLoading(true); + setCreatedAt(null); fetchAccountCreationDate(connectedAddress, network) .then((date: Date) => { - if (!isActive) return - setCreatedAt(date) + if (!isActive) return; + setCreatedAt(date); }) .finally(() => { - if (!isActive) return - setCreatedAtLoading(false) - }) + if (!isActive) return; + setCreatedAtLoading(false); + }); fetchAccountOffers(connectedAddress, network) .then((res: AccountOffer[]) => { - if (!isActive) return - setOffers(res) + if (!isActive) return; + setOffers(res); }) .catch((err: Error) => { - if (!isActive) return - setOffersError(err.message) + if (!isActive) return; + setOffersError(err.message); }) .finally(() => { - if (!isActive) return - setOffersLoading(false) - }) + if (!isActive) return; + setOffersLoading(false); + }); return () => { - isActive = false - } - }, [connectedAddress, network]) + isActive = false; + }; + }, [connectedAddress, network]); - if (!accountData) return ( -
No account loaded
- ) + if (!accountData) + return ( +
+ No account loaded +
+ ); - const xlm = accountData.balances?.find((b: { asset_type: string }) => b.asset_type === 'native') - const otherAssets = accountData.balances?.filter((b: { asset_type: string }) => b.asset_type !== 'native') || [] - const signers = accountData.signers || [] - const flags = accountData.flags || {} - const thresholds = accountData.thresholds || {} - const createdValue = createdAtLoading ? 'Loading...' : createdAt ? format(new Date(createdAt), 'MMM d, yyyy') : 'Unknown' + const xlm = accountData.balances?.find((b: { asset_type: string }) => b.asset_type === 'native'); + const otherAssets = + accountData.balances?.filter((b: { asset_type: string }) => b.asset_type !== 'native') || []; + const signers = accountData.signers || []; + const flags = accountData.flags || {}; + const thresholds = accountData.thresholds || {}; + const createdValue = createdAtLoading + ? 'Loading...' + : createdAt + ? format(new Date(createdAt), 'MMM d, yyyy') + : 'Unknown'; const { getEstimate } = useAssetUsdEstimates({ balances: accountData?.balances || [], connectedAddress, network, refreshKey: accountData, - }) - const xlmEstimate = xlm ? getEstimate(xlm) : null + }); + const xlmEstimate = xlm ? getEstimate(xlm) : null; return (
-
Account Detail
+
+
+ Account Detail +
+ +
-
-
Identity
- - + {offline && accountData && ( +
+ + {dataSource === 'cache-stale' ? '⚠' : 'ℹ'} + +
+
+ {dataSource === 'cache-stale' ? 'Using stale cached data' : 'Offline read-only mode'} +
+
+ {dataSource === 'cache-stale' + ? 'Cached account data is older than 5 minutes. All write actions are disabled. Reconnect to refresh.' + : 'Displaying last-known cached account data. Write actions (faucet, contract invocation, transaction submission) are blocked until reconnected.'} +
+ {accountCachedAt && ( +
+ Last cached: {format(new Date(accountCachedAt), 'MMM d, yyyy HH:mm:ss')} +
+ )} +
+
+ )} + +
+
+ Identity +
+ + {reserves && ( -
-
+
+
Reserve Breakdown
@@ -172,30 +310,62 @@ export default function Account() { -
+
- + Total Locked - + {formatXLM(reserves.totalReserves)} XLM
-
+
- + Available Spendable - + {formatXLM(reserves.availableBalance)} XLM
@@ -219,15 +389,32 @@ export default function Account() {
)} -
-
+
+
Asset Balances
{otherAssets.length === 0 ? ( -
No non-native assets
+
+ No non-native assets +
) : ( otherAssets.map((asset: Horizon.BalanceLine, index: number) => { - const estimate = getEstimate(asset) + const estimate = getEstimate(asset); return (
- + {shortAddress((asset as Horizon.BalanceLineAsset).asset_issuer)} )}
-
- +
+ {formatXLM(asset.balance)} {estimate && ( - + Est. {formatEstimatedUsd(estimate.usd)} )}
- ) + ); }) )}
-
-
Thresholds
+
+
+ Thresholds +
-
-
Flags
+
+
+ Flags +
{Object.entries(flags).map(([key, val]) => ( -
- +
+ {key.replace(/_/g, ' ')} - + {val ? 'TRUE' : 'FALSE'}
@@ -304,15 +571,36 @@ export default function Account() {
{signers.length > 0 && ( -
-
+
+
Signers ({signers.length})
{signers.map((s: Horizon.AccountSigner, i: number) => ( -
+
{shortAddress(s.key)} - weight: {s.weight} + + weight: {s.weight} +
))}
)} -
-
+
+
Open Offers
{offersLoading ? ( -
Loading offers...
+
+ Loading offers... +
) : offersError ? ( -
Error: {offersError}
+
+ Error: {offersError} +
) : offers.length === 0 ? ( -
No open offers
+
+ No open offers +
) : (
{offers.map((offer, i) => ( -
- -
+
-
Claimable Balances
-
View and simulate claiming pending balances
+
+ Claimable Balances +
+
+ View and simulate claiming pending balances +
- ) + ); } diff --git a/src/components/dashboard/Builder.tsx b/src/components/dashboard/Builder.tsx index bf19c504..e17c921c 100644 --- a/src/components/dashboard/Builder.tsx +++ b/src/components/dashboard/Builder.tsx @@ -392,24 +392,25 @@ export default function Builder() {
diff --git a/src/hooks/useCachedData.js b/src/hooks/useCachedData.js index 1f825205..36c5bad5 100644 --- a/src/hooks/useCachedData.js +++ b/src/hooks/useCachedData.js @@ -12,13 +12,14 @@ * useCacheStats — live cache statistics for a debug panel */ -import { useState, useEffect, useCallback, useRef, useSyncExternalStore } from 'react'; -import cache, { TTL, isOffline } from '../lib/cache.js'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import cache, { TTL, isOffline as cacheIsOffline } from '../lib/cache.js'; import { getCachedApiResponse, setCachedApiResponse, getOfflineQueue, } from '../lib/storage.js'; +import { evaluateDataSource, subscribeToConnectivity, isOnline } from '../lib/offlineReadOnly'; // ─── Internal helpers ───────────────────────────────────────────────────────── @@ -68,16 +69,27 @@ export function useCachedData(cacheKey, fetchFn, opts = {}) { onError = noop, } = opts; - const [data, setData] = useState(() => (cacheKey ? cache.get(cacheKey) : null)); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [stale, setStale] = useState(false); - const [source, setSource] = useState('init'); + const [data, setData] = useState(() => (cacheKey ? cache.get(cacheKey) : null)); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [stale, setStale] = useState(false); + const [source, setSource] = useState('init'); + const [cachedAt, setCachedAt] = useState(() => { + if (!cacheKey) return null; + const meta = cache._meta && cache._meta.get(cacheKey); + return meta?.createdAt ?? null; + }); + const [online, setOnline] = useState(() => isOnline()); + const [swCacheHit, setSwCacheHit] = useState(false); const mountedRef = useRef(true); const fetchFnRef = useRef(fetchFn); fetchFnRef.current = fetchFn; + useEffect(() => { + return subscribeToConnectivity(setOnline); + }, []); + const doFetch = useCallback(async (skipCache = false) => { if (!cacheKey || !enabled) return; @@ -124,6 +136,8 @@ export function useCachedData(cacheKey, fetchFn, opts = {}) { setData(fresh); setStale(false); setSource('network'); + setCachedAt(Date.now()); + setSwCacheHit(false); setLoading(false); setError(null); onSuccess(fresh); @@ -159,6 +173,7 @@ export function useCachedData(cacheKey, fetchFn, opts = {}) { setData(value); setStale(false); setSource('subscription'); + setCachedAt(Date.now()); } }); }, [cacheKey]); @@ -169,7 +184,31 @@ export function useCachedData(cacheKey, fetchFn, opts = {}) { doFetch(true); }, [cacheKey, doFetch]); - return { data, loading, error, stale, source, refetch, invalidate }; + const dataSourceInfo = evaluateDataSource( + data !== null && data !== undefined, + { + cachedAt, + ttlMs: ttl, + fromServiceWorkerCache: swCacheHit, + }, + ); + + return { + data, + loading, + error, + stale, + source, + refetch, + invalidate, + online, + offline: !online, + dataSource: dataSourceInfo.source, + dataSourceLabel: dataSourceInfo.label, + isLiveData: dataSourceInfo.isLive, + cachedAt: dataSourceInfo.cachedAt, + dataAgeMs: dataSourceInfo.ageMs, + }; } // ─── useCachedAccount ───────────────────────────────────────────────────────── @@ -313,23 +352,17 @@ export function useCachedItem(cacheKeyPrefix, id, fetchFn, opts = {}) { // ─── useOfflineStatus ───────────────────────────────────────────────────────── /** - * Returns { online, queueLength } and updates reactively. + * Returns { online, offline, queueLength, writeSafe } and updates reactively. + * + * writeSafe is false when offline and unsafe writes are attempted (i.e. the + * UI should disable submit buttons). */ export function useOfflineStatus() { - const [online, setOnline] = useState(() => - typeof navigator !== 'undefined' ? navigator.onLine : true - ); + const [online, setOnline] = useState(() => isOnline()); const [queueLength, setQueueLength] = useState(0); useEffect(() => { - const onOnline = () => setOnline(true); - const onOffline = () => setOnline(false); - window.addEventListener('online', onOnline); - window.addEventListener('offline', onOffline); - return () => { - window.removeEventListener('online', onOnline); - window.removeEventListener('offline', onOffline); - }; + return subscribeToConnectivity(setOnline); }, []); // Poll queue length every 5 s @@ -340,7 +373,12 @@ export function useOfflineStatus() { return () => clearInterval(id); }, []); - return { online, queueLength }; + return { + online, + offline: !online, + queueLength, + writeSafe: online, + }; } // ─── useCacheStats ──────────────────────────────────────────────────────────── diff --git a/src/hooks/useSWR.ts b/src/hooks/useSWR.ts index 301a7e64..35a0e56c 100644 --- a/src/hooks/useSWR.ts +++ b/src/hooks/useSWR.ts @@ -1,15 +1,17 @@ -import { useCallback, useEffect, useState } from 'react' -import useSWR, { mutate as globalMutate, type SWRConfiguration, type SWRResponse } from 'swr' +import { useCallback, useEffect, useState, useRef } from 'react'; +import useSWR, { mutate as globalMutate, type SWRConfiguration, type SWRResponse } from 'swr'; +import { CacheManager, TTL, stellarCacheManager } from '../lib/cacheManager'; import { - CacheManager, - TTL, - stellarCacheManager, -} from '../lib/cacheManager' + evaluateDataSource, + subscribeToConnectivity, + isOnline, + type DataSource, +} from '../lib/offlineReadOnly'; export interface UseStellarSWROptions extends SWRConfiguration { - cacheManager?: CacheManager - ttl?: number - tags?: string[] + cacheManager?: CacheManager; + ttl?: number; + tags?: string[]; } const defaultSWRConfig: Partial = { @@ -18,57 +20,93 @@ const defaultSWRConfig: Partial = { shouldRetryOnError: false, revalidateIfStale: true, revalidateOnMount: true, -} +}; export function useStellarSWR( key: string | null, fetcher: () => Promise, - options: UseStellarSWROptions = {}, -): SWRResponse { + options: UseStellarSWROptions = {} +): SWRResponse & { + online: boolean; + offline: boolean; + dataSource: DataSource; + dataSourceLabel: string; + isLiveData: boolean; + cachedAt: number | null; + dataAgeMs: number; +} { const { cacheManager = stellarCacheManager, ttl = TTL.ACCOUNT, tags = [], ...swrOptions - } = options + } = options; + + const [online, setOnline] = useState(() => isOnline()); + const cachedAtRef = useRef(null); + const swCacheHitRef = useRef(false); + + useEffect(() => { + return subscribeToConnectivity(setOnline); + }, []); const wrappedFetcher = useCallback(async () => { if (!key) { - throw new Error('useStellarSWR requires a cache key') + throw new Error('useStellarSWR requires a cache key'); } - return cacheManager.swr(key, fetcher, { ttl, tags }) - }, [key, fetcher, cacheManager, ttl, tags.join(',')]) + const cached = cacheManager.get(key); + if (cached !== null && cachedAtRef.current === null) { + // In-memory hit existed before fetch — note that we're serving cache initially + } + const result = await cacheManager.swr(key, fetcher, { ttl, tags }); + cachedAtRef.current = Date.now(); + swCacheHitRef.current = false; + return result; + }, [key, fetcher, cacheManager, ttl, tags.join(',')]); - return useSWR(key, wrappedFetcher, { + const result = useSWR(key, wrappedFetcher, { ...defaultSWRConfig, ...swrOptions, - }) + }); + + const dsInfo = evaluateDataSource(result.data !== null && result.data !== undefined, { + cachedAt: cachedAtRef.current, + ttlMs: ttl, + fromServiceWorkerCache: swCacheHitRef.current, + }); + + return { + ...result, + online, + offline: !online, + dataSource: dsInfo.source, + dataSourceLabel: dsInfo.label, + isLiveData: dsInfo.isLive, + cachedAt: dsInfo.cachedAt, + dataAgeMs: dsInfo.ageMs, + }; } export function useAccount( publicKey: string | null, network: string, - fetcher: (publicKey: string, network: string) => Promise, + fetcher: (publicKey: string, network: string) => Promise ): SWRResponse { - const key = publicKey ? `account:${publicKey}:${network}` : null - return useStellarSWR( - key, - () => fetcher(publicKey!, network), - { - ttl: 300_000, - tags: publicKey ? ['account', `account:${publicKey}`] : ['account'], - dedupingInterval: 30_000, - revalidateOnFocus: true, - revalidateOnReconnect: true, - keepPreviousData: true, - }, - ) + const key = publicKey ? `account:${publicKey}:${network}` : null; + return useStellarSWR(key, () => fetcher(publicKey!, network), { + ttl: 300_000, + tags: publicKey ? ['account', `account:${publicKey}`] : ['account'], + dedupingInterval: 30_000, + revalidateOnFocus: true, + revalidateOnReconnect: true, + keepPreviousData: true, + }); } export interface PaginatedResponse { - records: T[] - nextCursor?: string | null - hasMore?: boolean + records: T[]; + nextCursor?: string | null; + hasMore?: boolean; } export function useTransactions( @@ -78,15 +116,17 @@ export function useTransactions( publicKey: string, network: string, limit: number, - cursor: string | null, + cursor: string | null ) => Promise>, - limit = 20, + limit = 20 ) { - const [cursor, setCursor] = useState(null) - const [allRecords, setAllRecords] = useState([]) - const [hasMore, setHasMore] = useState(true) + const [cursor, setCursor] = useState(null); + const [allRecords, setAllRecords] = useState([]); + const [hasMore, setHasMore] = useState(true); - const key = publicKey ? `transactions:${publicKey}:${network}:${limit}:${cursor ?? 'null'}` : null + const key = publicKey + ? `transactions:${publicKey}:${network}:${limit}:${cursor ?? 'null'}` + : null; const swr = useStellarSWR>( key, @@ -97,30 +137,30 @@ export function useTransactions( dedupingInterval: 15_000, revalidateOnFocus: false, keepPreviousData: true, - }, - ) + } + ); useEffect(() => { - if (!swr.data) return - const records = swr.data.records ?? [] + if (!swr.data) return; + const records = swr.data.records ?? []; setAllRecords((prev) => { - const seen = new Set(prev.map((item: any) => item?.id ?? item)) - return [...prev, ...records.filter((item) => !seen.has((item as any)?.id ?? item))] - }) - setHasMore(swr.data.hasMore ?? Boolean(swr.data.nextCursor)) - }, [swr.data]) + const seen = new Set(prev.map((item: any) => item?.id ?? item)); + return [...prev, ...records.filter((item) => !seen.has((item as any)?.id ?? item))]; + }); + setHasMore(swr.data.hasMore ?? Boolean(swr.data.nextCursor)); + }, [swr.data]); useEffect(() => { - setAllRecords([]) - setHasMore(true) - setCursor(null) - }, [publicKey, network, limit]) + setAllRecords([]); + setHasMore(true); + setCursor(null); + }, [publicKey, network, limit]); const loadMore = useCallback(() => { if (!swr.isValidating && hasMore && swr.data?.nextCursor) { - setCursor(swr.data.nextCursor) + setCursor(swr.data.nextCursor); } - }, [hasMore, swr.data, swr.isValidating]) + }, [hasMore, swr.data, swr.isValidating]); return { ...swr, @@ -129,109 +169,117 @@ export function useTransactions( hasMore, loadMore, reset: () => { - setAllRecords([]) - setCursor(null) - setHasMore(true) - swr.mutate() + setAllRecords([]); + setCursor(null); + setHasMore(true); + swr.mutate(); }, - } + }; } export function useNetworkStats( network: string | null, fetcher: (network: string) => Promise, - refreshInterval = 30_000, + refreshInterval = 30_000 ) { - const key = network ? `network-stats:${network}` : null - return useStellarSWR( - key, - () => fetcher(network!), - { - ttl: 30_000, - tags: ['network-stats'], - refreshInterval, - dedupingInterval: 15_000, - revalidateOnFocus: true, - revalidateOnReconnect: true, - }, - ) + const key = network ? `network-stats:${network}` : null; + return useStellarSWR(key, () => fetcher(network!), { + ttl: 30_000, + tags: ['network-stats'], + refreshInterval, + dedupingInterval: 15_000, + revalidateOnFocus: true, + revalidateOnReconnect: true, + }); } -export type OptimisticUpdater = - | Data - | ((previousData: Data | null) => Data) +export type OptimisticUpdater = Data | ((previousData: Data | null) => Data); export interface UseOptimisticMutationOptions { - ttl?: number - tags?: string[] - optimisticData?: OptimisticUpdater - onSuccess?: (value: Data) => void - onError?: (error: unknown, rollback: () => void) => void - onSettled?: () => void + ttl?: number; + tags?: string[]; + optimisticData?: OptimisticUpdater; + onSuccess?: (value: Data) => void; + onError?: (error: unknown, rollback: () => void) => void; + onSettled?: () => void; } export function useOptimisticMutation( cacheKey: string | null, mutationFn: () => Promise, - options: UseOptimisticMutationOptions = {}, + options: UseOptimisticMutationOptions = {} ) { - const { - ttl = TTL.ACCOUNT, - tags = [], - optimisticData, - onSuccess, - onError, - onSettled, - } = options - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) + const { ttl = TTL.ACCOUNT, tags = [], optimisticData, onSuccess, onError, onSettled } = options; + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [online, setOnline] = useState(() => isOnline()); + + useEffect(() => { + return subscribeToConnectivity(setOnline); + }, []); const mutate = useCallback(async () => { if (!cacheKey) { - throw new Error('useOptimisticMutation requires a cache key') + throw new Error('useOptimisticMutation requires a cache key'); + } + + if (!online) { + const { OfflineWriteError } = await import('../lib/offlineReadOnly'); + throw new OfflineWriteError('optimistic mutation'); } - const previous = stellarCacheManager.get(cacheKey) + const previous = stellarCacheManager.get(cacheKey); const rollback = async () => { if (previous !== null) { - await stellarCacheManager.set(cacheKey, previous, ttl, tags) + await stellarCacheManager.set(cacheKey, previous, ttl, tags); } else { - await stellarCacheManager.delete(cacheKey) + await stellarCacheManager.delete(cacheKey); } - void globalMutate(cacheKey, previous as any, false) - } + void globalMutate(cacheKey, previous as any, false); + }; - setLoading(true) - setError(null) + setLoading(true); + setError(null); try { - let optimisticValue: Data | null = null + let optimisticValue: Data | null = null; if (optimisticData !== undefined) { - optimisticValue = typeof optimisticData === 'function' - ? (optimisticData as (prev: Data | null) => Data)(previous as Data | null) - : optimisticData + optimisticValue = + typeof optimisticData === 'function' + ? (optimisticData as (prev: Data | null) => Data)(previous as Data | null) + : optimisticData; } if (optimisticValue !== null && optimisticValue !== undefined) { - await stellarCacheManager.set(cacheKey, optimisticValue, ttl, tags) - void globalMutate(cacheKey, optimisticValue, false) + await stellarCacheManager.set(cacheKey, optimisticValue, ttl, tags); + void globalMutate(cacheKey, optimisticValue, false); } - const result = await mutationFn() - await stellarCacheManager.set(cacheKey, result, ttl, tags) - void globalMutate(cacheKey, result, false) - onSuccess?.(result) - return result + const result = await mutationFn(); + await stellarCacheManager.set(cacheKey, result, ttl, tags); + void globalMutate(cacheKey, result, false); + onSuccess?.(result); + return result; } catch (err) { - await rollback() - setError(err) - onError?.(err, rollback) - throw err + await rollback(); + setError(err); + onError?.(err, rollback); + throw err; } finally { - setLoading(false) - onSettled?.() + setLoading(false); + onSettled?.(); } - }, [cacheKey, mutationFn, ttl, tags.join(','), optimisticData, onError, onSettled, onSuccess]) + }, [ + cacheKey, + mutationFn, + ttl, + tags.join(','), + optimisticData, + onError, + onSettled, + onSuccess, + online, + ]); - return { mutate, loading, error } + return { mutate, loading, error, online, offline: !online }; } diff --git a/src/i18n/en.json b/src/i18n/en.json index 72b83597..da2dabc3 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -26,7 +26,13 @@ "ok": "OK", "retry": "Retry", "noData": "No data available", - "notAvailable": "N/A" + "notAvailable": "N/A", + "offline": "Offline", + "online": "Online", + "cachedData": "Cached data", + "liveData": "Live data", + "staleData": "Stale cached data", + "writesDisabled": "Writes disabled — reconnect to submit" }, "nav": { "overview": "Overview", @@ -329,5 +335,24 @@ "description": "These technical terms are never machine-translated — they are always preserved verbatim to ensure accuracy across all languages.", "more": "more terms protected" } + }, + "offline": { + "title": "Offline mode", + "banner": "You are offline. Displaying last-known cached data.", + "bannerStale": "You are offline. Cached data may be stale — reconnect to refresh.", + "bannerAccount": "Account data is cached. Write actions are disabled until reconnected.", + "writesBlocked": "Network write unavailable", + "writesBlockedDetail": "This action requires a live network connection. Please reconnect and try again.", + "cachedBadge": "CACHED", + "staleBadge": "STALE", + "liveBadge": "LIVE", + "dataSourceLive": "Live network data", + "dataSourceCache": "Cached data (last known good)", + "dataSourceCacheStale": "Stale cached data — older than TTL", + "faucetDisabled": "Faucet unavailable offline", + "builderDisabled": "Simulation unavailable offline", + "builderXdrStillWorks": "XDR export still works — it is a local-only operation.", + "queueHint": "Queued operations will replay when connectivity returns.", + "lastCached": "Last cached" } } \ No newline at end of file diff --git a/src/lib/offlineReadOnly.ts b/src/lib/offlineReadOnly.ts new file mode 100644 index 00000000..fd933059 --- /dev/null +++ b/src/lib/offlineReadOnly.ts @@ -0,0 +1,287 @@ +/** + * Offline Read-Only Behavior Module + * + * Defines the offline read-only contract for cached account data: + * - Distinguish cached (stale/offline) data from live (network-fresh) data + * - Block unsafe write operations (mutations) when the network is unavailable + * - Provide metadata (`DataSource`) that UI components consume for badges/banners + * - Validate input and degrade gracefully in unsupported environments + * + * Architecture + * ------------ + * NetworkStatus -> singleton, online/offline + heartbeat + * DataSource -> discriminator: 'live' | 'cache' | 'stale' | 'unknown' + * WriteSafetyGate -> predicate + typed error for write ops + * AccountDataSource -> cache freshness evaluator (TTL + network context) + */ + +export type DataSource = 'live' | 'cache' | 'cache-stale' | 'unknown'; + +export interface DataSourceInfo { + source: DataSource; + cachedAt: number | null; + ageMs: number; + isLive: boolean; + isOffline: boolean; + label: string; +} + +export interface WriteSafetyOptions { + /** If true, the caller is queueing the op for replay — allow it. */ + allowQueued?: boolean; + /** Human-readable label used in error messages. */ + operationLabel?: string; + /** If set, the function is allowed even offline because it's locally-side-effect free (e.g. XDR export). */ + localOnly?: boolean; +} + +export class OfflineWriteError extends Error { + readonly code = 'OFFLINE_WRITE_BLOCKED'; + readonly operation: string; + constructor(operation: string, message?: string) { + super( + message ?? + `Cannot perform "${operation}" while offline. Connect to the network and try again, or queue the operation for later replay.` + ); + this.name = 'OfflineWriteError'; + this.operation = operation; + } +} + +// ─── Network status singleton ──────────────────────────────────────────────── + +type OnlineListener = (online: boolean) => void; + +class NetworkStatus { + private online: boolean; + private listeners = new Set(); + private heartbeatTimer: ReturnType | null = null; + private lastVerifiedAt = 0; + private readonly VERIFY_INTERVAL_MS = 15_000; + + constructor() { + this.online = typeof navigator !== 'undefined' ? navigator.onLine : true; + if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('online', () => this.setOnline(true)); + window.addEventListener('offline', () => this.setOnline(false)); + } + this.startHeartbeat(); + } + + private setOnline(next: boolean): void { + const prev = this.online; + this.online = next; + this.lastVerifiedAt = Date.now(); + if (prev !== next) { + this.listeners.forEach((cb) => { + try { + cb(next); + } catch { + /* swallow listener errors */ + } + }); + } + } + + /** + * Lightweight heartbeat — verify online state using a no-op fetch so that + * `navigator.onLine` lying (e.g. captive portal) does not mask a real outage. + */ + private startHeartbeat(): void { + if (typeof window === 'undefined') return; + const tick = async (): Promise => { + if (!this.online) return; + try { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 4_000); + await fetch('/sw.js', { method: 'HEAD', cache: 'no-store', signal: ctrl.signal }); + clearTimeout(t); + this.lastVerifiedAt = Date.now(); + } catch { + // Treat any network-level failure as offline; browser events will recover us. + this.setOnline(false); + } + }; + this.heartbeatTimer = setInterval(tick, this.VERIFY_INTERVAL_MS); + } + + isOnline(): boolean { + return this.online; + } + + isOffline(): boolean { + return !this.online; + } + + /** Epoch ms when we last confirmed connectivity; 0 if never. */ + getLastVerifiedAt(): number { + return this.lastVerifiedAt; + } + + subscribe(listener: OnlineListener): () => void { + this.listeners.add(listener); + try { + listener(this.online); + } catch { + /* ignore */ + } + return () => this.listeners.delete(listener); + } + + destroy(): void { + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + this.listeners.clear(); + } +} + +export const networkStatus = typeof window !== 'undefined' ? new NetworkStatus() : null; + +function safeIsOnline(): boolean { + return networkStatus + ? networkStatus.isOnline() + : typeof navigator !== 'undefined' + ? navigator.onLine + : true; +} + +export function isOnline(): boolean { + return safeIsOnline(); +} + +export function isOffline(): boolean { + return !safeIsOnline(); +} + +export function subscribeToConnectivity(listener: OnlineListener): () => void { + if (!networkStatus) { + listener(true); + return () => {}; + } + return networkStatus.subscribe(listener); +} + +// ─── Data source evaluator ─────────────────────────────────────────────────── + +const DEFAULT_ACCOUNT_TTL_MS = 5 * 60_000; // 5 min — aligned with TTL.ACCOUNT + +export interface CachedDataMeta { + /** Epoch ms when the data was cached/fetched. */ + cachedAt?: number | null; + /** TTL used when the entry was written. */ + ttlMs?: number; + /** True when the fetch that produced this value came from a SW cache hit. */ + fromServiceWorkerCache?: boolean; +} + +/** + * Produce a `DataSourceInfo` record for a given cache entry + current network. + * + * Invalid / incomplete input degrades to `{ source: 'unknown' }` rather than + * throwing — callers should always be able to render. + */ +export function evaluateDataSource( + hasLiveData: boolean, + meta: CachedDataMeta = {} +): DataSourceInfo { + const now = Date.now(); + const cachedAt = typeof meta.cachedAt === 'number' ? meta.cachedAt : null; + const ttlMs = typeof meta.ttlMs === 'number' ? meta.ttlMs : DEFAULT_ACCOUNT_TTL_MS; + const ageMs = cachedAt ? Math.max(0, now - cachedAt) : 0; + + const offline = isOffline(); + + let source: DataSource = 'unknown'; + + if (hasLiveData) { + if (!offline && (!cachedAt || ageMs <= ttlMs) && !meta.fromServiceWorkerCache) { + source = 'live'; + } else if (offline || meta.fromServiceWorkerCache || ageMs > ttlMs) { + source = ageMs > ttlMs ? 'cache-stale' : 'cache'; + } else { + source = 'cache'; + } + } + + return { + source, + cachedAt, + ageMs, + isLive: source === 'live', + isOffline: offline, + label: dataSourceLabel(source), + }; +} + +export function dataSourceLabel(source: DataSource): string { + switch (source) { + case 'live': + return 'Live data'; + case 'cache': + return 'Cached data'; + case 'cache-stale': + return 'Stale cached data'; + case 'unknown': + default: + return 'Data state unknown'; + } +} + +// ─── Write safety gate ─────────────────────────────────────────────────────── + +/** + * Central guard for any operation that mutates on-network state. + * + * Rules: + * - Always allowed if `localOnly: true` (pure client work — e.g. XDR export). + * - Always allowed when online. + * - Blocked when offline UNLESS `allowQueued: true`, which indicates the + * caller will persist the operation in the offline queue for replay. + * + * Throws `OfflineWriteError` with a structured message when blocked. + * + * Invalid input (non-string label) is normalised rather than thrown — the + * guard should never itself crash callers. + */ +export function assertWriteSafe(operation: unknown, options: WriteSafetyOptions = {}): void { + const { localOnly = false, allowQueued = false } = options; + const label = + typeof operation === 'string' && operation.trim().length > 0 + ? operation.trim() + : 'unspecified write operation'; + + if (localOnly) return; + if (safeIsOnline()) return; + if (allowQueued) return; + + throw new OfflineWriteError(label); +} + +/** + * Non-throwing predicate companion to `assertWriteSafe`. + */ +export function isWriteSafe(operation: unknown, options: WriteSafetyOptions = {}): boolean { + try { + assertWriteSafe(operation, options); + return true; + } catch { + return false; + } +} + +// ─── Helpers for cached-vs-live distinction in hooks ────────────────────────── + +/** + * Wrap a fetcher so that, when offline, it either throws a descriptive error + * (no cached fallback) or lets the caller return cached content instead. + * + * Designed to be composed with useCachedData / useStellarSWR — they handle + * the cache reads; this guard only protects the network write surface. + */ +export async function withOfflineGuard( + operation: string, + fetcher: () => Promise, + options: WriteSafetyOptions = {} +): Promise { + assertWriteSafe(operation, options); + return fetcher(); +} diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index 9ec39cb8..c3fdc5dd 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -169,10 +169,10 @@ const CUSTOM_NETWORK_HEADERS_KEY = 'stellar-custom-network-headers'; function endpointShape(url: string): string { try { - const parsed = new URL(url) - return parsed.pathname || '/' + const parsed = new URL(url); + return parsed.pathname || '/'; } catch { - return 'unknown' + return 'unknown'; } } @@ -352,7 +352,7 @@ export function getServer(network: NetworkName = 'testnet'): StellarSdk.Horizon. ); } -export const ee = getServer +export const ee = getServer; export function getSorobanServer(network: NetworkName = 'testnet'): StellarSdk.SorobanRpc.Server { const config = NETWORKS[network]; @@ -582,24 +582,27 @@ export async function fetchAccountOffers( export async function fetchTransactionDetails( hash: string, network: NetworkName = 'testnet' -): Promise<{ transaction: StellarSdk.Horizon.ServerApi.TransactionRecord, operations: StellarSdk.Horizon.ServerApi.OperationRecord[] }> { - const cacheKey = `transaction-details:${hash}:${network}` - const cached = stellarCache.get(cacheKey) - if (cached) return cached +): Promise<{ + transaction: StellarSdk.Horizon.ServerApi.TransactionRecord; + operations: StellarSdk.Horizon.ServerApi.OperationRecord[]; +}> { + const cacheKey = `transaction-details:${hash}:${network}`; + const cached = stellarCache.get(cacheKey); + if (cached) return cached; - const server = getServer(network) + const server = getServer(network); const [transaction, opsResponse] = await Promise.all([ server.transactions().transaction(hash).call(), - server.operations().forTransaction(hash).call() - ]) + server.operations().forTransaction(hash).call(), + ]); const result = { transaction, - operations: opsResponse.records || [] - } - - stellarCache.set(cacheKey, result, TTL.TRANSACTIONS, ['transactions', hash]) - return result + operations: opsResponse.records || [], + }; + + stellarCache.set(cacheKey, result, TTL.TRANSACTIONS, ['transactions', hash]); + return result; } // ─── Operation labels ─────────────────────────────────────────────────────────── @@ -892,6 +895,12 @@ export async function fetchAssetPrice( // ─── Faucet ─────────────────────────────────────────────────────────────────── export async function fundTestnetAccount(publicKey: string): Promise { + // Guard: faucet requires network writes and cannot be queued — block offline. + const { isWriteSafe } = await import('./offlineReadOnly'); + if (!isWriteSafe('faucet funding')) { + const { OfflineWriteError } = await import('./offlineReadOnly'); + throw new OfflineWriteError('faucet funding'); + } const res = await fetch(`${NETWORKS.testnet.faucetUrl}?addr=${publicKey}`); if (!res.ok) throw new Error('Faucet request failed'); return res.json(); @@ -925,14 +934,14 @@ export async function fetchContractData( const server = getSorobanServer(network); let scValKey; - if (typeof key === "string") { + if (typeof key === 'string') { try { // Try to parse from JSON first const parsed = JSON.parse(key); scValKey = StellarSdk.nativeToScVal(parsed); } catch { // If JSON fails, treat as string - scValKey = StellarSdk.nativeToScVal(key, { type: "string" }); + scValKey = StellarSdk.nativeToScVal(key, { type: 'string' }); } } else { scValKey = key; @@ -943,7 +952,7 @@ export async function fetchContractData( return { key: StellarSdk.scValToNative(result.key), value: StellarSdk.scValToNative(result.val), - xdr: result.xdr + xdr: result.xdr, }; } catch (e) { throw new Error(`Failed to fetch contract data: ${(e as Error).message}`); @@ -1154,6 +1163,13 @@ export async function invokeContract(params: InvokeContractParams): Promise