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
77 changes: 77 additions & 0 deletions src/app/(app)/dashboard/setup/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,83 @@ describe('getEnsOwnerAction', () => {
})
})

describe('resolveFounderIdentityAction', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_CHAIN_ID = '11155111'
process.env.ALCHEMY_API_KEY = 'https://example-rpc'
})

it('returns direct address payload for valid 0x address input', async () => {
const { resolveFounderIdentityAction } = await import('./actions.js')
const result = await resolveFounderIdentityAction(
'0x1234567890abcdef1234567890abcdef12345678'
)

expect(result).toEqual({
input: '0x1234567890abcdef1234567890abcdef12345678',
source: 'address',
resolvedAddress: '0x1234567890abcdef1234567890abcdef12345678',
ensName: null,
error: null,
})
expect(mockGetOwner).not.toHaveBeenCalled()
})

it('resolves owned ENS input to owner address', async () => {
mockGetOwner.mockResolvedValue({
owner: '0x00000000000000000000000000000000000000aa',
})

const { resolveFounderIdentityAction } = await import('./actions.js')
const result = await resolveFounderIdentityAction('founder')

expect(result).toEqual({
input: 'founder',
source: 'ens',
resolvedAddress: '0x00000000000000000000000000000000000000aa',
ensName: 'founder.eth',
error: null,
})
})

it('returns actionable message for invalid ENS format', async () => {
const { resolveFounderIdentityAction } = await import('./actions.js')
const result = await resolveFounderIdentityAction('ab')

expect(result.resolvedAddress).toBeNull()
expect(result.error).toBe(
'Enter a valid ENS name (like yourname.eth) or a 0x address.'
)
})

it('returns actionable message for unregistered ENS names', async () => {
mockGetOwner.mockResolvedValue({
owner: '0x0000000000000000000000000000000000000000',
})

const { resolveFounderIdentityAction } = await import('./actions.js')
const result = await resolveFounderIdentityAction('available-name')

expect(result.resolvedAddress).toBeNull()
expect(result.error).toContain(
'available-name.eth is not registered yet. Enter a registered ENS name or a 0x address.'
)
})

it('returns actionable message when ENS resolution fails', async () => {
mockGetOwner.mockRejectedValue(new Error('rpc down'))

const { resolveFounderIdentityAction } = await import('./actions.js')
const result = await resolveFounderIdentityAction('founder')

expect(result.resolvedAddress).toBeNull()
expect(result.error).toBe(
'Unable to resolve ENS right now. Try again or use a 0x address.'
)
})
})

