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
19 changes: 19 additions & 0 deletions __tests__/components/batch-create-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<BatchCreatePage />)
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(<BatchCreatePage />)

Expand Down
45 changes: 40 additions & 5 deletions __tests__/components/create-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(<CreateForm />)

// 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', () => {
Expand Down
74 changes: 72 additions & 2 deletions __tests__/hooks/use-streams.test.ts
Original file line number Diff line number Diff line change
@@ -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 ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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])
Expand Down
63 changes: 63 additions & 0 deletions __tests__/lib/stellar-address-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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',
})
})
})
13 changes: 11 additions & 2 deletions app/app/create/batch/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
Expand Down
21 changes: 15 additions & 6 deletions app/app/create/create-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions hooks/use-streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 }
Expand Down
2 changes: 2 additions & 0 deletions lib/copy/batch-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions lib/copy/create-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@
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) =>
Expand All @@ -167,7 +169,7 @@
'Could not fetch token metadata. Verify this is a valid SEP-41 token contract.',
tokenContractQueryFailed: 'Failed to query token contract',
},
}

Check failure on line 172 in lib/copy/create-form.ts

View workflow job for this annotation

GitHub Actions / TypeScript Typecheck

Declaration or statement expected.

// CreateConfirmation dialog strings — issue #956
export const createConfirmationCopy = {
Expand Down
36 changes: 36 additions & 0 deletions lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading