diff --git a/apps/abcpay-web/public/sw.js b/apps/abcpay-web/public/sw.js
index 076670d..bca9a59 100644
--- a/apps/abcpay-web/public/sw.js
+++ b/apps/abcpay-web/public/sw.js
@@ -1,5 +1,7 @@
/* AbcPay Wallet Service Worker — PWA installability + Push & System Notifications */
const CACHE_NAME = 'abcpay-shell-v2';
+const TOKEN_ICONS_CACHE = 'abcpay-token-icons-v1';
+const TOKEN_ICONS_ORIGIN = 'https://icons.etokens.cash';
const PRECACHE_URLS = [
'/manifest.webmanifest',
'/abcpay-logo.svg',
@@ -28,7 +30,11 @@ self.addEventListener('activate', event => {
caches
.keys()
.then(keys =>
- Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)))
+ Promise.all(
+ keys
+ .filter(key => key !== CACHE_NAME && key !== TOKEN_ICONS_CACHE)
+ .map(key => caches.delete(key))
+ )
)
.then(() => self.clients.claim())
);
@@ -39,6 +45,27 @@ self.addEventListener('fetch', event => {
if (req.method !== 'GET') return;
const url = new URL(req.url);
+
+ // Cashtab/eTokens icons — cache-first so token images survive reloads offline.
+ if (url.origin === TOKEN_ICONS_ORIGIN && /\.png$/i.test(url.pathname)) {
+ event.respondWith(
+ caches.open(TOKEN_ICONS_CACHE).then(async cache => {
+ const cached = await cache.match(req);
+ if (cached) return cached;
+ const res = await fetch(req);
+ if (res.ok) {
+ try {
+ await cache.put(req, res.clone());
+ } catch {
+ // ignore quota failures
+ }
+ }
+ return res;
+ })
+ );
+ return;
+ }
+
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith('/cws/api') || url.pathname.startsWith('/bws/api')) return;
diff --git a/apps/abcpay-web/src/App.tsx b/apps/abcpay-web/src/App.tsx
index 797547e..f893834 100644
--- a/apps/abcpay-web/src/App.tsx
+++ b/apps/abcpay-web/src/App.tsx
@@ -1,4 +1,5 @@
import { Routes, Route } from 'react-router-dom';
+import { PullToReload } from './components/PullToReload';
import { TabBar } from './components/ui';
import { HomePage } from './pages/HomePage';
import { ScanPage } from './pages/ScanPage';
@@ -15,6 +16,7 @@ import { RestorePage } from './pages/RestorePage';
export function App() {
return (
+
} />
} />
diff --git a/apps/abcpay-web/src/components/PullToReload.tsx b/apps/abcpay-web/src/components/PullToReload.tsx
new file mode 100644
index 0000000..92e89cd
--- /dev/null
+++ b/apps/abcpay-web/src/components/PullToReload.tsx
@@ -0,0 +1,252 @@
+import { useEffect, useRef, useState, type CSSProperties } from 'react';
+import { useWallets } from '../context/WalletContext';
+
+const SOFT_THRESHOLD_PX = 64;
+const HARD_THRESHOLD_PX = 118;
+const PULL_MAX_PX = 140;
+const PULL_RESISTANCE = 0.42;
+const UPDATE_CHECK_MS = 2000;
+
+type PtrPhase = 'idle' | 'pulling' | 'checking' | 'refreshing' | 'uptodate' | 'reloading';
+
+function pageScrollTop(): number {
+ return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
+}
+
+function sleep(ms: number): Promise {
+ return new Promise(resolve => window.setTimeout(resolve, ms));
+}
+
+function currentBundleName(): string | null {
+ for (const script of document.scripts) {
+ const match = script.src.match(/\/assets\/(index-[A-Za-z0-9_-]+\.js)/);
+ if (match) return match[1];
+ }
+ return null;
+}
+
+/** Fast no-cache HTML peek — true when the deployed JS bundle hash changed. */
+async function hasNewWebBuild(): Promise {
+ const current = currentBundleName();
+ if (!current) return false;
+ try {
+ const res = await fetch(`/?_ptr=${Date.now()}`, {
+ cache: 'no-store',
+ headers: { Accept: 'text/html' }
+ });
+ if (!res.ok) return false;
+ const html = await res.text();
+ const match = html.match(/\/assets\/(index-[A-Za-z0-9_-]+\.js)/);
+ if (!match) return false;
+ return match[1] !== current;
+ } catch {
+ return false;
+ }
+}
+
+async function requestServiceWorkerUpdate(): Promise {
+ try {
+ const reg = await navigator.serviceWorker?.getRegistration();
+ await reg?.update();
+ } catch {
+ // ignore
+ }
+}
+
+function forceReload(): void {
+ // Kick SW update in the background; don't wait — user asked for a full reload.
+ void requestServiceWorkerUpdate();
+ window.location.reload();
+}
+
+function ptrLabel(phase: PtrPhase, pullPx: number): string {
+ if (phase === 'checking') return 'Checking for updates…';
+ if (phase === 'refreshing') return 'Refreshing balances…';
+ if (phase === 'uptodate') return 'Up to date';
+ if (phase === 'reloading') return 'Reloading…';
+ if (pullPx >= HARD_THRESHOLD_PX) return 'Release to force reload';
+ if (pullPx >= SOFT_THRESHOLD_PX) return 'Release to check for updates';
+ return 'Pull to check';
+}
+
+/**
+ * Two-stage pull-to-reload for iOS standalone PWAs (no native Safari PTR):
+ * - Short pull: peek for a new web build; reload only if one exists, else soft-refresh balances
+ * - Long pull: force a full page reload immediately
+ */
+export function PullToReload() {
+ const { refreshBalances } = useWallets();
+ const [pullPx, setPullPx] = useState(0);
+ const [phase, setPhase] = useState('idle');
+ const startYRef = useRef(null);
+ const pullPxRef = useRef(0);
+ const trackingRef = useRef(false);
+ const phaseRef = useRef('idle');
+ const refreshBalancesRef = useRef(refreshBalances);
+ refreshBalancesRef.current = refreshBalances;
+
+ useEffect(() => {
+ pullPxRef.current = pullPx;
+ }, [pullPx]);
+
+ useEffect(() => {
+ phaseRef.current = phase;
+ }, [phase]);
+
+ useEffect(() => {
+ const busy = () => {
+ const p = phaseRef.current;
+ return p === 'checking' || p === 'refreshing' || p === 'uptodate' || p === 'reloading';
+ };
+
+ const resetPull = () => {
+ trackingRef.current = false;
+ startYRef.current = null;
+ pullPxRef.current = 0;
+ setPullPx(0);
+ };
+
+ const runSoftCheck = async () => {
+ setPhase('checking');
+ setPullPx(SOFT_THRESHOLD_PX);
+ const hasUpdate = await Promise.race([
+ hasNewWebBuild(),
+ sleep(UPDATE_CHECK_MS).then(() => false)
+ ]);
+ if (hasUpdate) {
+ setPhase('reloading');
+ await requestServiceWorkerUpdate();
+ window.location.reload();
+ return;
+ }
+ setPhase('refreshing');
+ try {
+ await refreshBalancesRef.current();
+ } catch {
+ // Balance refresh is best-effort.
+ }
+ setPhase('uptodate');
+ await sleep(700);
+ setPhase('idle');
+ resetPull();
+ };
+
+ const onTouchStart = (event: TouchEvent) => {
+ if (busy()) return;
+ if (event.touches.length !== 1) return;
+ if (pageScrollTop() > 1) {
+ resetPull();
+ return;
+ }
+ startYRef.current = event.touches[0].clientY;
+ trackingRef.current = false;
+ setPhase('pulling');
+ };
+
+ const onTouchMove = (event: TouchEvent) => {
+ if (busy()) return;
+ const startY = startYRef.current;
+ if (startY == null) return;
+
+ if (pageScrollTop() > 1) {
+ resetPull();
+ setPhase('idle');
+ return;
+ }
+
+ const dy = event.touches[0].clientY - startY;
+ if (dy <= 0) {
+ if (trackingRef.current) {
+ resetPull();
+ setPhase('idle');
+ }
+ return;
+ }
+
+ const dist = Math.min(PULL_MAX_PX, dy * PULL_RESISTANCE);
+ trackingRef.current = true;
+ pullPxRef.current = dist;
+ setPullPx(dist);
+ setPhase('pulling');
+
+ if (event.cancelable) event.preventDefault();
+ };
+
+ const onTouchEnd = () => {
+ if (busy()) return;
+ if (!trackingRef.current) {
+ resetPull();
+ setPhase('idle');
+ return;
+ }
+ const dist = pullPxRef.current;
+ if (dist >= HARD_THRESHOLD_PX) {
+ setPhase('reloading');
+ setPullPx(HARD_THRESHOLD_PX);
+ forceReload();
+ return;
+ }
+ if (dist >= SOFT_THRESHOLD_PX) {
+ trackingRef.current = false;
+ startYRef.current = null;
+ void runSoftCheck();
+ return;
+ }
+ resetPull();
+ setPhase('idle');
+ };
+
+ const onTouchCancel = () => {
+ if (!busy()) {
+ resetPull();
+ setPhase('idle');
+ }
+ };
+
+ document.addEventListener('touchstart', onTouchStart, { passive: true });
+ document.addEventListener('touchmove', onTouchMove, { passive: false });
+ document.addEventListener('touchend', onTouchEnd, { passive: true });
+ document.addEventListener('touchcancel', onTouchCancel, { passive: true });
+
+ return () => {
+ document.removeEventListener('touchstart', onTouchStart);
+ document.removeEventListener('touchmove', onTouchMove);
+ document.removeEventListener('touchend', onTouchEnd);
+ document.removeEventListener('touchcancel', onTouchCancel);
+ };
+ }, []);
+
+ const active = phase !== 'idle' || pullPx > 4;
+ const softArmed = pullPx >= SOFT_THRESHOLD_PX || phase === 'checking' || phase === 'refreshing';
+ const hardArmed = pullPx >= HARD_THRESHOLD_PX || phase === 'reloading';
+ const spinning =
+ phase === 'checking' || phase === 'refreshing' || phase === 'reloading';
+ const progress = Math.min(1, pullPx / HARD_THRESHOLD_PX);
+
+ return (
+
+
+
+ {ptrLabel(phase, pullPx)}
+
+
+ );
+}
diff --git a/apps/abcpay-web/src/components/TokenIcon.tsx b/apps/abcpay-web/src/components/TokenIcon.tsx
new file mode 100644
index 0000000..49813d1
--- /dev/null
+++ b/apps/abcpay-web/src/components/TokenIcon.tsx
@@ -0,0 +1,40 @@
+import { useEffect, useState } from 'react';
+import {
+ TOKEN_ICON_FALLBACK,
+ loadTokenIcon,
+ type TokenIconSize
+} from '../lib/token-icons';
+
+export function TokenIcon({
+ tokenId,
+ size = 64,
+ className = 'w-5 h-5'
+}: {
+ tokenId: string;
+ size?: TokenIconSize;
+ className?: string;
+}) {
+ const [src, setSrc] = useState(TOKEN_ICON_FALLBACK);
+
+ useEffect(() => {
+ let cancelled = false;
+ setSrc(TOKEN_ICON_FALLBACK);
+ void loadTokenIcon(tokenId, size).then(url => {
+ if (!cancelled) setSrc(url);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [tokenId, size]);
+
+ return (
+
setSrc(TOKEN_ICON_FALLBACK)}
+ />
+ );
+}
diff --git a/apps/abcpay-web/src/context/WalletContext.tsx b/apps/abcpay-web/src/context/WalletContext.tsx
index 5b4a431..fceeb7b 100644
--- a/apps/abcpay-web/src/context/WalletContext.tsx
+++ b/apps/abcpay-web/src/context/WalletContext.tsx
@@ -17,6 +17,7 @@ import {
import { getCredentials, saveCredentials } from '../lib/credentials-store';
import { getWalletBalance as getBalanceFromStored } from '../lib/bwc';
import { api, type AuthContext } from '../lib/api';
+import { prefetchTokenIcons } from '../lib/token-icons';
/**
* One Account = one derivation (path + coin) under a Wallet (seed).
@@ -451,6 +452,12 @@ export function WalletProvider({ children }: { children: ReactNode }) {
localStorage.setItem(KNOWN_TOKENS_KEY, JSON.stringify(knownTokens));
}, [knownTokens]);
+ // Warm Cashtab/eTokens icons into Cache API whenever we learn new token ids.
+ useEffect(() => {
+ if (knownTokens.length === 0) return;
+ prefetchTokenIcons(knownTokens.map(token => token.tokenId), 64);
+ }, [knownTokens]);
+
useEffect(() => {
localStorage.setItem(TOKENS_BY_ACCOUNT_KEY, JSON.stringify(tokensByAccount));
}, [tokensByAccount]);
diff --git a/apps/abcpay-web/src/index.css b/apps/abcpay-web/src/index.css
index dc2d276..caffa87 100644
--- a/apps/abcpay-web/src/index.css
+++ b/apps/abcpay-web/src/index.css
@@ -15,12 +15,19 @@
box-sizing: border-box;
}
+html {
+ /* Prefer our pull-to-reload over competing overscroll UX in the PWA shell. */
+ overscroll-behavior-y: contain;
+}
+
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--abcpay-bg);
color: var(--abcpay-text);
min-height: 100vh;
+ overscroll-behavior-y: contain;
+ -webkit-overflow-scrolling: touch;
}
#root {
@@ -35,6 +42,81 @@ body {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
+/* Pull-to-reload (iOS standalone PWA has no native Safari PTR). */
+.ptr-indicator {
+ position: fixed;
+ top: calc(env(safe-area-inset-top, 0px) + 0.35rem);
+ left: 50%;
+ z-index: 60;
+ transform: translate3d(-50%, calc(-120% + var(--ptr-pull, 0px)), 0);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.15s ease;
+}
+
+.ptr-indicator.is-visible {
+ opacity: 1;
+}
+
+.ptr-indicator-inner {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ padding: 0.45rem 0.85rem;
+ border-radius: 999px;
+ background: color-mix(in srgb, #1a1a1a 78%, transparent);
+ border: 1px solid color-mix(in srgb, #ffffff 14%, transparent);
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
+ color: var(--abcpay-muted);
+ backdrop-filter: blur(14px);
+ -webkit-backdrop-filter: blur(14px);
+}
+
+.ptr-indicator.is-armed .ptr-indicator-inner {
+ color: #ffffff;
+ border-color: color-mix(in srgb, var(--abcpay-accent) 55%, transparent);
+}
+
+.ptr-indicator.is-force .ptr-indicator-inner {
+ color: #ffffff;
+ border-color: color-mix(in srgb, #f59e0b 65%, transparent);
+}
+
+.ptr-indicator.is-done .ptr-indicator-inner {
+ color: #86efac;
+ border-color: color-mix(in srgb, #22c55e 45%, transparent);
+}
+
+.ptr-spinner {
+ width: 1.05rem;
+ height: 1.05rem;
+ transform: rotate(calc(var(--ptr-progress, 0) * 270deg));
+}
+
+.ptr-indicator.is-reloading .ptr-spinner {
+ animation: ptr-spin 0.75s linear infinite;
+ transform: none;
+}
+
+.ptr-label {
+ font-size: 0.72rem;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ white-space: nowrap;
+}
+
+@keyframes ptr-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ptr-indicator.is-reloading .ptr-spinner {
+ animation: none;
+ }
+}
+
/* Floating liquid-glass tab bar (WLotus-style) */
.glass-nav {
position: fixed;
diff --git a/apps/abcpay-web/src/lib/token-icons.ts b/apps/abcpay-web/src/lib/token-icons.ts
new file mode 100644
index 0000000..2520d9f
--- /dev/null
+++ b/apps/abcpay-web/src/lib/token-icons.ts
@@ -0,0 +1,90 @@
+/** Cashtab / eTokens icon CDN (same host Cashtab uses via VITE_TOKEN_ICONS_URL). */
+export const TOKEN_ICONS_BASE_URL = 'https://icons.etokens.cash';
+export const TOKEN_ICONS_CACHE = 'abcpay-token-icons-v1';
+export const TOKEN_ICON_FALLBACK = '/currencies/eToken.svg';
+
+export type TokenIconSize = 32 | 64 | 128 | 256 | 512;
+
+const memory = new Map();
+const inflight = new Map>();
+
+export function tokenIconUrl(tokenId: string, size: TokenIconSize = 64): string {
+ const id = tokenId.trim().toLowerCase();
+ return `${TOKEN_ICONS_BASE_URL}/${size}/${id}.png`;
+}
+
+function cacheKey(tokenId: string, size: TokenIconSize): string {
+ return `${size}:${tokenId.trim().toLowerCase()}`;
+}
+
+async function responseToObjectUrl(response: Response): Promise {
+ const blob = await response.blob();
+ if (blob.size === 0) throw new Error('empty token icon');
+ // Some browsers omit type on Cache API blobs; still treat PNG bytes as image.
+ if (blob.type && !blob.type.startsWith('image/')) throw new Error('invalid token icon');
+ return URL.createObjectURL(blob);
+}
+
+/**
+ * Resolve a displayable token icon URL, preferring Cache API → network → fallback.
+ * Successful fetches are stored in `abcpay-token-icons-v1` for later / offline use.
+ */
+export async function loadTokenIcon(
+ tokenId: string,
+ size: TokenIconSize = 64
+): Promise {
+ const id = tokenId.trim().toLowerCase();
+ if (!/^[0-9a-f]{64}$/.test(id)) return TOKEN_ICON_FALLBACK;
+
+ const key = cacheKey(id, size);
+ const hit = memory.get(key);
+ if (hit) return hit;
+
+ const pending = inflight.get(key);
+ if (pending) return pending;
+
+ const task = (async () => {
+ const url = tokenIconUrl(id, size);
+ try {
+ if (typeof caches === 'undefined') {
+ memory.set(key, url);
+ return url;
+ }
+ const cache = await caches.open(TOKEN_ICONS_CACHE);
+ const cached = await cache.match(url);
+ if (cached) {
+ const objectUrl = await responseToObjectUrl(cached);
+ memory.set(key, objectUrl);
+ return objectUrl;
+ }
+ const res = await fetch(url, { mode: 'cors', credentials: 'omit' });
+ if (!res.ok) throw new Error(`icon ${res.status}`);
+ try {
+ await cache.put(url, res.clone());
+ } catch {
+ // Quota / private mode — still use the image this session.
+ }
+ const objectUrl = await responseToObjectUrl(res);
+ memory.set(key, objectUrl);
+ return objectUrl;
+ } catch {
+ memory.set(key, TOKEN_ICON_FALLBACK);
+ return TOKEN_ICON_FALLBACK;
+ } finally {
+ inflight.delete(key);
+ }
+ })();
+
+ inflight.set(key, task);
+ return task;
+}
+
+/** Warm the icon cache for a set of token ids (e.g. after balance discovery). */
+export function prefetchTokenIcons(
+ tokenIds: Iterable,
+ size: TokenIconSize = 64
+): void {
+ for (const tokenId of tokenIds) {
+ void loadTokenIcon(tokenId, size);
+ }
+}
diff --git a/apps/abcpay-web/src/pages/HomePage.tsx b/apps/abcpay-web/src/pages/HomePage.tsx
index d671b41..9ec7f7c 100644
--- a/apps/abcpay-web/src/pages/HomePage.tsx
+++ b/apps/abcpay-web/src/pages/HomePage.tsx
@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom';
import { COIN_CONFIGS } from '@bcpros/abcpay-models';
import { AbcPayLogo, CoinIcon, EyeToggle } from '../components/ui';
+import { TokenIcon } from '../components/TokenIcon';
import { formatFixed, hiddenTokenCount, visibleTokens, walletDisplayId } from '@bcpros/abcpay-wallet-core';
import { useWallets, accountNetworkLabel } from '../context/WalletContext';
import { formatTokenAtoms, tokenLabel } from '../lib/token-format';
@@ -143,11 +144,7 @@ export function HomePage() {
className="flex items-center justify-between text-sm"
>
-
+
{tokenLabel(token)}
diff --git a/apps/abcpay-web/src/pages/SettingsPage.tsx b/apps/abcpay-web/src/pages/SettingsPage.tsx
index b4c8a66..c1f4b4d 100644
--- a/apps/abcpay-web/src/pages/SettingsPage.tsx
+++ b/apps/abcpay-web/src/pages/SettingsPage.tsx
@@ -7,6 +7,7 @@ import {
labelForAccount
} from '../context/WalletContext';
import { useNotifications } from '../context/NotificationContext';
+import { TokenIcon } from '../components/TokenIcon';
import { API_URL } from '../lib/api';
import { tokenLabel } from '../lib/token-format';
@@ -124,10 +125,13 @@ export function SettingsPage() {
key={token.tokenId}
className="flex items-center justify-between gap-3 cursor-pointer"
>
-
- {tokenLabel(token)}
-
- {token.tokenId.slice(0, 16)}…
+
+
+
+ {tokenLabel(token)}
+
+ {token.tokenId.slice(0, 16)}…
+
{token?.protocol ?? 'SLP'} token balance
-
- {token ? `${formatTokenAtoms(token.atoms, decimals)} ${label}` : 'Loading…'}
-
- {token?.name && token.name !== label && (
- {token.name}
- )}
+
+ {tokenId &&
}
+
+
+ {token ? `${formatTokenAtoms(token.atoms, decimals)} ${label}` : 'Loading…'}
+
+ {token?.name && token.name !== label && (
+
{token.name}
+ )}
+
+
diff --git a/apps/abcpay-web/src/pages/WalletDetailPage.tsx b/apps/abcpay-web/src/pages/WalletDetailPage.tsx
index 8e7f585..74c6d99 100644
--- a/apps/abcpay-web/src/pages/WalletDetailPage.tsx
+++ b/apps/abcpay-web/src/pages/WalletDetailPage.tsx
@@ -8,6 +8,7 @@ import { useNotifications } from '../context/NotificationContext';
import { api } from '../lib/api';
import { intentFromProposal, mergeJoinRecordUntilComplete } from '../lib/proposal-verify';
import { broadcastProposal, signAndMaybeBroadcast } from '../lib/tx';
+import { TokenIcon } from '../components/TokenIcon';
import { formatTokenAtoms, tokenLabel } from '../lib/token-format';
import { formatAmount, hiddenTokenCount, visibleTokens, walletDisplayId } from '@bcpros/abcpay-wallet-core';
import { accountDisplayTitle, accountNetworkLabel, labelForAccount } from '../context/WalletContext';
@@ -176,7 +177,10 @@ export function WalletDetailPage() {
)}
- {tokenLabel(token)}
+
+
+
{tokenLabel(token)}
+
{token.name && token.name !== tokenLabel(token) && (
{token.name}
)}