From bc482e2eea0792368c79775005243ab4f3c8c622 Mon Sep 17 00:00:00 2001 From: bade2brazy <205598258+bade2brazy@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:11:22 +0100 Subject: [PATCH 01/10] feat(env): clamp getFeeMultiplier to a sane [1, 10] range `getFeeMultiplier()` had a floor but no ceiling, so a mistyped `NEXT_PUBLIC_SOROBAN_FEE_MULTIPLIER=200` (for `2.00`) bid 200x the recent inclusion fee on every transaction, backstopped only by MAX_INCLUSION_FEE (0.1 XLM). It now rejects a non-numeric, non-positive, or out-of-[1, 10] value with a console.warn and uses the default, matching env.ts's posture. Closes #428 --- lib/env.ts | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/env.ts b/lib/env.ts index 6b91e1d..2a90fdb 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -50,6 +50,15 @@ export function getHorizonUrl(): string | undefined { const DEFAULT_FEE_MULTIPLIER = 2; +/** + * A fee multiplier outside this range is almost certainly a typo — e.g. `200` + * typed for `2.00` — and would overbid the inclusion fee on every transaction, + * bounded only by MAX_INCLUSION_FEE (0.1 XLM) in lib/soroban.ts. Values outside + * it are rejected in favour of the default. See #428. + */ +const MIN_FEE_MULTIPLIER = 1; +const MAX_FEE_MULTIPLIER = 10; + /** * Multiplier applied over the network's observed inclusion fee (and over * BASE_FEE as a floor) when building contract transactions. @@ -57,12 +66,31 @@ const DEFAULT_FEE_MULTIPLIER = 2; * A bid of exactly BASE_FEE (100 stroops) is the network minimum and is not * selected under any inclusion-fee pressure, which surfaced to users as a * misleading "transaction timed out" instead of "fee too low" (see #360). - * Defaults to 2×; ignores non-numeric or non-positive values. + * Defaults to 2×. A non-numeric, non-positive, or out-of-[1, 10]-range value + * is rejected with a `console.warn` and the default is used instead — the same + * defensive posture the rest of this module takes (#428). */ export function getFeeMultiplier(): number { const raw = process.env['NEXT_PUBLIC_SOROBAN_FEE_MULTIPLIER']; if (!raw) return DEFAULT_FEE_MULTIPLIER; + const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_FEE_MULTIPLIER; + if (!Number.isFinite(parsed) || parsed <= 0) { + console.warn( + `Ignoring NEXT_PUBLIC_SOROBAN_FEE_MULTIPLIER="${raw}" — not a positive number. ` + + `Using the default ${DEFAULT_FEE_MULTIPLIER}x.`, + ); + return DEFAULT_FEE_MULTIPLIER; + } + + if (parsed < MIN_FEE_MULTIPLIER || parsed > MAX_FEE_MULTIPLIER) { + console.warn( + `Ignoring NEXT_PUBLIC_SOROBAN_FEE_MULTIPLIER=${parsed} — outside the supported ` + + `[${MIN_FEE_MULTIPLIER}, ${MAX_FEE_MULTIPLIER}] range. A value like 200 (meant as ` + + `2.00) would overbid every transaction. Using the default ${DEFAULT_FEE_MULTIPLIER}x.`, + ); + return DEFAULT_FEE_MULTIPLIER; + } + return parsed; } From 962fe4eb21f534990a12c809ba022593221b6489 Mon Sep 17 00:00:00 2001 From: bade2brazy <205598258+bade2brazy@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:11:23 +0100 Subject: [PATCH 02/10] test(env): cover getFeeMultiplier bounds Default, in-range values and boundaries, and the rejected cases (200, non-numeric, zero, negative) with the warn. Refs #428 --- lib/env.test.ts | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/env.test.ts b/lib/env.test.ts index f94cbc6..6794ceb 100644 --- a/lib/env.test.ts +++ b/lib/env.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const ENV_KEYS = [ 'NEXT_PUBLIC_SOROBAN_RPC_URL', @@ -6,6 +6,7 @@ const ENV_KEYS = [ 'NEXT_PUBLIC_FACTORY_CONTRACT_ID', 'NEXT_PUBLIC_GOVERNOR_CONTRACT_ID', 'NEXT_PUBLIC_HORIZON_URL', + 'NEXT_PUBLIC_SOROBAN_FEE_MULTIPLIER', ] as const; const original: Record = {}; @@ -61,3 +62,47 @@ describe('getGovernorContractId / getHorizonUrl', () => { expect(getHorizonUrl()).toBe('https://horizon-testnet.stellar.org'); }); }); +describe('getFeeMultiplier (#428)', () => { + it('defaults to 2 when the var is unset', async () => { + const { getFeeMultiplier } = await import('./env.js'); + expect(getFeeMultiplier()).toBe(2); + }); + + it('accepts a value inside [1, 10], including the boundaries', async () => { + const { getFeeMultiplier } = await import('./env.js'); + process.env['NEXT_PUBLIC_SOROBAN_FEE_MULTIPLIER'] = '3.5'; + expect(getFeeMultiplier()).toBe(3.5); + process.env['NEXT_PUBLIC_SOROBAN_FEE_MULTIPLIER'] = '1'; + expect(getFeeMultiplier()).toBe(1); + process.env['NEXT_PUBLIC_SOROBAN_FEE_MULTIPLIER'] = '10'; + expect(getFeeMultiplier()).toBe(10); + }); + + it('rejects a fat-fingered 200 and warns, falling back to the default', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + process.env['NEXT_PUBLIC_SOROBAN_FEE_MULTIPLIER'] = '200'; + const { getFeeMultiplier } = await import('./env.js'); + expect(getFeeMultiplier()).toBe(2); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('rejects a non-numeric value and warns', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + process.env['NEXT_PUBLIC_SOROBAN_FEE_MULTIPLIER'] = 'fast'; + const { getFeeMultiplier } = await import('./env.js'); + expect(getFeeMultiplier()).toBe(2); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('rejects zero and negative values', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { getFeeMultiplier } = await import('./env.js'); + process.env['NEXT_PUBLIC_SOROBAN_FEE_MULTIPLIER'] = '0'; + expect(getFeeMultiplier()).toBe(2); + process.env['NEXT_PUBLIC_SOROBAN_FEE_MULTIPLIER'] = '-3'; + expect(getFeeMultiplier()).toBe(2); + vi.restoreAllMocks(); + }); +}); From 5a7e04b43b867ee36588ac9697885cdaadea6255 Mon Sep 17 00:00:00 2001 From: bade2brazy <205598258+bade2brazy@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:11:24 +0100 Subject: [PATCH 03/10] feat(tokens): add resolveTokenBySymbol and cross-network lookup helpers `TOKENS_TESTNET` has EURC and `TOKENS_MAINNET` does not (#429), so a symbol carried over a network switch resolves to `undefined` and breaks callers that assume a `TokenMeta`. `resolveTokenBySymbol` falls back to the network's XLM with `wasReset: true`; `networksForSymbol` / `networksForAddress` report where a token actually exists. Refs #429 --- lib/tokens.ts | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/lib/tokens.ts b/lib/tokens.ts index 3d6d690..881c028 100644 --- a/lib/tokens.ts +++ b/lib/tokens.ts @@ -84,6 +84,64 @@ export function tokenLogoUrl(symbol: string, network: 'mainnet' | 'testnet' | 'l return tokenBySymbol(symbol, network)?.logoUrl ?? '/tokens/generic.svg'; } +/** + * Symbol used as the safe fallback when a previously-selected token is not + * available on the active network (#429). It is present on every network list. + */ +export const DEFAULT_TOKEN_SYMBOL = 'XLM'; + +export interface TokenResolution { + /** The resolved token — never `undefined`; falls back to {@link DEFAULT_TOKEN_SYMBOL}. */ + token: TokenMeta; + /** `true` when `symbol` was absent on `network` and the default was substituted. */ + wasReset: boolean; +} + +/** + * Resolve a token by symbol on a network, falling back to that network's + * default token (XLM) instead of returning `undefined`. + * + * The testnet and mainnet symbol sets differ — testnet has EURC, mainnet does + * not (#429) — so a symbol carried over from a previous network selection can + * be missing on the new one, leaving `TokenSelector` with no selection and + * breaking `/create` / top-up flows that assume a resolved `TokenMeta`. + * Callers that need a concrete token should use this and surface `wasReset` + * (e.g. a toast) rather than dereferencing a possibly-`undefined` lookup. + */ +export function resolveTokenBySymbol( + symbol: string, + network: 'mainnet' | 'testnet' | 'local', +): TokenResolution { + const match = tokenBySymbol(symbol, network); + if (match) return { token: match, wasReset: false }; + + const list = getTokens(network); + const fallback = list.find(t => t.symbol === DEFAULT_TOKEN_SYMBOL) ?? list[0]; + if (!fallback) { + throw new Error(`No tokens configured for network "${network}"`); + } + return { token: fallback, wasReset: true }; +} + +/** + * The networks on which `symbol` is a known token. Lets the UI say "EURC + * exists on testnet but not mainnet" instead of a bare "unknown token". + */ +export function networksForSymbol(symbol: string): Array<'mainnet' | 'testnet'> { + const networks: Array<'mainnet' | 'testnet'> = []; + if (TOKENS_MAINNET.some(t => t.symbol === symbol)) networks.push('mainnet'); + if (TOKENS_TESTNET.some(t => t.symbol === symbol)) networks.push('testnet'); + return networks; +} + +/** Companion to {@link networksForSymbol} for the address-based selector. */ +export function networksForAddress(address: string): Array<'mainnet' | 'testnet'> { + const networks: Array<'mainnet' | 'testnet'> = []; + if (TOKENS_MAINNET.some(t => t.address === address)) networks.push('mainnet'); + if (TOKENS_TESTNET.some(t => t.address === address)) networks.push('testnet'); + return networks; +} + // ── Token Allowance Helpers (SEP-41) ────────────────────────────────────────── import { From 25fc7513e5cca1bdbac2f012d43dd041d39ea524 Mon Sep 17 00:00:00 2001 From: bade2brazy <205598258+bade2brazy@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:11:26 +0100 Subject: [PATCH 04/10] feat(tokens): tell the user when a contract is a token on another network TokenSelector now distinguishes "unknown contract" from "known token, wrong network" and names the network(s) it does exist on (#429). --- components/TokenSelector.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/components/TokenSelector.tsx b/components/TokenSelector.tsx index e80fbd4..5a47e48 100644 --- a/components/TokenSelector.tsx +++ b/components/TokenSelector.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect, useRef, useCallback } from 'react'; -import { tokenByAddress, type TokenMeta } from '@/lib/tokens'; +import { tokenByAddress, networksForAddress, type TokenMeta } from '@/lib/tokens'; /** * Stellar Soroban contract addresses are base32-encoded with the RFC 4648 @@ -55,6 +55,9 @@ export function TokenSelector({ const [status, setStatus] = useState('idle'); const [token, setToken] = useState(null); const [errorMsg, setErrorMsg] = useState(null); + // Networks (other than the current one) on which an otherwise-unknown + // address IS a known token — so the message can point the user there (#429). + const [otherNetworks, setOtherNetworks] = useState>([]); // Ref to the AbortController for the current in-flight metadata lookup. // Replaced on every new lookup so the previous one can be cancelled. @@ -70,6 +73,7 @@ export function TokenSelector({ setStatus('loading'); setToken(null); setErrorMsg(null); + setOtherNetworks([]); try { // Simulate async resolution — allows a future upgrade to a real RPC @@ -84,6 +88,11 @@ export function TokenSelector({ setToken(found); setStatus(found ? 'resolved' : 'unknown'); + if (!found) { + setOtherNetworks( + networksForAddress(address).filter(n => n !== network), + ); + } onTokenResolved?.(found); } catch (err: unknown) { if (controller.signal.aborted) return; @@ -103,6 +112,7 @@ export function TokenSelector({ setStatus('idle'); setToken(null); setErrorMsg(null); + setOtherNetworks([]); if (value.length > 0) { // Non-empty but invalid — the error state is shown by the validation // message below rather than as a resolve error. @@ -177,7 +187,10 @@ export function TokenSelector({ {!showValidationError && status === 'unknown' && (

- Contract address not in the known token list — it may still be valid on-chain. + {otherNetworks.length > 0 + ? `This contract is a known token on ${otherNetworks.join(' and ')}, but not on ` + + `${network}. Switch networks or choose a different token.` + : 'Contract address not in the known token list — it may still be valid on-chain.'}

)} From 66fa4bfb6af385c09d64003014facb5967d22486 Mon Sep 17 00:00:00 2001 From: bade2brazy <205598258+bade2brazy@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:11:26 +0100 Subject: [PATCH 05/10] test(tokens): cover resolveTokenBySymbol and the network helpers Refs #429 --- lib/tokens.test.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/lib/tokens.test.ts b/lib/tokens.test.ts index 3026357..88afb4d 100644 --- a/lib/tokens.test.ts +++ b/lib/tokens.test.ts @@ -9,6 +9,9 @@ import { getAllowance, checkAllowance, approveAllowance, + resolveTokenBySymbol, + networksForSymbol, + networksForAddress, } from './tokens'; import { StrKey, Keypair } from '@stellar/stellar-sdk'; import { resetTokenAllowanceGateway } from './token-allowance-gateway'; @@ -144,3 +147,33 @@ describe('SEP-41 Token Allowance Helpers (#347, #348)', () => { expect(passedArgs).toHaveLength(4); }); }); +describe('resolveTokenBySymbol / cross-network helpers (#429)', () => { + it('resolves a symbol that exists on the network without resetting', () => { + const { token, wasReset } = resolveTokenBySymbol('USDC', 'mainnet'); + expect(token.symbol).toBe('USDC'); + expect(wasReset).toBe(false); + }); + + it('falls back to XLM with wasReset when the symbol is missing on the network', () => { + // EURC is only in the testnet list. + const { token, wasReset } = resolveTokenBySymbol('EURC', 'mainnet'); + expect(token.symbol).toBe('XLM'); + expect(wasReset).toBe(true); + }); + + it('does not reset for a symbol that does exist on the target network', () => { + expect(resolveTokenBySymbol('EURC', 'testnet').wasReset).toBe(false); + }); + + it('reports which networks a symbol belongs to', () => { + expect(networksForSymbol('EURC')).toEqual(['testnet']); + expect(networksForSymbol('USDC').sort()).toEqual(['mainnet', 'testnet']); + expect(networksForSymbol('DOGE')).toEqual([]); + }); + + it('reports which networks an address belongs to', () => { + const eurc = TOKENS_TESTNET.find(t => t.symbol === 'EURC'); + expect(networksForAddress(eurc!.address!)).toEqual(['testnet']); + expect(networksForAddress('CNOTATOKEN')).toEqual([]); + }); +}); From 215b03e027a780168141f8a0a4ad2eb2448c2226 Mon Sep 17 00:00:00 2001 From: bade2brazy <205598258+bade2brazy@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:11:28 +0100 Subject: [PATCH 06/10] feat(wallet): add touchWalletSession for sliding session expiry The wallet session's `expiresAt` is stamped once at connect, so an active user is force-disconnected exactly 24h later regardless of activity (#430). `touchWalletSession(ttlMs?)` re-stamps a still-valid session; it is a no-op for an absent or expired session. Refs #430 --- lib/wallet-storage.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/wallet-storage.ts b/lib/wallet-storage.ts index 3a4b1e3..ac999a1 100644 --- a/lib/wallet-storage.ts +++ b/lib/wallet-storage.ts @@ -82,6 +82,25 @@ export function clearWalletSession(): void { safeRemove(WALLET_STORAGE_KEY); } +/** + * Slide a still-valid session's expiry to `ttlMs` from now (#430). + * + * `expiresAt` is otherwise stamped once at connect, so a user who keeps the + * app open is force-disconnected exactly `DEFAULT_SESSION_TTL_MS` after the + * initial connect, mid-session. Call this on meaningful activity (a successful + * signature) and when a valid session is restored on mount, so only a genuinely + * idle session lapses. + * + * No-op when there is no stored session, or it is already expired — those go + * through the normal connect flow. `loadWalletSession()` clears an expired + * entry as a side effect, matching the old behaviour. + */ +export function touchWalletSession(ttlMs: number = DEFAULT_SESSION_TTL_MS): void { + const existing = loadWalletSession(); + if (!existing) return; + saveWalletSession({ key: existing.key, name: existing.name }, ttlMs); +} + /** Load a previously-persisted wallet session, or null if none exists, malformed, or expired. */ export function loadWalletSession(): PersistedWallet | null { const raw = safeGet(WALLET_STORAGE_KEY); From 4b3ed841f82156416f6acad68f9a7f79617fd212 Mon Sep 17 00:00:00 2001 From: bade2brazy <205598258+bade2brazy@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:11:29 +0100 Subject: [PATCH 07/10] feat(wallet): slide the session TTL on restore and after each signature WalletProvider calls `touchWalletSession()` when it restores a valid session on mount and after every successful `signTx`, so only a genuinely idle session lapses. Closes #430 --- contexts/WalletContext.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/contexts/WalletContext.tsx b/contexts/WalletContext.tsx index 92533ae..f824618 100644 --- a/contexts/WalletContext.tsx +++ b/contexts/WalletContext.tsx @@ -40,6 +40,7 @@ import { clearWalletSession, loadWalletSession, saveWalletSession, + touchWalletSession, } from '@/lib/wallet-storage'; import toast from 'react-hot-toast'; @@ -327,6 +328,10 @@ export function WalletProvider({ if (stored) { setPublicKey(stored.key); setWalletName(stored.name); + // A returning user with a still-valid session is active — slide the + // expiry so an open tab isn't force-disconnected 24h after the first + // connect (#430). + touchWalletSession(); } return () => { @@ -529,6 +534,9 @@ export function WalletProvider({ if (error || !signedTxXdr) { throw new Error(error?.message ?? 'Failed to sign transaction in Freighter.'); } + // A successful signature is meaningful activity — keep the session + // alive rather than let it lapse mid-use (#430). + touchWalletSession(); return signedTxXdr; } finally { release(); From 8e60ec6ab2e9c6339dd9044b622af043867b5b7c Mon Sep 17 00:00:00 2001 From: bade2brazy <205598258+bade2brazy@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:11:30 +0100 Subject: [PATCH 08/10] test(wallet): cover sliding-expiry behaviour Refs #430 --- lib/wallet-storage.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/lib/wallet-storage.test.ts b/lib/wallet-storage.test.ts index 6b14100..e485a33 100644 --- a/lib/wallet-storage.test.ts +++ b/lib/wallet-storage.test.ts @@ -14,6 +14,7 @@ import { clearWalletSession, loadWalletSession, saveWalletSession, + touchWalletSession, } from './wallet-storage.js'; // ── Minimal localStorage stub ──────────────────────────────────────────────── @@ -149,3 +150,39 @@ describe('loadWalletSession', () => { expect(loadWalletSession()).toBeNull(); }); }); +describe('touchWalletSession (#430)', () => { + it('slides a still-valid session expiry forward', () => { + const soon = Date.now() + 60_000; + store.set( + WALLET_STORAGE_KEY, + JSON.stringify({ key: 'GTEST', name: 'Freighter', expiresAt: soon }), + ); + touchWalletSession(); + const parsed = JSON.parse(store.get(WALLET_STORAGE_KEY)!); + expect(parsed.expiresAt).toBeGreaterThan(soon); + }); + + it('honours a custom ttl', () => { + saveWalletSession({ key: 'GTEST', name: 'Freighter' }); + const before = Date.now(); + touchWalletSession(5 * 60 * 1000); + const parsed = JSON.parse(store.get(WALLET_STORAGE_KEY)!); + expect(parsed.expiresAt).toBeGreaterThanOrEqual(before + 5 * 60 * 1000); + expect(parsed.expiresAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000); + }); + + it('is a no-op when no session is stored, leaving unrelated keys intact', () => { + touchWalletSession(); + expect(store.has(WALLET_STORAGE_KEY)).toBe(false); + expect(store.get(THEME_KEY)).toBe(THEME_VALUE); + }); + + it('clears and does not re-stamp an already-expired session', () => { + store.set( + WALLET_STORAGE_KEY, + JSON.stringify({ key: 'GTEST', name: 'Freighter', expiresAt: Date.now() - 1000 }), + ); + touchWalletSession(); + expect(store.has(WALLET_STORAGE_KEY)).toBe(false); + }); +}); From fc0d8beba69f0ff45b18e02ac86564b6a475371d Mon Sep 17 00:00:00 2001 From: bade2brazy <205598258+bade2brazy@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:11:31 +0100 Subject: [PATCH 09/10] feat(query): add structured query keys and a targeted stream-mutation invalidator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lib/query-keys.ts` gives streams stable keys (`['stream', address, 'info']`, ...) and `invalidateStreamMutation(qc, address)` invalidates only the stream, streams-list, dashboard, transactions, and wallet-balance trees — the foundation for replacing the app-wide `invalidateQueries()` (#431). Refs #431 --- lib/query-keys.test.ts | 38 ++++++++++++++++++++++++++++ lib/query-keys.ts | 57 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 lib/query-keys.test.ts create mode 100644 lib/query-keys.ts diff --git a/lib/query-keys.test.ts b/lib/query-keys.test.ts new file mode 100644 index 0000000..e2a6c44 --- /dev/null +++ b/lib/query-keys.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest'; +import { QueryClient } from '@tanstack/react-query'; +import { queryKeys, invalidateStreamMutation } from './query-keys'; + +describe('queryKeys (#431)', () => { + it('produces stable structured stream keys', () => { + expect(queryKeys.streams.detail('CABC')).toEqual(['stream', 'CABC']); + expect(queryKeys.streams.info('CABC')).toEqual(['stream', 'CABC', 'info']); + expect(queryKeys.streams.withdrawable('CABC')).toEqual(['stream', 'CABC', 'withdrawable']); + expect(queryKeys.streams.lists()).toEqual(['stream', 'list']); + }); + + it('detail() is a prefix of info()/withdrawable() so one invalidate covers both', () => { + const detail = queryKeys.streams.detail('CABC'); + expect(queryKeys.streams.info('CABC').slice(0, detail.length)).toEqual([...detail]); + expect(queryKeys.streams.withdrawable('CABC').slice(0, detail.length)).toEqual([...detail]); + }); +}); + +describe('invalidateStreamMutation (#431)', () => { + it('invalidates only the touched trees, never the whole cache', async () => { + const qc = new QueryClient(); + const spy = vi.spyOn(qc, 'invalidateQueries').mockResolvedValue(undefined); + + await invalidateStreamMutation(qc, 'CABC'); + + const keys = spy.mock.calls.map(c => c[0]?.queryKey); + expect(keys).toContainEqual(['stream', 'CABC']); + expect(keys).toContainEqual(['stream', 'list']); + expect(keys).toContainEqual(['dashboard']); + expect(keys).toContainEqual(['transactions']); + expect(keys).toContainEqual(['wallet']); + // The regression: a no-filter invalidateQueries() call. + for (const call of spy.mock.calls) { + expect(call[0]?.queryKey).toBeDefined(); + } + }); +}); diff --git a/lib/query-keys.ts b/lib/query-keys.ts new file mode 100644 index 0000000..24540be --- /dev/null +++ b/lib/query-keys.ts @@ -0,0 +1,57 @@ +import type { QueryClient } from '@tanstack/react-query'; + +/** + * Central, structured React Query keys (#431). + * + * Stream reads were previously unkeyed, so any stream action fell back to a + * blanket `queryClient.invalidateQueries()` that marked every query in the app + * stale and refetched the active ones (wallet balance, allowance, the full + * /streams list, /dashboard, /transactions). Structured keys make a targeted + * `invalidateQueries({ queryKey: queryKeys.streams.detail(address) })` + * possible instead — new stream queries should adopt these. + */ +export const queryKeys = { + wallet: { + all: ['wallet'] as const, + balance: (address: string) => ['wallet', 'balance', address] as const, + }, + transactions: { + all: ['transactions'] as const, + list: (address: string | null) => ['transactions', address] as const, + }, + streams: { + all: ['stream'] as const, + lists: () => ['stream', 'list'] as const, + detail: (address: string) => ['stream', address] as const, + info: (address: string) => ['stream', address, 'info'] as const, + withdrawable: (address: string) => ['stream', address, 'withdrawable'] as const, + }, + dashboard: { + all: ['dashboard'] as const, + }, +} as const; + +/** + * Invalidate exactly the queries a single-stream mutation (pause / resume / + * cancel / top-up / clawback / withdraw) can affect: + * + * - that stream's own reads (`detail` is a prefix of `info` / `withdrawable`), + * - the streams list and dashboard aggregate that include it, + * - the transactions list, which gains a row, + * - the wallet balance, for the actions that move tokens. + * + * Replaces the unfiltered `queryClient.invalidateQueries()` in StreamActions + * and WithdrawButton (#431). + */ +export async function invalidateStreamMutation( + qc: QueryClient, + streamAddress: string, +): Promise { + await Promise.all([ + qc.invalidateQueries({ queryKey: queryKeys.streams.detail(streamAddress) }), + qc.invalidateQueries({ queryKey: queryKeys.streams.lists() }), + qc.invalidateQueries({ queryKey: queryKeys.dashboard.all }), + qc.invalidateQueries({ queryKey: queryKeys.transactions.all }), + qc.invalidateQueries({ queryKey: queryKeys.wallet.all }), + ]); +} From 00b7bd04e9590b9ed624de9ae96460639ba079f5 Mon Sep 17 00:00:00 2001 From: bade2brazy <205598258+bade2brazy@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:11:32 +0100 Subject: [PATCH 10/10] fix(query): stop stream actions from invalidating every query in the app `StreamActions.run()` and `WithdrawButton` called `queryClient.invalidateQueries()` with no filter after any action, refetching wallet balance, allowance, the full /streams list, /dashboard, and /transactions on every pause/resume/ cancel/top-up/clawback/withdraw. Both now use `invalidateStreamMutation`, and `refreshStreamData()` is narrowed to the stream/dashboard/transactions trees (flagged in #215 / #354). Closes #431 --- components/stream/StreamActions.tsx | 10 ++++++---- components/stream/WithdrawButton.tsx | 8 +++++--- lib/queryClient.ts | 10 +++++++++- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/components/stream/StreamActions.tsx b/components/stream/StreamActions.tsx index e350dfd..65691cf 100644 --- a/components/stream/StreamActions.tsx +++ b/components/stream/StreamActions.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/Input'; import * as streamLib from '@/lib/stream'; import { safeToStroops } from '@/lib/safe-operations'; import { queryClient } from '@/lib/queryClient'; +import { invalidateStreamMutation } from '@/lib/query-keys'; type StreamStatus = 'active' | 'paused' | 'ended' | 'cancelled'; @@ -56,10 +57,11 @@ export function StreamActions({ try { await fn(); if (!mounted.current) return; - // The stream's on-chain state just changed — invalidate any cached - // reads (e.g. a Profile Page's balance/status query) so they don't - // keep showing pre-action data (fixes #193). - await queryClient.invalidateQueries(); + // The stream's on-chain state just changed — invalidate only what this + // action touched (this stream's reads, the streams list / dashboard, + // the transactions list, the wallet balance) rather than every query in + // the app (fixes #193, narrowed per #431). + await invalidateStreamMutation(queryClient, streamAddress); onSuccess?.(); } catch (e) { if (!mounted.current) return; diff --git a/components/stream/WithdrawButton.tsx b/components/stream/WithdrawButton.tsx index 6d5797f..d2c3db9 100644 --- a/components/stream/WithdrawButton.tsx +++ b/components/stream/WithdrawButton.tsx @@ -8,6 +8,7 @@ import { useWallet } from '@/contexts/WalletContext'; import { withdraw } from '@/lib/stream'; import { CopyHashButton } from '@/components/ui/CopyHashButton'; import { queryClient } from '@/lib/queryClient'; +import { invalidateStreamMutation } from '@/lib/query-keys'; type Step = 'idle' | 'signing' | 'submitting' | 'done' | 'error'; @@ -50,9 +51,10 @@ export function WithdrawButton({ streamAddress, withdrawable, token, onSuccess } if (!mounted.current) return; setTxHash(hash); setStep('done'); - // Withdrawn balance just changed on-chain — invalidate cached reads - // (e.g. a Profile Page's balance query) so they refetch (fixes #193). - await queryClient.invalidateQueries(); + // Withdrawn balance just changed on-chain — invalidate only the stream, + // list/dashboard, transactions, and wallet-balance trees rather than + // every query in the app (fixes #193, narrowed per #431). + await invalidateStreamMutation(queryClient, streamAddress); onSuccess?.(); } catch (e) { // Always clear the loading state, even if the RPC provider timed out diff --git a/lib/queryClient.ts b/lib/queryClient.ts index 4e3a63d..080db99 100644 --- a/lib/queryClient.ts +++ b/lib/queryClient.ts @@ -1,4 +1,5 @@ import { QueryClient } from '@tanstack/react-query'; +import { queryKeys } from './query-keys'; export function makeQueryClient(): QueryClient { return new QueryClient({ @@ -36,7 +37,14 @@ export const queryClient: QueryClient = getQueryClient(); export async function refreshStreamData(): Promise { try { - await getQueryClient().invalidateQueries({ refetchType: 'active' }); + const qc = getQueryClient(); + // Only the stream-related trees, not every active query in the app + // (flagged in #215 / #354, structured for #431). + await Promise.all([ + qc.invalidateQueries({ queryKey: queryKeys.streams.all, refetchType: 'active' }), + qc.invalidateQueries({ queryKey: queryKeys.dashboard.all, refetchType: 'active' }), + qc.invalidateQueries({ queryKey: queryKeys.transactions.all, refetchType: 'active' }), + ]); } catch (error) { console.warn('Failed to refresh stream data after a transaction.', error); }