Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions components/TokenSelector.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -55,6 +55,9 @@ export function TokenSelector({
const [status, setStatus] = useState<ResolveStatus>('idle');
const [token, setToken] = useState<TokenMeta | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(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<Array<'mainnet' | 'testnet'>>([]);

// Ref to the AbortController for the current in-flight metadata lookup.
// Replaced on every new lookup so the previous one can be cancelled.
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -177,7 +187,10 @@ export function TokenSelector({

{!showValidationError && status === 'unknown' && (
<p className="text-xs text-gray-400 mt-1" role="status" aria-live="polite">
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.'}
</p>
)}

Expand Down
10 changes: 6 additions & 4 deletions components/stream/StreamActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions components/stream/WithdrawButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions contexts/WalletContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
clearWalletSession,
loadWalletSession,
saveWalletSession,
touchWalletSession,
} from '@/lib/wallet-storage';
import toast from 'react-hot-toast';

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down
47 changes: 46 additions & 1 deletion lib/env.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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',
'NEXT_PUBLIC_NETWORK_PASSPHRASE',
'NEXT_PUBLIC_FACTORY_CONTRACT_ID',
'NEXT_PUBLIC_GOVERNOR_CONTRACT_ID',
'NEXT_PUBLIC_HORIZON_URL',
'NEXT_PUBLIC_SOROBAN_FEE_MULTIPLIER',
] as const;

const original: Record<string, string | undefined> = {};
Expand Down Expand Up @@ -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();
});
});
32 changes: 30 additions & 2 deletions lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,47 @@ 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.
*
* 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;
}
38 changes: 38 additions & 0 deletions lib/query-keys.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
57 changes: 57 additions & 0 deletions lib/query-keys.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 }),
]);
}
10 changes: 9 additions & 1 deletion lib/queryClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { QueryClient } from '@tanstack/react-query';
import { queryKeys } from './query-keys';

export function makeQueryClient(): QueryClient {
return new QueryClient({
Expand Down Expand Up @@ -36,7 +37,14 @@ export const queryClient: QueryClient = getQueryClient();

export async function refreshStreamData(): Promise<void> {
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);
}
Expand Down
Loading
Loading