-
-
Offer ID: {offer.id}
+
+
+
+ Offer ID: {offer.id}
+
-
Selling: {formatXLM(offer.amount)} {formatAsset(offer.selling.asset_type, offer.selling.asset_code)}
-
Buying: {formatAsset(offer.buying.asset_type, offer.buying.asset_code)}
+
+ Selling:{' '}
+ {formatXLM(offer.amount)}{' '}
+ {formatAsset(offer.selling.asset_type, offer.selling.asset_code)}
+
+
+ Buying:{' '}
+ {formatAsset(offer.buying.asset_type, offer.buying.asset_code)}
+
-
Price: {offer.price}
-
Ratio: {offer.price_r.n}/{offer.price_r.d}
+
+ Price: {offer.price}
+
+
+ Ratio: {offer.price_r.n}/
+ {offer.price_r.d}
+
))}
@@ -376,10 +712,31 @@ export default function Account() {
)}
-
+
-
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