Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"calendar-link": "^2.8.0",
"dequal": "2.0.3",
"dns-packet": "^5.4.0",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"glob": "^8.0.3",
"graphql-request": "6.1.0",
"i18next": "^21.9.1",
Expand All @@ -87,6 +87,7 @@
"immer": "^9.0.15",
"iso-639-1": "^2.1.15",
"markdown-to-jsx": "^7.7.3",
"next": "14.2.30",
"next": "13.5.8",
"node-fetch": "^3.3.2",
"node-forge": "1.3.1",
Expand Down Expand Up @@ -122,7 +123,7 @@
"@next/bundle-analyzer": "^13.4.19",
"@nomicfoundation/hardhat-toolbox-viem": "^3.0.0",
"@nomicfoundation/hardhat-viem": "2.0.3",
"@openzeppelin/contracts": "^4.7.3",
"@openzeppelin/contracts": "4.9.6",
"@playwright/test": "1.50.1",
"@tenkeylabs/dappwright": "^2.11.2",
"@testing-library/dom": "^10.4.0",
Expand Down Expand Up @@ -185,7 +186,7 @@
"vitest-canvas-mock": "^0.3.3",
"wait-on": "^8.0.2",
"wrangler": "^3.26.0",
"ws": "^8.16.0"
"ws": "^8.18.2"
},
"pnpm": {
"onlyBuiltDependencies": [
Expand Down
2 changes: 1 addition & 1 deletion public/locales/en/profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"warnings": {
"wrappedDNS": "DNS names can be reclaimed by the DNS owner at any time. Do not purchase DNS names.",
"offchain": "Offchain names do not currently appear in your 'Names' list. <a>Learn more</a>",
"homoglyph": "This name contains non-ASCII characters. There may be characters that look identical or very similar to other characters, which could be used to deceive readers. <a>Learn more about homoglyphs</a>"
"homoglyph": "This name uses non-ASCII characters (for example, accents or letters like ñ). That’s normal in many languages, such as Spanish. If this is your intended spelling, you can proceed. <a>Learn more about Unicode security guidance</a>"
}
},
"records": {
Expand Down
9 changes: 9 additions & 0 deletions public/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,14 @@
"wrongAccount": "Debes estar conectado como <strong>{{nameOrAddress}}</strong> para establecer el registro de verificación.",
"default": "No pudimos verificar tu cuenta. Por favor, regresa a Dentity e inténtalo de nuevo."
},
"homoglyph": {
"verificationErrorDialog": {
"title": "Falló la verificación",
"resolverRequired": "Se requiere un resolver válido para completar el flujo de verificación",
"ownerNotManager": "Debes estar conectado como Administrador de este nombre para establecer el registro de verificación. Puedes ver y actualizar el Administrador en la pestaña Propiedad.",
"wrongAccount": "Debes estar conectado como <strong>{{nameOrAddress}}</strong> para establecer el registro de verificación.",
"default": "No pudimos verificar tu cuenta. Por favor, regresa a Dentity e inténtalo de nuevo."
},
"homoglyph": {
"content": "Este nombre contiene caracteres válidos en español (á, é, í, ó, ú, ü, ñ). A diferencia de otros idiomas:",
"points": [
Expand All @@ -432,3 +440,4 @@
"title": "Caracteres del español"
}
}
}
2 changes: 1 addition & 1 deletion public/locales/es/profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"warnings": {
"wrappedDNS": "Los nombres DNS pueden ser reclamados por el Propietario DNS en cualquier momento. No compres nombres DNS.",
"offchain": "Los nombres offchain no aparecen actualmente en tu lista de 'Nombres'. <a>Más información</a>",
"homoglyph": "Este nombre contiene caracteres no ASCII. Podrían existir caracteres idénticos o muy similares a otros, lo cual puede usarse para engañar a los lectores. <a>Más información sobre homoglifos</a>"
"homoglyph": "Este nombre usa caracteres no ASCII (por ejemplo, acentos o letras como ñ). Es normal en idiomas como el español. Si esta es la ortografía que deseas, puedes continuar. <a>Más información sobre Unicode</a>"
}
},
"records": {
Expand Down
48 changes: 44 additions & 4 deletions src/components/pages/profile/[name]/tabs/ProfileTab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'
import { useAccount } from 'wagmi'
Expand Down Expand Up @@ -104,6 +104,35 @@ const ProfileTab = ({ nameDetails, name }: Props) => {
appendVerificationProps,
})

