+```
+
+## Icons
+
+Using **Lucide React**:
+- Standard: `size-4` or `size-5`
+- Large: `size-6`
+- In buttons: `[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`
diff --git a/.claude/skills/tanstack-query/SKILL.md b/.claude/skills/tanstack-query/SKILL.md
new file mode 100644
index 00000000..b9b304e7
--- /dev/null
+++ b/.claude/skills/tanstack-query/SKILL.md
@@ -0,0 +1,137 @@
+---
+name: tanstack-query
+description: TanStack Query v5 patterns specific to this project. Covers our cache class pattern, repository integration, suspense queries, event-driven updates, version-aware caching, and mutation patterns. Use for all server state management and cache operations.
+---
+
+# TanStack Query v5 — Project Conventions
+
+`@tanstack/react-query` v5.90+ with React 19, React Router v7.
+
+**References** (load when needed):
+- [cache-reference.md](references/cache-reference.md) — All cache classes, queryOptions factories, staleTime config, query key patterns, error classes
+- [code-patterns.md](references/code-patterns.md) — Full code examples for each pattern below
+
+## Architecture Decisions
+
+### Event-Driven Updates (not polling)
+
+Most data uses `staleTime: Infinity` and is updated via **Supabase Realtime** subscriptions, not polling or refetching. Cache classes receive Realtime events and update the query cache directly. `invalidate()` is used on reconnection as a safety net for missed events.
+
+The only polled query is exchange rates (`refetchInterval: 15_000`).
+
+### Cache Class Pattern
+
+Domain-specific classes encapsulate all cache operations with a static `Key` property. Provides typed API over raw `setQueryData`. Use `useXxxCache()` hooks for stable instances.
+
+```typescript
+class AccountsCache {
+ public static Key = 'accounts';
+ constructor(private readonly queryClient: QueryClient) {}
+ upsert(account: Account) { /* version-checked setQueryData */ }
+ update(account: Account) { /* version-checked setQueryData */ }
+ getAll() { /* getQueryData */ }
+ invalidate() { /* invalidateQueries */ }
+}
+
+// Stable instance via useMemo
+export function useAccountsCache() {
+ const queryClient = useQueryClient();
+ return useMemo(() => new AccountsCache(queryClient), [queryClient]);
+}
+```
+
+### Version-Aware Updates
+
+All cache updates check a `version` field to prevent stale data overwriting newer data (Supabase Realtime events can arrive out of order):
+
+```typescript
+// Always compare versions before updating
+curr.map((x) =>
+ x.id === item.id && item.version > x.version ? item : x,
+);
+```
+
+### queryOptions() Factories
+
+Primary abstraction for reusable query configs. Co-locates `queryKey` + `queryFn` with full type inference. Used across `useSuspenseQuery`, `ensureQueryData`, `prefetchQuery`, etc.
+
+### Query Keys
+
+Use static `Key` properties from cache classes. Structure generic-to-specific for fuzzy invalidation:
+- `[AccountsCache.Key]` — collection
+- `[CashuSendQuoteCache.Key, quoteId]` — individual resource
+- `[allTransactionsQueryKey, accountId]` — filtered list
+
+## Key Patterns
+
+### Required Data → `useSuspenseQuery`
+
+Data guaranteed non-undefined. Loading/error handled by boundaries. **Gotcha**: Multiple suspense queries in one component run serially — use `useSuspenseQueries` or prefetch in loader for parallel fetching.
+
+### Select → always `useCallback`
+
+Memoize `select` callbacks to prevent re-computation every render.
+
+### useUser Select
+
+`useUser((user) => user.id)` — components only re-render when selected field changes.
+
+### Route Loaders
+
+Use `ensureQueryData` for critical data (blocks rendering), `prefetchQuery` for nice-to-have data (non-blocking). Parallel `Promise.all` in `_protected.tsx` loader.
+
+### Invalidate vs Direct Update
+
+- **Direct update** (`setQueryData` via cache class) when you have the new data — avoids network round-trip
+- **Invalidation** when server computes the result
+
+### Mutation Callbacks — Two Levels
+
+1. `useMutation({ onSuccess })` — always runs, survives unmount → put cache updates here
+2. `mutate(vars, { onSuccess })` — only runs if mounted → put navigation/toasts here
+
+### Retry Strategy
+
+```typescript
+retry: (failureCount, error) => {
+ if (error instanceof DomainError) return false; // Never retry
+ if (error instanceof ConcurrencyError) return true; // Always retry
+ return failureCount < 1; // Network: once
+},
+```
+
+## Dynamic Scope (Custom Patch)
+
+**We patch `@tanstack/query-core`** (`patches/@tanstack%2Fquery-core@5.90.20.patch`) to support passing `scope` on individual `mutate()` / `mutateAsync()` calls. Stock TanStack Query only allows `scope` on `useMutation` options (static per-hook).
+
+**Why**: Payment state machines need global serialization for creation (one quote at a time) but per-entity serialization for state transitions (complete/expire/fail). Without dynamic scope, "complete quote A" blocks "expire quote B."
+
+```typescript
+// Static scope on hook — all create calls serialized globally
+useMutation({
+ scope: { id: 'initiate-cashu-send-quote' },
+ mutationFn: /* ... */,
+});
+
+// Dynamic scope at call site — per-entity serialization
+markAsPending(sendQuote.id, {
+ scope: { id: `cashu-send-quote-${sendQuote.id}` },
+});
+```
+
+**How it works**: `MutationObserver.mutate()` merges `options.scope` into mutation options when building the mutation. A `#mutationScopeOverride` flag preserves the scope across React re-renders (which trigger `setOptions`).
+
+**Upgrade warning**: When upgrading `@tanstack/query-core`, this patch must be reapplied.
+
+## Anti-Patterns
+
+| Don't | Do Instead |
+|-------|-----------|
+| `useEffect` for data fetching | `useQuery` / `useSuspenseQuery` |
+| `onSuccess`/`onError` on `useQuery` | Removed in v5. Use mutation callbacks or `useEffect` on query state |
+| Cache update without version check | Always compare `version` field |
+| `new AccountsCache(queryClient)` in render | `useAccountsCache()` hook (useMemo) |
+| `invalidateQueries` when you have the data | Direct cache update via cache class |
+| Unmemoized `select` callback | Wrap in `useCallback` |
+| Copy query data to `useState` | Use query data directly, transform with `select` |
+| Missing variables in query key | Include ALL `queryFn` dependencies in key |
diff --git a/.claude/skills/tanstack-query/references/cache-reference.md b/.claude/skills/tanstack-query/references/cache-reference.md
new file mode 100644
index 00000000..bc24d387
--- /dev/null
+++ b/.claude/skills/tanstack-query/references/cache-reference.md
@@ -0,0 +1,88 @@
+# Cache & Query Reference Tables
+
+## Cache Classes
+
+All cache classes follow the same pattern: static `Key` property, constructor takes `QueryClient`, methods are version-aware. Use `useXxxCache()` hooks for stable instances (wraps `useMemo`).
+
+| Cache Class | Key | Location |
+|---|---|---|
+| `AccountsCache` | `'accounts'` | `app/features/accounts/account-hooks.ts` |
+| `TransactionsCache` | `'transactions'` | `app/features/transactions/transaction-hooks.ts` |
+| `ContactsCache` | `'contacts'` | `app/features/contacts/contact-hooks.ts` |
+| `CashuSendQuoteCache` | `'cashu-send-quote'` | `app/features/send/cashu-send-quote-hooks.ts` |
+| `UnresolvedCashuSendQuotesCache` | `'unresolved-cashu-send-quotes'` | `app/features/send/cashu-send-quote-hooks.ts` |
+| `CashuSendSwapCache` | `'cashu-send-swap'` | `app/features/send/cashu-send-swap-hooks.ts` |
+| `CashuReceiveQuoteCache` | `'cashu-receive-quote'` | `app/features/receive/cashu-receive-quote-hooks.ts` |
+| `PendingCashuReceiveQuotesCache` | `'pending-cashu-receive-quotes'` | `app/features/receive/cashu-receive-quote-hooks.ts` |
+| `CashuReceiveSwapCache` | `'cashu-receive-swap'` | `app/features/receive/cashu-receive-swap-hooks.ts` |
+| `PendingCashuReceiveSwapsCache` | `'pending-cashu-receive-swaps'` | `app/features/receive/cashu-receive-swap-hooks.ts` |
+| `SparkReceiveQuoteCache` | `'spark-receive-quote'` | `app/features/receive/spark-receive-quote-hooks.ts` |
+| `PendingSparkReceiveQuotesCache` | `'pending-spark-receive-quotes'` | `app/features/receive/spark-receive-quote-hooks.ts` |
+| `UnresolvedSparkSendQuotesCache` | `'unresolved-spark-send-quotes'` | `app/features/send/spark-send-quote-hooks.ts` |
+
+**Standard methods**: `add()`, `update()` / `updateIfExists()`, `upsert()`, `get()` / `getAll()`, `remove()`, `invalidate()`.
+
+## queryOptions Factories
+
+| Factory | staleTime | Location |
+|---|---|---|
+| `accountsQueryOptions` | `Infinity` | `app/features/accounts/account-hooks.ts` |
+| `userQueryOptions` | default | `app/features/user/user-hooks.tsx` |
+| `exchangeRatesQueryOptions` | default | `app/hooks/use-exchange-rate.ts` |
+| `seedQueryOptions` | `Infinity` | `app/features/shared/cashu.ts` |
+| `xpubQueryOptions` | `Infinity` | `app/features/shared/cashu.ts` |
+| `privateKeyQueryOptions` | `Infinity` | `app/features/shared/cashu.ts` |
+| `mintInfoQueryOptions` | 1 hour | `app/features/shared/cashu.ts` |
+| `allMintKeysetsQueryOptions` | 1 hour | `app/features/shared/cashu.ts` |
+| `mintKeysQueryOptions` | 1 hour | `app/features/shared/cashu.ts` |
+| `isTestMintQueryOptions` | `Infinity` | `app/features/shared/cashu.ts` |
+| `sparkMnemonicQueryOptions` | `Infinity` | `app/features/shared/spark.ts` |
+| `sparkIdentityPublicKeyQueryOptions` | `Infinity` | `app/features/shared/spark.ts` |
+| `sparkWalletQueryOptions` | `Infinity` + `gcTime: Infinity` | `app/features/shared/spark.ts` |
+
+## staleTime Configuration Rationale
+
+| Data Type | staleTime | gcTime | Rationale |
+|---|---|---|---|
+| Crypto keys, seeds, mnemonics | `Infinity` | `Infinity` | Deterministic derivation, never changes |
+| Spark wallet instances | `Infinity` | `Infinity` | Expensive to reinitialize |
+| Accounts, transactions, contacts | `Infinity` | default | Updated via Supabase Realtime events |
+| Mint metadata (info, keysets, keys) | 1 hour | default | Rarely changes but not event-driven |
+| Exchange rates | default | default | Uses `refetchInterval: 15_000` |
+
+## Query Key Patterns
+
+**Pattern 1 — Static keys on cache classes** (single-collection queries):
+```typescript
+queryKey: [AccountsCache.Key] // ['accounts']
+queryKey: [ContactsCache.Key] // ['contacts']
+```
+
+**Pattern 2 — Hierarchical keys with IDs** (individual resources):
+```typescript
+queryKey: [CashuSendQuoteCache.Key, quoteId] // ['cashu-send-quote', 'abc']
+queryKey: [TransactionsCache.Key, transactionId] // ['transactions', '123']
+queryKey: ['spark-balance', accountId] // ['spark-balance', 'acc-1']
+```
+
+**Pattern 3 — Derived keys** (computed from base keys):
+```typescript
+const allTransactionsQueryKey = 'all-transactions';
+const unacknowledgedCountQueryKey = `${TransactionsCache.Key}-unacknowledged-count`;
+```
+
+**Pattern 4 — Parameterized keys** (list views with filters):
+```typescript
+queryKey: [allTransactionsQueryKey, accountId] // ['all-transactions', 'acc-1']
+```
+
+## Error Classes
+
+Defined in `app/features/shared/error.ts`:
+
+| Class | Retry behavior | Use |
+|---|---|---|
+| `DomainError` | Never retry | Business rule violations, user-friendly messages |
+| `ConcurrencyError` | Always retry | Optimistic locking conflicts |
+| `NotFoundError` | Never retry | Missing resources |
+| `UniqueConstraintError` | Never retry | Duplicate entries |
diff --git a/.claude/skills/tanstack-query/references/code-patterns.md b/.claude/skills/tanstack-query/references/code-patterns.md
new file mode 100644
index 00000000..5a4f9778
--- /dev/null
+++ b/.claude/skills/tanstack-query/references/code-patterns.md
@@ -0,0 +1,286 @@
+# Code Patterns
+
+## Cache Class Pattern
+
+```typescript
+// app/features/accounts/account-hooks.ts
+export class AccountsCache {
+ public static Key = 'accounts';
+
+ constructor(private readonly queryClient: QueryClient) {}
+
+ upsert(account: Account) {
+ this.queryClient.setQueryData([AccountsCache.Key], (curr: Account[]) => {
+ const exists = curr.findIndex((x) => x.id === account.id);
+ if (exists !== -1) {
+ // Version check: only update if newer
+ return curr.map((x) =>
+ x.id === account.id && account.version > x.version ? account : x,
+ );
+ }
+ return [...curr, account];
+ });
+ }
+
+ update(account: Account) {
+ this.queryClient.setQueryData([AccountsCache.Key], (curr: Account[]) =>
+ curr.map((x) =>
+ x.id === account.id && account.version > x.version ? account : x,
+ ),
+ );
+ }
+
+ getAll() {
+ return this.queryClient.getQueryData
([AccountsCache.Key]);
+ }
+
+ get(id: string) {
+ return this.getAll()?.find((x) => x.id === id) ?? null;
+ }
+
+ invalidate() {
+ return this.queryClient.invalidateQueries({
+ queryKey: [AccountsCache.Key],
+ });
+ }
+}
+
+// Hook for stable instance (useMemo prevents re-creation on every render)
+export function useAccountsCache() {
+ const queryClient = useQueryClient();
+ return useMemo(() => new AccountsCache(queryClient), [queryClient]);
+}
+```
+
+## queryOptions Factory
+
+```typescript
+export const accountsQueryOptions = ({
+ userId,
+ accountRepository,
+}: { userId: string; accountRepository: AccountRepository }) => {
+ return queryOptions({
+ queryKey: [AccountsCache.Key],
+ queryFn: () => accountRepository.getAll(userId),
+ staleTime: Number.POSITIVE_INFINITY,
+ });
+};
+
+// Use in component
+const { data } = useSuspenseQuery(accountsQueryOptions({ userId, accountRepository }));
+
+// Use in route loader
+await queryClient.ensureQueryData(accountsQueryOptions({ userId, accountRepository }));
+```
+
+## Suspense Query with Select
+
+```typescript
+export function useAccounts(
+ select?: UseAccountsSelect,
+): UseSuspenseQueryResult[]> {
+ const user = useUser();
+ const accountRepository = useAccountRepository();
+
+ return useSuspenseQuery({
+ ...accountsQueryOptions({ userId: user.id, accountRepository }),
+ refetchOnWindowFocus: 'always',
+ refetchOnReconnect: 'always',
+ // ALWAYS memoize select to prevent re-computation every render
+ select: useCallback(
+ (data: Account[]) => data.filter(/* ... */) as ExtendedAccount[],
+ [select?.currency, select?.type],
+ ),
+ });
+}
+```
+
+## useUser Select Pattern
+
+Extract specific fields to avoid re-renders when other user fields change:
+```typescript
+const userId = useUser((user) => user.id);
+const defaultCurrency = useUser((x) => x.defaultCurrency);
+```
+
+## Mutation with Cache Update
+
+```typescript
+export function useAddCashuAccount() {
+ const userId = useUser((x) => x.id);
+ const accountCache = useAccountsCache();
+ const accountService = useAccountService();
+
+ const { mutateAsync } = useMutation({
+ mutationFn: async (account) =>
+ accountService.addCashuAccount({ userId, account }),
+ onSuccess: (account) => {
+ accountCache.upsert(account); // Direct cache update, no refetch
+ },
+ });
+
+ return mutateAsync;
+}
+```
+
+## Mutation with Static Scope
+
+```typescript
+useMutation({
+ mutationKey: ['initiate-cashu-send-quote'],
+ scope: { id: 'initiate-cashu-send-quote' }, // All calls serialized
+ mutationFn: /* ... */,
+ onSuccess: (data) => {
+ cashuSendQuoteCache.add(data);
+ onSuccess(data);
+ },
+ retry: (failureCount, error) => {
+ if (error instanceof ConcurrencyError) return true;
+ if (error instanceof DomainError) return false;
+ return failureCount < 1;
+ },
+});
+```
+
+## Dynamic Scope at Call Site
+
+```typescript
+// Per-entity scope: "complete quote A" doesn't block "expire quote B"
+markSendQuoteAsPending(sendQuote.id, {
+ scope: { id: `cashu-send-quote-${sendQuote.id}` },
+});
+
+completeSwap(swap.id, {
+ scope: { id: `send-swap-${swap.id}` },
+});
+```
+
+## Infinite Query (Transactions)
+
+```typescript
+const PAGE_SIZE = 20;
+
+export function useTransactions(accountId?: string) {
+ return useInfiniteQuery({
+ queryKey: [allTransactionsQueryKey, accountId],
+ initialPageParam: null,
+ queryFn: async ({ pageParam }: { pageParam: Cursor | null }) => {
+ const result = await transactionRepository.list({
+ userId, cursor: pageParam, pageSize: PAGE_SIZE, accountId,
+ });
+ return {
+ transactions: result.transactions,
+ nextCursor: result.transactions.length === PAGE_SIZE
+ ? result.nextCursor : null,
+ };
+ },
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ refetchOnWindowFocus: 'always',
+ refetchOnReconnect: 'always',
+ retry: 1,
+ });
+}
+```
+
+## Updating Infinite Query Cache
+
+Use `getQueriesData` to update all filtered views at once:
+```typescript
+const acknowledgeInHistoryCache = (queryClient, transaction) => {
+ const queries = queryClient.getQueriesData>({
+ queryKey: [allTransactionsQueryKey],
+ });
+
+ queries.forEach(([queryKey, data]) => {
+ if (!data) return;
+ queryClient.setQueryData(queryKey, {
+ ...data,
+ pages: data.pages.map((page) => ({
+ ...page,
+ transactions: page.transactions.map((tx) =>
+ tx.id === transaction.id ? { ...tx, acknowledgmentStatus: 'acknowledged' } : tx,
+ ),
+ })),
+ });
+ });
+};
+```
+
+## Event-Driven Cache Updates (Supabase Realtime)
+
+```typescript
+// app/features/wallet/use-track-wallet-changes.ts
+export const useTrackWalletChanges = () => {
+ const accountChangeHandlers = useAccountChangeHandlers();
+ const transactionChangeHandlers = useTransactionChangeHandlers();
+
+ useTrackDatabaseChanges({
+ handlers: [...accountChangeHandlers, ...transactionChangeHandlers],
+ onConnected: () => {
+ accountsCache.invalidate(); // Catch events missed during disconnect
+ transactionsCache.invalidate();
+ },
+ });
+};
+
+// Change handler pattern
+export function useAccountChangeHandlers() {
+ const accountRepository = useAccountRepository();
+ const accountCache = useAccountsCache();
+
+ return [
+ {
+ event: 'ACCOUNT_CREATED',
+ handleEvent: async (payload) => {
+ const account = await accountRepository.toAccount(payload);
+ accountCache.upsert(account);
+ },
+ },
+ {
+ event: 'ACCOUNT_UPDATED',
+ handleEvent: async (payload) => {
+ const account = await accountRepository.toAccount(payload);
+ accountCache.update(account); // Version-checked
+ },
+ },
+ ];
+}
+```
+
+## Route Loader Prefetching
+
+```typescript
+// app/routes/_protected.tsx
+const ensureUserData = async (queryClient, authUser) => {
+ const [encryptionPrivateKey, encryptionPublicKey, cashuLockingXpub] =
+ await Promise.all([
+ queryClient.ensureQueryData(encryptionPrivateKeyQueryOptions()),
+ queryClient.ensureQueryData(encryptionPublicKeyQueryOptions()),
+ queryClient.ensureQueryData(xpubQueryOptions({ queryClient, derivationPath })),
+ ]);
+};
+```
+
+## Query Client Setup
+
+**File**: `app/query-client.ts`
+
+```typescript
+let browserQueryClient: QueryClient | undefined = undefined;
+
+function makeQueryClient() {
+ return new QueryClient();
+}
+
+export function getQueryClient() {
+ if (isServer) {
+ return makeQueryClient(); // New instance per SSR request
+ }
+ if (!browserQueryClient) {
+ browserQueryClient = makeQueryClient();
+ }
+ return browserQueryClient; // Singleton in browser
+}
+```
+
+Provider with HydrationBoundary and devtools in `app/root.tsx`.
diff --git a/app/components/wallet-card.tsx b/app/components/wallet-card.tsx
index 2a29d69c..14588168 100644
--- a/app/components/wallet-card.tsx
+++ b/app/components/wallet-card.tsx
@@ -103,7 +103,6 @@ export function WalletCardBackgroundImage({