diff --git a/package.json b/package.json index f12d4955a..14244334a 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -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", @@ -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": [ diff --git a/public/locales/en/profile.json b/public/locales/en/profile.json index 59efa2ae1..65bf17fb5 100644 --- a/public/locales/en/profile.json +++ b/public/locales/en/profile.json @@ -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. Learn more", - "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. Learn more about homoglyphs" + "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. Learn more about Unicode security guidance" } }, "records": { diff --git a/public/locales/es/common.json b/public/locales/es/common.json index f996cee88..9be5bcde3 100644 --- a/public/locales/es/common.json +++ b/public/locales/es/common.json @@ -418,6 +418,14 @@ "wrongAccount": "Debes estar conectado como {{nameOrAddress}} 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 {{nameOrAddress}} 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": [ @@ -432,3 +440,4 @@ "title": "Caracteres del español" } } +} diff --git a/public/locales/es/profile.json b/public/locales/es/profile.json index 06026f2d9..6cb811206 100644 --- a/public/locales/es/profile.json +++ b/public/locales/es/profile.json @@ -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'. Más información", - "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. Más información sobre homoglifos" + "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. Más información sobre Unicode" } }, "records": { diff --git a/src/components/pages/profile/[name]/tabs/ProfileTab.tsx b/src/components/pages/profile/[name]/tabs/ProfileTab.tsx index b842ebd03..bd12af61f 100644 --- a/src/components/pages/profile/[name]/tabs/ProfileTab.tsx +++ b/src/components/pages/profile/[name]/tabs/ProfileTab.tsx @@ -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' @@ -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 ( { /> )} - {nameDetails.isNonASCII && ( - + {nameDetails.isNonASCII && nameDetails.isLatinOnly && !nameDetails.hasMixedScripts && !infoDismissed && ( + , + a: , }} /> + + + )} + {nameDetails.isNonASCII && nameDetails.hasMixedScripts && !warnDismissed && ( + + {t('tabs.profile.warnings.homoglyph')} + )} {isWrapped && !normalisedName.endsWith('.eth') && ( diff --git a/src/hooks/useValidate.test.ts b/src/hooks/useValidate.test.ts index 0f407edc3..6b00cc26d 100644 --- a/src/hooks/useValidate.test.ts +++ b/src/hooks/useValidate.test.ts @@ -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' }, @@ -53,6 +87,9 @@ describe('useValidate', () => { is2LD: undefined, isETH: undefined, labelDataArray: [], + hasEmoji: undefined, + hasMixedScripts: undefined, + isLatinOnly: undefined, }) }) describe('mocks', () => { diff --git a/src/hooks/useValidate.ts b/src/hooks/useValidate.ts index dbfc07db8..ae0cd5064 100644 --- a/src/hooks/useValidate.ts +++ b/src/hooks/useValidate.ts @@ -10,6 +10,9 @@ export type ValidationResult = Prettify< isNonASCII: boolean | undefined labelCount: number labelDataArray: ParsedInputResult['labelDataArray'] + hasEmoji?: boolean + hasMixedScripts?: boolean + isLatinOnly?: boolean } > @@ -23,9 +26,22 @@ 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, @@ -33,6 +49,9 @@ export const validate = (input: string) => { beautifiedName: tryBeautify(outputName), isNonASCII, labelCount: parsedInput.labelDataArray.length, + hasEmoji, + hasMixedScripts, + isLatinOnly, } } @@ -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 = { diff --git a/test/mock/makeMockUseValidate.ts b/test/mock/makeMockUseValidate.ts index 46af5e25d..3a73d13c7 100644 --- a/test/mock/makeMockUseValidate.ts +++ b/test/mock/makeMockUseValidate.ts @@ -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, @@ -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, @@ -69,6 +77,7 @@ export const makeMockUseValidate = (type: MockUseValidateType): ValidationResult tokens: [[110, 97, 109, 101]], type: 'ASCII', output: [110, 97, 109, 101], + emoji: undefined, }, { input: [101, 116, 104], @@ -76,12 +85,16 @@ export const makeMockUseValidate = (type: MockUseValidateType): ValidationResult 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, @@ -96,6 +109,7 @@ export const makeMockUseValidate = (type: MockUseValidateType): ValidationResult tokens: [[110, 97, 109, 101]], type: 'ASCII', output: [110, 97, 109, 101], + emoji: undefined, }, { input: [99, 111, 109], @@ -103,12 +117,16 @@ export const makeMockUseValidate = (type: MockUseValidateType): ValidationResult 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, @@ -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, @@ -153,6 +174,7 @@ 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], @@ -160,6 +182,7 @@ export const makeMockUseValidate = (type: MockUseValidateType): ValidationResult tokens: [[110, 97, 109, 101]], type: 'ASCII', output: [110, 97, 109, 101], + emoji: undefined, }, { input: [101, 116, 104], @@ -167,11 +190,15 @@ export const makeMockUseValidate = (type: MockUseValidateType): ValidationResult 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() }