// Dismissible unicode info/warning banner persistence (per-normalisedName)
const normalisedNameKey = nameDetails?.normalisedName || name
const infoKey = `unicode-banner:${normalisedNameKey}:info`
const warnKey = `unicode-banner:${normalisedNameKey}:warn`
const [infoDismissed, setInfoDismissed] = useState(false)
const [warnDismissed, setWarnDismissed] = useState(false)

useEffect(() => {
if (typeof window === 'undefined') return
try {
setInfoDismissed(localStorage.getItem(infoKey) === '1')
setWarnDismissed(localStorage.getItem(warnKey) === '1')
} catch {}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [infoKey, warnKey])

const dismissInfo = () => {
setInfoDismissed(true)
try {
if (typeof window !== 'undefined') localStorage.setItem(infoKey, '1')
} catch {}
}
const dismissWarn = () => {
setWarnDismissed(true)
try {
if (typeof window !== 'undefined') localStorage.setItem(warnKey, '1')
} catch {}
}

return (
<DetailsWrapper>
<ProfileSnippet
Expand All @@ -128,15 +157,26 @@ const ProfileTab = ({ nameDetails, name }: Props) => {
/>
</Helper>
)}
{nameDetails.isNonASCII && (
<Helper alert="warning" alignment="horizontal">
{nameDetails.isNonASCII && nameDetails.isLatinOnly && !nameDetails.hasMixedScripts && !infoDismissed && (
<Helper alert="info" alignment="horizontal">
<Trans
i18nKey="tabs.profile.warnings.homoglyph"
ns="profile"
components={{
a: <Outlink href={getSupportLink('homoglyphs')} />,
a: <Outlink href="https://unicode.org/reports/tr36/" />,
}}
/>
<button aria-label={t('action.dismiss', { ns: 'common' })} onClick={dismissInfo} style={{ marginLeft: 'auto', background: 'none', border: 0, cursor: 'pointer' }}>
×
</button>
</Helper>
)}
{nameDetails.isNonASCII && nameDetails.hasMixedScripts && !warnDismissed && (
<Helper alert="warning" alignment="horizontal">
{t('tabs.profile.warnings.homoglyph')}
<button aria-label={t('action.dismiss', { ns: 'common' })} onClick={dismissWarn} style={{ marginLeft: 'auto', background: 'none', border: 0, cursor: 'pointer' }}>
×
</button>
</Helper>
)}
{isWrapped && !normalisedName.endsWith('.eth') && (
Expand Down
37 changes: 37 additions & 0 deletions src/hooks/useValidate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,40 @@ describe('useValidate', () => {
const { result } = renderHook(() => useValidate({ input: '%' }))
expect(result.current.isValid).toEqual(false)
})
it('should normalize decomposed to composed (NFC) identically', () => {
const composed = 'café' // e.g., "é" as single code point
const decomposed = 'cafe\u0301' // "e" + combining acute
const a = renderHook(() => useValidate({ input: composed }))
const b = renderHook(() => useValidate({ input: decomposed }))
expect(a.result.current.name).toEqual(b.result.current.name)
expect(a.result.current.beautifiedName).toEqual(b.result.current.beautifiedName)
})
it('should detect mixed scripts when combining Latin and Cyrillic', () => {
const { result } = renderHook(() => useValidate({ input: 'pаypal' })) // second "a" is Cyrillic
expect(result.current.isNonASCII).toEqual(true)
expect(result.current.hasMixedScripts).toEqual(true)
expect(result.current.isLatinOnly).toEqual(false)
})
it('should treat Latin with diacritics as info (nonASCII but LatinOnly)', () => {
const { result } = renderHook(() => useValidate({ input: 'jalapeño' }))
expect(result.current.isNonASCII).toEqual(true)
expect(result.current.isLatinOnly).toEqual(true)
expect(result.current.hasMixedScripts).toEqual(false)
})
it('should detect emoji including ZWJ sequences', () => {
const familyZWJ = '👨‍👩‍👧‍👦' // ZWJ sequence
const { result } = renderHook(() => useValidate({ input: `test${familyZWJ}` }))
expect(result.current.hasEmoji).toEqual(true)
})
it('should treat Spanish words with diacritics as LatinOnly info (not mixed)', () => {
const words = ['españa', 'camión', 'pingüino']
for (const w of words) {
const { result } = renderHook(() => useValidate({ input: w }))
expect(result.current.isNonASCII).toEqual(true)
expect(result.current.isLatinOnly).toEqual(true)
expect(result.current.hasMixedScripts).toEqual(false)
}
})
it('should cache the result for the same input', () => {
const { result, rerender } = renderHook(({ input }) => useValidate({ input }), {
initialProps: { input: 'test' },
Expand Down Expand Up @@ -53,6 +87,9 @@ describe('useValidate', () => {
is2LD: undefined,
isETH: undefined,
labelDataArray: [],
hasEmoji: undefined,
hasMixedScripts: undefined,
isLatinOnly: undefined,
})
})
describe('mocks', () => {
Expand Down
28 changes: 25 additions & 3 deletions src/hooks/useValidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export type ValidationResult = Prettify<
isNonASCII: boolean | undefined
labelCount: number
labelDataArray: ParsedInputResult['labelDataArray']
hasEmoji?: boolean
hasMixedScripts?: boolean
isLatinOnly?: boolean
}
>

Expand All @@ -23,16 +26,32 @@ const tryDecodeURIComponent = (input: string) => {

export const validate = (input: string) => {
const decodedInput = tryDecodeURIComponent(input)
const { normalised: name, ...parsedInput } = parseInput(decodedInput)
const isNonASCII = parsedInput.labelDataArray.some((dataItem) => dataItem.type !== 'ASCII')
const outputName = name || input
// Normalize to NFC to ensure consistent code point composition before parsing
const nfcInput = typeof decodedInput.normalize === 'function' ? decodedInput.normalize('NFC') : decodedInput
const { normalised: name, ...parsedInput } = parseInput(nfcInput)
// Ignore Common/Inherited/ASCII buckets when determining script mixing
const scriptOf = (t: unknown) => String(t || '')
const relevantScripts = parsedInput.labelDataArray
.map((d) => scriptOf((d as any).type))
.filter((t) => t && t !== 'ASCII' && t !== 'Common' && t !== 'Inherited')
const scriptSet = new Set(relevantScripts)
const hasMixedScripts = scriptSet.size > 1
const isLatinOnly = scriptSet.size <= 1 && (scriptSet.size === 0 || scriptSet.has('Latin'))
const isNonASCII = parsedInput.labelDataArray.some((dataItem) => scriptOf((dataItem as any).type) !== 'ASCII')
// Consider either explicit emoji metadata or presence of extended pictographic chars
const emojiRegex = /\p{Extended_Pictographic}/u
const hasEmoji = parsedInput.labelDataArray.some((d) => Boolean((d as any).emoji)) || emojiRegex.test(nfcInput)
const outputName = name || nfcInput

return {
...parsedInput,
name: outputName,
beautifiedName: tryBeautify(outputName),
isNonASCII,
labelCount: parsedInput.labelDataArray.length,
hasEmoji,
hasMixedScripts,
isLatinOnly,
}
}

Expand All @@ -47,6 +66,9 @@ const defaultData = Object.freeze({
is2LD: undefined,
isETH: undefined,
labelDataArray: [],
hasEmoji: undefined as boolean | undefined,
hasMixedScripts: undefined as boolean | undefined,
isLatinOnly: undefined as boolean | undefined,
})

type UseValidateParameters = {
Expand Down
27 changes: 27 additions & 0 deletions test/mock/makeMockUseValidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@ export const makeMockUseValidate = (type: MockUseValidateType): ValidationResult
tokens: [[101, 116, 104]],
type: 'ASCII',
output: [101, 116, 104],
emoji: undefined,
},
],
name: 'eth',
beautifiedName: 'eth',
isNonASCII: false,
labelCount: 1,
hasEmoji: false,
hasMixedScripts: false,
isLatinOnly: false,
}))
.with('dns', () => ({
type: 'label' as const,
Expand All @@ -49,12 +53,16 @@ export const makeMockUseValidate = (type: MockUseValidateType): ValidationResult
tokens: [[99, 111, 109]],
type: 'ASCII',
output: [99, 111, 109],
emoji: undefined,
},
],
name: 'com',
beautifiedName: 'com',
isNonASCII: false,
labelCount: 1,
hasEmoji: false,
hasMixedScripts: false,
isLatinOnly: false,
}))
.with('valid-2ld', () => ({
type: 'name' as const,
Expand All @@ -69,19 +77,24 @@ export const makeMockUseValidate = (type: MockUseValidateType): ValidationResult
tokens: [[110, 97, 109, 101]],
type: 'ASCII',
output: [110, 97, 109, 101],
emoji: undefined,
},
{
input: [101, 116, 104],
offset: 5,
tokens: [[101, 116, 104]],
type: 'ASCII',
output: [101, 116, 104],
emoji: undefined,
},
],
name: 'name.eth',
beautifiedName: 'name.eth',
isNonASCII: false,
labelCount: 2,
hasEmoji: false,
hasMixedScripts: false,
isLatinOnly: false,
}))
.with('valid-2ld:dns', () => ({
type: 'name' as const,
Expand All @@ -96,19 +109,24 @@ export const makeMockUseValidate = (type: MockUseValidateType): ValidationResult
tokens: [[110, 97, 109, 101]],
type: 'ASCII',
output: [110, 97, 109, 101],
emoji: undefined,
},
{
input: [99, 111, 109],
offset: 5,
tokens: [[99, 111, 109]],
type: 'ASCII',
output: [99, 111, 109],
emoji: undefined,
},
],
name: 'name.com',
beautifiedName: 'name.com',
isNonASCII: false,
labelCount: 2,
hasEmoji: false,
hasMixedScripts: false,
isLatinOnly: false,
}))
.with('invalid-2ld', () => ({
type: 'name' as const,
Expand Down Expand Up @@ -138,6 +156,9 @@ export const makeMockUseValidate = (type: MockUseValidateType): ValidationResult
beautifiedName: 'name❤️.eth',
isNonASCII: true,
labelCount: 2,
hasEmoji: true,
hasMixedScripts: true,
isLatinOnly: false,
}))
.with('valid-subname', () => ({
type: 'name' as const,
Expand All @@ -153,25 +174,31 @@ export const makeMockUseValidate = (type: MockUseValidateType): ValidationResult
tokens: [[115, +117, +98, +110, +97, +109, +101]],
type: 'ASCII',
output: [115, +117, +98, +110, +97, +109, +101],
emoji: undefined,
},
{
input: [110, 97, 109, 101],
offset: 8,
tokens: [[110, 97, 109, 101]],
type: 'ASCII',
output: [110, 97, 109, 101],
emoji: undefined,
},
{
input: [101, 116, 104],
offset: 13,
tokens: [[101, 116, 104]],
type: 'ASCII',
output: [101, 116, 104],
emoji: undefined,
},
],
beautifiedName: 'subname.name.eth',
isNonASCII: false,
labelCount: 3,
hasEmoji: false,
hasMixedScripts: false,
isLatinOnly: false,
}))
.exhaustive()
}