describe('finalizeEnsRegistrationAction', () => {
const safeAddress = '0x00000000000000000000000000000000000000aa' as const
const existingRegistrationTxHash =
Expand Down
73 changes: 73 additions & 0 deletions src/app/(app)/dashboard/setup/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,79 @@ export async function getEnsOwnerAction(name: string) {
return { name: fullName, owner }
}

export interface FounderIdentityResolution {
input: string
source: 'address' | 'ens'
resolvedAddress: string | null
ensName: string | null
error: string | null
}

export async function resolveFounderIdentityAction(
input: string
): Promise<FounderIdentityResolution> {
const trimmedInput = input.trim()

if (!trimmedInput) {
return {
input: trimmedInput,
source: 'ens',
resolvedAddress: null,
ensName: null,
error: 'Enter an ENS name or Ethereum address.',
}
}

if (isAddress(trimmedInput)) {
return {
input: trimmedInput,
source: 'address',
resolvedAddress: trimmedInput,
ensName: null,
error: null,
}
}

try {
const { fullName } = normalizeEnsInput(trimmedInput)
const result = await getOwner(ensPublicClient, { name: fullName })
const owner =
result?.owner && result.owner !== ZERO_ADDRESS ? result.owner : null

if (!owner) {
return {
input: trimmedInput,
source: 'ens',
resolvedAddress: null,
ensName: fullName,
error: `${fullName} is not registered yet. Enter a registered ENS name or a 0x address.`,
}
}

return {
input: trimmedInput,
source: 'ens',
resolvedAddress: owner,
ensName: fullName,
error: null,
}
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown ENS resolution error'
const isFormatError = message.includes('Invalid ENS name')

return {
input: trimmedInput,
source: 'ens',
resolvedAddress: null,
ensName: null,
error: isFormatError
? 'Enter a valid ENS name (like yourname.eth) or a 0x address.'
: 'Unable to resolve ENS right now. Try again or use a 0x address.',
}
}
}

type PendingRecord = PendingRegistration
export type EnsRegistrationRecord = PendingRegistration

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

import {
clearFounderValidationTimer,
createIdleFounderValidation,
scheduleFounderValidation,
shouldApplyFounderResolution,
} from './founder-validation-utils'

describe('founder-validation-utils', () => {
beforeEach(() => {
vi.useFakeTimers()
})

it('debounces founder validation and only fires latest input once', () => {
const timers: Record<string, ReturnType<typeof setTimeout> | undefined> = {}
const onDebouncedValidate = vi.fn()

scheduleFounderValidation({
founderId: 'f-1',
input: 'p',
delayMs: 350,
timers,
onDebouncedValidate,
})
scheduleFounderValidation({
founderId: 'f-1',
input: 'pe',
delayMs: 350,
timers,
onDebouncedValidate,
})
scheduleFounderValidation({
founderId: 'f-1',
input: 'peter.eth',
delayMs: 350,
timers,
onDebouncedValidate,
})

vi.advanceTimersByTime(349)
expect(onDebouncedValidate).not.toHaveBeenCalled()

vi.advanceTimersByTime(1)
expect(onDebouncedValidate).toHaveBeenCalledTimes(1)
expect(onDebouncedValidate).toHaveBeenCalledWith('f-1', 'peter.eth')
})

it('applies resolution only when version and input still match', () => {
const shouldApplyCurrent = shouldApplyFounderResolution({
requestVersion: 3,
latestVersion: 3,
requestInput: 'peter.eth',
latestInput: 'peter.eth',
})
expect(shouldApplyCurrent).toBe(true)

const shouldIgnoreStaleVersion = shouldApplyFounderResolution({
requestVersion: 2,
latestVersion: 3,
requestInput: 'peter.eth',
latestInput: 'peter.eth',
})
expect(shouldIgnoreStaleVersion).toBe(false)

const shouldIgnoreStaleInput = shouldApplyFounderResolution({
requestVersion: 3,
latestVersion: 3,
requestInput: 'pet',
latestInput: 'peter.eth',
})
expect(shouldIgnoreStaleInput).toBe(false)
})

it('clears timer for removed founder row', () => {
const timers: Record<string, ReturnType<typeof setTimeout> | undefined> = {}
const onDebouncedValidate = vi.fn()

scheduleFounderValidation({
founderId: 'f-2',
input: 'remove.me.eth',
delayMs: 350,
timers,
onDebouncedValidate,
})

clearFounderValidationTimer('f-2', timers)
vi.advanceTimersByTime(400)

expect(onDebouncedValidate).not.toHaveBeenCalled()
expect(timers['f-2']).toBeUndefined()
})

it('creates idle validation state when founder input is cleared', () => {
expect(createIdleFounderValidation('')).toEqual({
status: 'idle',
input: '',
resolvedAddress: null,
ensName: null,
source: null,
error: null,
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
export type FounderValidationStatus = 'idle' | 'validating' | 'valid' | 'invalid'

export interface FounderValidationState {
status: FounderValidationStatus
input: string
resolvedAddress: string | null
ensName: string | null
source: 'address' | 'ens' | null
error: string | null
}

export function createIdleFounderValidation(input = ''): FounderValidationState {
return {
status: 'idle',
input,
resolvedAddress: null,
ensName: null,
source: null,
error: null,
}
}

interface ScheduleFounderValidationParams {
founderId: string
input: string
delayMs: number
timers: Record<string, ReturnType<typeof setTimeout> | undefined>
onDebouncedValidate: (founderId: string, input: string) => void
setTimeoutFn?: typeof setTimeout
clearTimeoutFn?: typeof clearTimeout
}

export function scheduleFounderValidation({
founderId,
input,
delayMs,
timers,
onDebouncedValidate,
setTimeoutFn = setTimeout,
clearTimeoutFn = clearTimeout,
}: ScheduleFounderValidationParams) {
const existingTimer = timers[founderId]
if (existingTimer) {
clearTimeoutFn(existingTimer)
}

timers[founderId] = setTimeoutFn(() => {
onDebouncedValidate(founderId, input)
}, delayMs)
}

export function clearFounderValidationTimer(
founderId: string,
timers: Record<string, ReturnType<typeof setTimeout> | undefined>,
clearTimeoutFn: typeof clearTimeout = clearTimeout
) {
const existingTimer = timers[founderId]
if (existingTimer) {
clearTimeoutFn(existingTimer)
delete timers[founderId]
}
}

interface ShouldApplyFounderResolutionParams {
requestVersion: number
latestVersion: number
requestInput: string
latestInput: string
}

export function shouldApplyFounderResolution({
requestVersion,
latestVersion,
requestInput,
latestInput,
}: ShouldApplyFounderResolutionParams) {
return requestVersion === latestVersion && requestInput === latestInput.trim()
}
Loading
Loading