diff --git a/__tests__/components/batch-create-page.test.tsx b/__tests__/components/batch-create-page.test.tsx index 664f29d..2b89bbd 100644 --- a/__tests__/components/batch-create-page.test.tsx +++ b/__tests__/components/batch-create-page.test.tsx @@ -234,6 +234,25 @@ describe('BatchCreatePage — page-level composition', () => { }) }) + it('shows a checksum-specific error for a near-miss recipient address (issue #143)', async () => { + // Same length/prefix/alphabet as a real G-address, but not + // checksum-valid — e.g. a single mistyped character. + const nearMissAddress = 'G' + 'A'.repeat(55) + const checksumCsv = [ + 'recipient,amount,start_time,end_time', + `${nearMissAddress},100,${START_TS},${END_TS}`, + ].join('\n') + + render() + await uploadCsv(checksumCsv) + + await waitFor(() => { + expect( + screen.getByText('Invalid recipient address checksum — check for a mistyped character'), + ).toBeInTheDocument() + }) + }) + it('shows an upload error for non-CSV files', async () => { render() diff --git a/__tests__/components/create-form.test.tsx b/__tests__/components/create-form.test.tsx index c07363e..569a33a 100644 --- a/__tests__/components/create-form.test.tsx +++ b/__tests__/components/create-form.test.tsx @@ -32,11 +32,25 @@ vi.mock('@/hooks/use-form-draft', () => ({ })) // ─── Stellar / contract lib ─────────────────────────────────────────────────── -vi.mock('@/lib/stellar', () => ({ - getAllTokens: () => [{ address: 'CNATIVE', symbol: 'XLM', decimals: 7 }], - saveCustomToken: vi.fn(), - checkAccountInfo: vi.fn().mockResolvedValue({ exists: true, funded: true, transactionCount: 1 }), -})) +vi.mock('@/lib/stellar', async () => { + const { StrKey } = await import('@stellar/stellar-sdk') + const ED25519_SHAPE_RE = /^G[A-Z2-7]{55}$/ + return { + getAllTokens: () => [{ address: 'CNATIVE', symbol: 'XLM', decimals: 7 }], + saveCustomToken: vi.fn(), + checkAccountInfo: vi + .fn() + .mockResolvedValue({ exists: true, funded: true, transactionCount: 1 }), + // Mirrors the real lib/stellar.ts implementation (issue #143) so tests + // exercise the same valid/format/checksum classification. + validateStellarAddress: (address: string) => { + const trimmed = address.trim() + if (StrKey.isValidEd25519PublicKey(trimmed)) return { valid: true } + if (ED25519_SHAPE_RE.test(trimmed)) return { valid: false, reason: 'checksum' } + return { valid: false, reason: 'format' } + }, + } +}) vi.mock('@/lib/contract', () => ({ getTokenMetadata: vi.fn(), @@ -133,6 +147,27 @@ describe('CreateForm — validation', () => { // No amount error should be present expect(screen.queryByText('Enter a valid amount greater than 0')).not.toBeInTheDocument() }) + + it('shows a checksum-specific error for a near-miss recipient (issue #143)', async () => { + render() + + // Same length/prefix/alphabet as a real G-address, but not + // checksum-valid — e.g. a single mistyped character. + fireEvent.change(screen.getByPlaceholderText(/GABC/i), { + target: { value: 'G' + 'A'.repeat(55) }, + }) + fireEvent.change(screen.getByLabelText(/total amount/i), { + target: { value: '100' }, + }) + + submitForm() + + await waitFor(() => { + expect( + screen.getByText('Invalid checksum — double-check this address for a mistyped character'), + ).toBeInTheDocument() + }) + }) }) describe('cliff amount exceeds total amount', () => { diff --git a/__tests__/hooks/use-streams.test.ts b/__tests__/hooks/use-streams.test.ts index 0598839..d0452de 100644 --- a/__tests__/hooks/use-streams.test.ts +++ b/__tests__/hooks/use-streams.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, waitFor, act } from '@testing-library/react' import type { StreamData } from '@/types/stream' // ── Mocks ────────────────────────────────────────────────────────────────── @@ -83,6 +83,76 @@ describe('useStreams', () => { }) }) +// Issue #142: polling intervals must be created exactly once per mount and +// fully cleared on unmount — no orphaned `setInterval` continuing to fire +// (and make RPC calls) after the component is gone. +describe('useStreams polling lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(fetchStreamsForAddress).mockResolvedValue([mockStream]) + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('polls on the configured interval while mounted', async () => { + const { result } = renderHook(() => useStreams({ enablePolling: true, pollInterval: 1000 })) + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + await waitFor(() => expect(result.current.loading).toBe(false)) + + const callsAfterMount = vi.mocked(fetchStreamsForAddress).mock.calls.length + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000) + }) + + // 3 ticks at 1000ms each. + expect(vi.mocked(fetchStreamsForAddress).mock.calls.length).toBe(callsAfterMount + 3) + }) + + it('clears the polling interval on unmount — no orphaned fetches afterward', async () => { + const { result, unmount } = renderHook(() => + useStreams({ enablePolling: true, pollInterval: 1000 }), + ) + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + await waitFor(() => expect(result.current.loading).toBe(false)) + + unmount() + const callsAtUnmount = vi.mocked(fetchStreamsForAddress).mock.calls.length + + await act(async () => { + await vi.advanceTimersByTimeAsync(10000) + }) + + // No further polling ticks should have fired after unmount. + expect(vi.mocked(fetchStreamsForAddress).mock.calls.length).toBe(callsAtUnmount) + }) + + it('clears exactly one interval per mount on unmount (no leaked timer handle)', async () => { + const clearIntervalSpy = vi.spyOn(global, 'clearInterval') + + const { unmount } = renderHook(() => useStreams({ enablePolling: true, pollInterval: 1000 })) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + unmount() + + // The polling-interval cleanup should run exactly once for this mount. + expect(clearIntervalSpy).toHaveBeenCalledTimes(1) + + clearIntervalSpy.mockRestore() + }) +}) + describe('invalidateStreams', () => { it('triggers refetch in subscribed hooks', async () => { vi.mocked(fetchStreamsForAddress).mockResolvedValue([mockStream]) diff --git a/__tests__/lib/stellar-address-validation.test.ts b/__tests__/lib/stellar-address-validation.test.ts new file mode 100644 index 0000000..4d16578 --- /dev/null +++ b/__tests__/lib/stellar-address-validation.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { validateStellarAddress } from '@/lib/stellar' + +// A real, checksum-valid ed25519 public key. +const VALID = 'GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI' + +describe('validateStellarAddress', () => { + it('accepts a valid ed25519 public key', () => { + expect(validateStellarAddress(VALID)).toEqual({ valid: true }) + }) + + it('trims surrounding whitespace before validating', () => { + expect(validateStellarAddress(` ${VALID} `)).toEqual({ valid: true }) + }) + + it('flags a near-miss (right shape, mistyped character) as a checksum error', () => { + // Same length (56) and 'G' + base32 shape as a real key, but not a + // real checksum-valid key — this is the "one mistyped character" + // scenario issue #143 is about. + const nearMiss = 'G' + 'A'.repeat(55) + expect(nearMiss).toHaveLength(56) + expect(validateStellarAddress(nearMiss)).toEqual({ valid: false, reason: 'checksum' }) + }) + + it('flags a single-character mutation of a valid address as a checksum error', () => { + const mutated = VALID.slice(0, -1) + (VALID.at(-1) === 'A' ? 'B' : 'A') + expect(validateStellarAddress(mutated)).toEqual({ valid: false, reason: 'checksum' }) + }) + + it('flags an address that is too short as a format error', () => { + expect(validateStellarAddress(VALID.slice(0, -1))).toEqual({ valid: false, reason: 'format' }) + }) + + it('flags an address that is too long as a format error', () => { + expect(validateStellarAddress(VALID + 'A')).toEqual({ valid: false, reason: 'format' }) + }) + + it('flags an address with the wrong prefix as a format error', () => { + // Contract addresses start with 'C', not 'G'. + expect( + validateStellarAddress('CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'), + ).toEqual({ valid: false, reason: 'format' }) + }) + + it('flags an address with invalid base32 characters as a format error', () => { + // '0', '1', '8', '9' are not in the base32 alphabet used by StrKey. + expect(validateStellarAddress('G' + '0'.repeat(55))).toEqual({ + valid: false, + reason: 'format', + }) + }) + + it('flags an empty string as a format error', () => { + expect(validateStellarAddress('')).toEqual({ valid: false, reason: 'format' }) + }) + + it('flags an arbitrary non-address string as a format error', () => { + expect(validateStellarAddress('not-a-stellar-address')).toEqual({ + valid: false, + reason: 'format', + }) + }) +}) diff --git a/app/app/create/batch/page.tsx b/app/app/create/batch/page.tsx index 04137e2..9e32eed 100644 --- a/app/app/create/batch/page.tsx +++ b/app/app/create/batch/page.tsx @@ -20,7 +20,7 @@ import { useNetwork } from '@/components/providers/network-provider' import { parseTokenAmount, formatDateTime } from '@/lib/stream-utils' import { parseCsvBatch, parseDuration, resolveCliffTime, type CsvBatchRow } from '@/lib/csv-parser' import { downloadCSV } from '@/lib/export' -import { isValidStellarAddress } from '@/lib/stellar' +import { validateStellarAddress } from '@/lib/stellar' import { batchCreateCopy } from '@/lib/copy/batch-create' import type { TokenInfo } from '@/types/stream' @@ -113,8 +113,17 @@ export default function BatchCreatePage() { : null const errors: string[] = [] - if (!recipient || !isValidStellarAddress(recipient)) { + if (!recipient) { errors.push(batchCreateCopy.rowValidation.invalidRecipient) + } else { + const addressCheck = validateStellarAddress(recipient) + if (!addressCheck.valid) { + errors.push( + addressCheck.reason === 'checksum' + ? batchCreateCopy.rowValidation.invalidRecipientChecksum + : batchCreateCopy.rowValidation.invalidRecipient, + ) + } } if (!amount || parseDecimalAmount(amount, selectedTokenInfo.decimals) === null) { errors.push(batchCreateCopy.rowValidation.invalidAmount) diff --git a/app/app/create/create-form.tsx b/app/app/create/create-form.tsx index 42f1b61..4381f49 100644 --- a/app/app/create/create-form.tsx +++ b/app/app/create/create-form.tsx @@ -29,7 +29,7 @@ import { } from '@/components/ui/select' import { useContract } from '@/hooks/use-contract' import { useWallet } from '@/hooks/use-wallet' -import { getAllTokens, saveCustomToken, checkAccountInfo } from '@/lib/stellar' +import { getAllTokens, saveCustomToken, checkAccountInfo, validateStellarAddress } from '@/lib/stellar' import { getTokenMetadata, getTokenBalance } from '@/lib/contract' import { parseTokenAmount, formatTokenAmount } from '@/lib/stream-utils' import { StreamPreview } from '@/components/streams/stream-preview' @@ -442,14 +442,23 @@ export function CreateForm() { newErrors.recipient = copy.errors.federationResolving } else if (federationStatus === 'error') { newErrors.recipient = federationError ?? copy.errors.federationLookupFailed - } else if ( - // Issue #28: use StrKey for proper Stellar address validation - !form.recipient.trim() || - !StrKey.isValidEd25519PublicKey(form.recipient.trim()) - ) { + } else if (!form.recipient.trim()) { newErrors.recipient = isFederationAddress(recipientInput.trim()) ? copy.errors.federationDidNotResolve : copy.errors.invalidAddressFormat + } else { + // Issue #28/#143: use StrKey for proper Stellar address validation, + // and surface a checksum-specific message for near-miss typos. + const addressCheck = validateStellarAddress(form.recipient.trim()) + if (!addressCheck.valid) { + if (isFederationAddress(recipientInput.trim())) { + newErrors.recipient = copy.errors.federationDidNotResolve + } else if (addressCheck.reason === 'checksum') { + newErrors.recipient = copy.errors.invalidAddressChecksum + } else { + newErrors.recipient = copy.errors.invalidAddressFormat + } + } } // Issue #103: require warning acknowledgment for unfunded accounts if (recipientAccountInfo && !recipientAccountInfo.exists && !recipientWarningAcknowledged) { diff --git a/hooks/use-streams.ts b/hooks/use-streams.ts index af6fb6e..229bb25 100644 --- a/hooks/use-streams.ts +++ b/hooks/use-streams.ts @@ -198,6 +198,17 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams { fetch() }, [fetch]) + // Issue #142: abort whatever request is still in flight when this hook + // instance unmounts (or `fetch` is about to be replaced because address/ + // network changed), rather than only aborting on the *next* fetch call. + // Without this, a slow response can land after unmount and still call + // setState on a component that's gone. + useEffect(() => { + return () => { + abortCtrlRef.current?.abort() + } + }, [fetch]) + // Re-fetch when a write invalidates the cache useInvalidation(fetch) @@ -358,6 +369,15 @@ export function useStream(id: string): { fetch() }, [fetch]) + // Issue #142: abort any in-flight request on unmount/id change instead of + // only on the next fetch call, so a late response can't update state after + // this hook instance is gone. + useEffect(() => { + return () => { + abortCtrlRef.current?.abort() + } + }, [fetch]) + useInvalidation(fetch) return { stream, loading, refetch: fetch } diff --git a/lib/copy/batch-create.ts b/lib/copy/batch-create.ts index ecb19be..9f6d7ef 100644 --- a/lib/copy/batch-create.ts +++ b/lib/copy/batch-create.ts @@ -71,6 +71,8 @@ export const batchCreateCopy = { rowValidation: { invalidRecipient: "Invalid recipient address", + invalidRecipientChecksum: + "Invalid recipient address checksum — check for a mistyped character", invalidAmount: "Invalid amount", invalidStartTime: "Invalid start_time", invalidEndTime: "Invalid end_time", diff --git a/lib/copy/create-form.ts b/lib/copy/create-form.ts index 8cb9b68..1d4e204 100644 --- a/lib/copy/create-form.ts +++ b/lib/copy/create-form.ts @@ -154,6 +154,8 @@ export const createFormCopy = { federationLookupFailed: 'Federation lookup failed', federationDidNotResolve: 'Federation address did not resolve to a valid Stellar account', invalidAddressFormat: 'Invalid Stellar address format', + invalidAddressChecksum: + 'Invalid checksum — double-check this address for a mistyped character', acknowledgeRecipientWarning: 'Please acknowledge the warning about this recipient address', invalidAmount: 'Enter a valid amount greater than 0', amountExceedsBalance: (balance: string, symbol: string) => diff --git a/lib/stellar.ts b/lib/stellar.ts index 6e6be22..9c5c404 100644 --- a/lib/stellar.ts +++ b/lib/stellar.ts @@ -37,6 +37,42 @@ export function isValidStellarAddress(address: string): boolean { return StrKey.isValidEd25519PublicKey(address.trim()) } +/** + * Matches the shape of a Stellar Ed25519 public key (`G` + 55 base32 + * characters) without verifying the embedded CRC-16 checksum. Used by + * {@link validateStellarAddress} to tell a near-miss (right shape, wrong + * checksum — e.g. a single mistyped character) apart from a value that + * isn't even trying to be a G-address. + */ +const ED25519_SHAPE_RE = /^G[A-Z2-7]{55}$/ + +/** + * Result of {@link validateStellarAddress}. + * + * - `reason: 'checksum'` — the value has the correct length, prefix, and + * base32 alphabet of a Stellar Ed25519 address, but the CRC-16 checksum + * encoded in its last two bytes doesn't match (a classic single-typo + * near-miss). + * - `reason: 'format'` — the value doesn't even match the basic shape of a + * Stellar G-address (wrong prefix, wrong length, or invalid characters). + */ +export type StellarAddressValidation = + | { valid: true } + | { valid: false; reason: 'checksum' | 'format' } + +/** + * Validates `address` as a Stellar Ed25519 public key and, when invalid, + * distinguishes a bad checksum (right shape, mistyped character) from a + * value that is the wrong shape entirely. This lets callers show a more + * actionable error than a single generic "invalid address" message. + */ +export function validateStellarAddress(address: string): StellarAddressValidation { + const trimmed = address.trim() + if (StrKey.isValidEd25519PublicKey(trimmed)) return { valid: true } + if (ED25519_SHAPE_RE.test(trimmed)) return { valid: false, reason: 'checksum' } + return { valid: false, reason: 'format' } +} + /** * Static per-network configuration map (RPC endpoints, Horizon URLs, network * passphrases, and built-in known tokens). Does **not** include the streaming