Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/controllers/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ ALWAYS update this list when creating a new controller, and provide a one-senten
- **ContractNamesController** – Resolves human-readable names for smart-contract addresses via the relayer.
- **DappsController** – Manages dApp connections, sessions, verification status, and the dApp catalog.
- **DebugController** – Toggles per-controller debug logging at runtime (developer tool); persists toggles and hydrates the `debugLogger` module.
- **DomainsController** – Resolves and caches ENS and Namoshi names (and avatars) for addresses.
- **DomainsController** – Resolves and caches ENS, Namoshi and GNS names (and avatars) for addresses.
- **EmailVaultController** – Handles email-based recovery, magic-link flows, and vault secret management.
- **FeatureFlagsController** – Toggles application features at runtime for roll-outs and A/B testing.
- **EstimationController** – Estimates gas, fees, and payment options for smart-account transactions.
Expand Down
65 changes: 54 additions & 11 deletions src/controllers/domains/domains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ const ENS2 = {

const NO_DOMAINS_ADDRESS = '0x1b9B9813C5805A60184091956F8b36E752272a93'

const GNS_TEST = {
address: '0xC04689227Fa24785609B1174698DBe481437f1A3',
name: 'donnoh.gwei'
}

const makeStorage = (initial: Record<string, any> = {}) => {
const store: Record<string, any> = { domainsCache: initial }
return {
Expand Down Expand Up @@ -251,7 +256,8 @@ describe('Domains', () => {
ENS_LATEST_RESOLVER.address
])

expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(1)
// One ENS lookup and one GNS lookup for the batch
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(2)

const firstCallArgs = reverseLookupEnsSpy.mock.calls[0]
expect(firstCallArgs).toBeDefined()
Expand All @@ -278,6 +284,8 @@ describe('Domains', () => {
const reverseLookupEnsSpy = jest
.spyOn(ensDomainsModule, 'reverseLookupEns')
.mockReturnValueOnce(deferred)
// The GNS leg of the same lookup resolves immediately
.mockResolvedValueOnce({})
const getEnsAvatarSpy = jest.spyOn(ensDomainsModule, 'getEnsAvatar').mockResolvedValue(null)

// First call starts the lookup; the second one is fired while the first is
Expand All @@ -291,9 +299,10 @@ describe('Domains', () => {

await Promise.all([first, second])

// A single underlying lookup despite two reverseLookup calls, and both
// calls only resolved once the data was written to state.
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(1)
// A single underlying lookup per name service (ENS + GNS) despite two
// reverseLookup calls, and both calls only resolved once the data was
// written to state.
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(2)
expect(controller.domains[address]!.ens).toBe(ENS_OLDEST_RESOLVER.name)

reverseLookupEnsSpy.mockRestore()
Expand All @@ -313,10 +322,13 @@ describe('Domains', () => {
.spyOn(ensDomainsModule, 'reverseLookupEns')
// First lookup (addressInFlight) stays pending until we resolve it
.mockReturnValueOnce(deferred)
// ...while its GNS leg resolves immediately
.mockResolvedValueOnce({})
// Batch lookup for the remaining address resolves immediately
.mockResolvedValueOnce({
[addressInBatch]: { name: ENS_LATEST_RESOLVER.name, failed: false }
})
.mockResolvedValueOnce({})
const getEnsAvatarSpy = jest.spyOn(ensDomainsModule, 'getEnsAvatar').mockResolvedValue(null)

const inFlight = controller.reverseLookup(addressInFlight)
Expand All @@ -330,8 +342,9 @@ describe('Domains', () => {

expect(controller.domains[addressInFlight]!.ens).toBe(ENS_OLDEST_RESOLVER.name)
expect(controller.domains[addressInBatch]!.ens).toBe(ENS_LATEST_RESOLVER.name)
// One lookup for the in-flight address, one for the rest of the batch — no duplicate.
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(2)
// One ENS + one GNS lookup for the in-flight address, and one pair for the
// rest of the batch — no duplicate.
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(4)

reverseLookupEnsSpy.mockRestore()
getEnsAvatarSpy.mockRestore()
Expand Down Expand Up @@ -395,6 +408,34 @@ describe('Domains', () => {
expect(domainsController.domainToAddresses[TEST.name]?.type).toBe('namoshi')
expect(domainsController.domains[TEST.address]!.namoshi).toBe(TEST.name)
})
it('should resolve .gwei domain (GNS) on ethereum', async () => {
await domainsController.resolveDomain({ domain: GNS_TEST.name })

expect(domainsController.domainToAddresses[GNS_TEST.name]?.address).toBe(GNS_TEST.address)
expect(domainsController.domainToAddresses[GNS_TEST.name]?.type).toBe('gwei')
expect(domainsController.domains[GNS_TEST.address]!.gwei).toBe(GNS_TEST.name)
})
it('should reverse lookup (GNS .gwei)', async () => {
domainsController.domains = {}

await domainsController.reverseLookup(GNS_TEST.address)

expect(domainsController.domains[GNS_TEST.address]!.gwei).toBe(GNS_TEST.name)
expect(domainsController.domainToAddresses[GNS_TEST.name]?.address).toBe(GNS_TEST.address)
expect(domainsController.domainToAddresses[GNS_TEST.name]?.type).toBe('gwei')
})
it('should not resolve an unregistered .gwei domain', async () => {
const UNREGISTERED_NAME = 'surely-not-registered-1x2y.gwei'

await domainsController.resolveDomain({ domain: UNREGISTERED_NAME })

expect(domainsController.domainToAddresses[UNREGISTERED_NAME]).toBeUndefined()
})
it('should set gwei to null if no domain is found', async () => {
await domainsController.reverseLookup(NO_DOMAINS_ADDRESS)

expect(domainsController.domains[NO_DOMAINS_ADDRESS]!.gwei).toBe(null)
})

it('privacy mode: a whenStale lookup refreshes once the cached value is older than the TTL', async () => {
const controller = new DomainsController({
Expand All @@ -413,17 +454,18 @@ describe('Domains', () => {
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(start)

await controller.reverseLookup(address)
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(1)
// Each refresh performs two lookups: ENS and GNS
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(2)

// Before the TTL even a whenStale lookup serves from cache.
nowSpy.mockReturnValue(start + PERSIST_DOMAIN_FOR_IN_MS - 60000)
await controller.reverseLookup(address, true, { privacyUpdateMode: 'whenStale' })
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(1)
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(2)

// After the TTL a whenStale lookup refreshes.
nowSpy.mockReturnValue(start + PERSIST_DOMAIN_FOR_IN_MS + 60000)
await controller.reverseLookup(address, true, { privacyUpdateMode: 'whenStale' })
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(2)
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(4)

nowSpy.mockRestore()
reverseLookupEnsSpy.mockRestore()
Expand All @@ -447,12 +489,13 @@ describe('Domains', () => {
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(start)

await controller.reverseLookup(address)
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(1)
// Each refresh performs two lookups: ENS and GNS
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(2)

// Passive lookup past the TTL refreshes
nowSpy.mockReturnValue(start + PERSIST_DOMAIN_FOR_IN_MS + 60000)
await controller.reverseLookup(address)
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(2)
expect(reverseLookupEnsSpy).toHaveBeenCalledTimes(4)

nowSpy.mockRestore()
reverseLookupEnsSpy.mockRestore()
Expand Down
44 changes: 34 additions & 10 deletions src/controllers/domains/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { RPCProviders } from '../../interfaces/provider'
import { IStorageController } from '../../interfaces/storage'
import {
getEnsAvatar,
getIsGweiDomain,
getIsNamoshiDomain,
resolveENSDomain,
reverseLookupEns,
Expand Down Expand Up @@ -45,7 +46,7 @@ export class DomainsController extends EventEmitter implements IDomainsControlle
domainToAddresses: {
[domain: string]: {
address: string | undefined
type: 'ens' | 'namoshi'
type: 'ens' | 'namoshi' | 'gwei'
}
} = {}

Expand Down Expand Up @@ -189,6 +190,8 @@ export class DomainsController extends EventEmitter implements IDomainsControlle
*/
async resolveDomain({ domain }: { domain: string }) {
const isNamoshiDomain = getIsNamoshiDomain(domain)
const isGweiDomain = getIsGweiDomain(domain)
const domainType = isNamoshiDomain ? 'namoshi' : isGweiDomain ? 'gwei' : 'ens'
const providerChainId = isNamoshiDomain
? '4114'
: this.#defaultNetworksMode === 'mainnet'
Expand Down Expand Up @@ -228,19 +231,19 @@ export class DomainsController extends EventEmitter implements IDomainsControlle
await resolveENSDomain({
provider: provider,
domain,
options: { isNamoshiDomain }
options: { isNamoshiDomain, isGweiDomain }
})
.then(async ({ address, avatar }) => {
if (address) {
this.domainToAddresses[domain] = {
address: getAddress(address),
type: isNamoshiDomain ? 'namoshi' : 'ens'
type: domainType
}
this.#saveResolvedDomain({
address,
ensAvatar: avatar,
domain,
type: isNamoshiDomain ? 'namoshi' : 'ens'
type: domainType
})
}
this.resolveDomainsStatus[domain] = 'RESOLVED'
Expand Down Expand Up @@ -272,7 +275,7 @@ export class DomainsController extends EventEmitter implements IDomainsControlle
address: string
domain: string
ensAvatar: string | null
type: 'ens' | 'namoshi'
type: 'ens' | 'namoshi' | 'gwei'
}) {
const checksummedAddress = getAddress(address)
const { ens: prevEns } = this.domains[checksummedAddress] || { ens: null }
Expand All @@ -284,6 +287,7 @@ export class DomainsController extends EventEmitter implements IDomainsControlle
ensAvatar: type === 'ens' ? ensAvatar : (existing?.ensAvatar ?? null),
ens: type === 'ens' ? domain : prevEns,
namoshi: type === 'namoshi' ? domain : (existing?.namoshi ?? null),
gwei: type === 'gwei' ? domain : (existing?.gwei ?? null),
createdAt: existing?.createdAt ?? now,
updatedAt: now
}
Expand Down Expand Up @@ -368,7 +372,7 @@ export class DomainsController extends EventEmitter implements IDomainsControlle
if (hasBeenResolvedOnce) {
this.domains[address]!.updateFailedAt = Date.now()
} else {
this.domains[address] = { ens: null, namoshi: null, updateFailedAt: Date.now() }
this.domains[address] = { ens: null, namoshi: null, gwei: null, updateFailedAt: Date.now() }
}
}

Expand All @@ -395,7 +399,7 @@ export class DomainsController extends EventEmitter implements IDomainsControlle
this.emitUpdate()

try {
const [ensByAddress, namoshiByAddress] = await Promise.all([
const [ensByAddress, namoshiByAddress, gweiByAddress] = await Promise.all([
withTimeout(() => reverseLookupEns(addressesToLookup, ethereumProvider), {
timeoutMs: 15000
}),
Expand All @@ -410,6 +414,12 @@ export class DomainsController extends EventEmitter implements IDomainsControlle
{
timeoutMs: 15000
}
),
withTimeout(
() => reverseLookupEns(addressesToLookup, ethereumProvider, { isGweiDomain: true }),
{
timeoutMs: 15000
}
)
])

Expand All @@ -418,11 +428,13 @@ export class DomainsController extends EventEmitter implements IDomainsControlle
if (!ensEntry || ensEntry.failed) return { address, failed: true as const }

const namoshiEntry = namoshiByAddress[address]
const gweiEntry = gweiByAddress[address]
return {
address,
failed: false as const,
ens: ensEntry.name,
namoshi: namoshiEntry && !namoshiEntry.failed ? namoshiEntry.name : null
namoshi: namoshiEntry && !namoshiEntry.failed ? namoshiEntry.name : null,
gwei: gweiEntry && !gweiEntry.failed ? gweiEntry.name : null
}
})

Expand Down Expand Up @@ -450,6 +462,15 @@ export class DomainsController extends EventEmitter implements IDomainsControlle
return [entry.address, avatar] as const
}

const gweiName = entry.gwei
if (gweiName) {
const avatar = await withTimeout(
() => getEnsAvatar(gweiName, ethereumProvider, { isGweiDomain: true }),
{ timeoutMs: 15000 }
).catch(() => null)
return [entry.address, avatar] as const
}

return [entry.address, null] as const
})
)
Expand All @@ -461,26 +482,29 @@ export class DomainsController extends EventEmitter implements IDomainsControlle
continue
}

const { address, ens, namoshi } = entry
const { address, ens, namoshi, gwei } = entry

if (ens) {
this.domainToAddresses[ens] = { address, type: 'ens' }
} else if (namoshi && citreaProvider) {
this.domainToAddresses[namoshi] = { address, type: 'namoshi' }
} else if (gwei) {
this.domainToAddresses[gwei] = { address, type: 'gwei' }
}

const now = Date.now()
const existing = this.domains[address]
this.domains[address] = {
ens,
namoshi,
gwei,
ensAvatar: avatarByAddress[address] ?? null,
createdAt: existing?.createdAt ?? now,
updatedAt: now
}
}
} catch (e: any) {
console.warn('reverse ENS/Namoshi lookup failed', e)
console.warn('reverse ENS/Namoshi/GNS lookup failed', e)

addressesToLookup.forEach((address) => this.#setLookupFailure(address))
} finally {
Expand Down
9 changes: 7 additions & 2 deletions src/interfaces/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ export type Domains = {
*/
namoshi: string | null
/**
* ENS or Namoshi avatar URL
* GNS domains are fully compatible with the ENS implementation, they just use a different universal resolver contract
* and have a different TLD (.gwei).
*/
gwei: string | null
/**
* ENS, Namoshi or GNS avatar URL
*/
ensAvatar?: string | null
createdAt?: number
Expand All @@ -35,7 +40,7 @@ type ReverseLookupOptions = {
type AddressState = {
fieldValue: string
resolvedAddress: string
resolvedAddressType: 'ens' | 'namoshi' | null
resolvedAddressType: 'ens' | 'namoshi' | 'gwei' | null
isDomainResolving: boolean
}

Expand Down
Loading