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