From 6476885344b05f03f8d608c972c3b01d1a00f30b Mon Sep 17 00:00:00 2001 From: tomiir Date: Thu, 23 May 2024 19:25:11 -0600 Subject: [PATCH] feat: WC Names integration (#2122) @svenvoskamp merging as you are OOO. Please let me know if there's anything that wasn't addressed --- .../composites/wui-ens-input.stories.ts | 22 ++ .../composites/wui-icon-box.stories.ts | 2 +- apps/laboratory/tests/shared/utils/email.ts | 4 + packages/common/index.ts | 2 + packages/common/src/utils/ConstantsUtil.ts | 3 + packages/common/src/utils/NavigationUtil.ts | 5 + packages/core/index.ts | 3 + .../controllers/BlockchainApiController.ts | 39 +++- .../core/src/controllers/EnsController.ts | 162 +++++++++++++++ .../core/src/controllers/RouterController.ts | 3 + .../core/src/controllers/ThemeController.ts | 4 +- packages/core/src/utils/EnsUtil.ts | 11 + packages/core/src/utils/FetchUtil.ts | 23 ++- packages/core/src/utils/RouterUtil.ts | 6 +- packages/core/src/utils/TypeUtil.ts | 36 ++++ .../controllers/ConnectorController.test.ts | 2 +- .../tests/controllers/EnsController.test.ts | 176 ++++++++++++++++ packages/ethers/src/client.ts | 46 ++++- packages/scaffold/index.ts | 3 + packages/scaffold/src/client.ts | 16 +- .../scaffold/src/modal/w3m-router/index.ts | 79 +++---- .../partials/w3m-email-login-widget/index.ts | 2 +- .../scaffold/src/partials/w3m-header/index.ts | 3 + .../views/w3m-account-settings-view/index.ts | 62 ++++-- .../src/views/w3m-account-view/index.ts | 1 + .../w3m-choose-account-name-view/index.ts | 88 ++++++++ .../w3m-choose-account-name-view/styles.ts | 7 + .../w3m-email-verify-device-view/index.ts | 2 +- .../index.ts | 82 ++++++++ .../styles.ts | 7 + .../w3m-register-account-name-view/index.ts | 192 ++++++++++++++++++ .../w3m-register-account-name-view/styles.ts | 34 ++++ .../w3m-update-email-wallet-view/index.ts | 2 +- .../index.ts | 8 +- packages/ui/index.ts | 1 + packages/ui/src/assets/svg/checkmark.ts | 17 +- packages/ui/src/assets/svg/id.ts | 14 ++ packages/ui/src/components/wui-icon/index.ts | 2 + .../ui/src/composites/wui-ens-input/index.ts | 68 +++++++ .../ui/src/composites/wui-ens-input/styles.ts | 21 ++ .../ui/src/composites/wui-input-text/index.ts | 14 +- .../src/composites/wui-input-text/styles.ts | 36 ++++ .../src/composites/wui-network-image/index.ts | 2 +- packages/ui/src/composites/wui-tag/styles.ts | 2 +- packages/ui/src/utils/JSXTypeUtil.ts | 2 + packages/ui/src/utils/ThemeUtil.ts | 2 + packages/ui/src/utils/TypeUtil.ts | 4 +- packages/wagmi/src/client.ts | 52 ++++- 48 files changed, 1264 insertions(+), 110 deletions(-) create mode 100644 apps/gallery/stories/composites/wui-ens-input.stories.ts create mode 100644 packages/common/src/utils/ConstantsUtil.ts create mode 100644 packages/common/src/utils/NavigationUtil.ts create mode 100644 packages/core/src/controllers/EnsController.ts create mode 100644 packages/core/src/utils/EnsUtil.ts create mode 100644 packages/core/tests/controllers/EnsController.test.ts create mode 100644 packages/scaffold/src/views/w3m-choose-account-name-view/index.ts create mode 100644 packages/scaffold/src/views/w3m-choose-account-name-view/styles.ts create mode 100644 packages/scaffold/src/views/w3m-register-account-name-success-view/index.ts create mode 100644 packages/scaffold/src/views/w3m-register-account-name-success-view/styles.ts create mode 100644 packages/scaffold/src/views/w3m-register-account-name-view/index.ts create mode 100644 packages/scaffold/src/views/w3m-register-account-name-view/styles.ts create mode 100644 packages/ui/src/assets/svg/id.ts create mode 100644 packages/ui/src/composites/wui-ens-input/index.ts create mode 100644 packages/ui/src/composites/wui-ens-input/styles.ts diff --git a/apps/gallery/stories/composites/wui-ens-input.stories.ts b/apps/gallery/stories/composites/wui-ens-input.stories.ts new file mode 100644 index 0000000000..62deae9a1e --- /dev/null +++ b/apps/gallery/stories/composites/wui-ens-input.stories.ts @@ -0,0 +1,22 @@ +import type { Meta } from '@storybook/web-components' +import '@web3modal/ui/src/composites/wui-ens-input' +import type { WuiEnsInput } from '@web3modal/ui' +import { html } from 'lit' +import '../../components/gallery-container' + +type Component = Meta + +export default { + title: 'Composites/wui-ens-input', + args: { + errorMessage: '', + disabled: false + } +} as Component + +export const Default: Component = { + render: args => + html`` +} diff --git a/apps/gallery/stories/composites/wui-icon-box.stories.ts b/apps/gallery/stories/composites/wui-icon-box.stories.ts index 028cd47b2c..d40cc0f429 100644 --- a/apps/gallery/stories/composites/wui-icon-box.stories.ts +++ b/apps/gallery/stories/composites/wui-icon-box.stories.ts @@ -27,7 +27,7 @@ export default { argTypes: { size: { defaultValue: 'md', - options: ['sm', 'md', 'lg'], + options: ['sm', 'md', 'lg', 'xl'], control: { type: 'select' } }, backgroundColor: { diff --git a/apps/laboratory/tests/shared/utils/email.ts b/apps/laboratory/tests/shared/utils/email.ts index f755fb838e..f16bd899b6 100644 --- a/apps/laboratory/tests/shared/utils/email.ts +++ b/apps/laboratory/tests/shared/utils/email.ts @@ -81,4 +81,8 @@ export class Email { return `w3m-w${index}${randIndex}@${domain}` } + + getSmartAccountEnabledEmail(): string { + return 'web3modal-smart-account@mailsac.com' + } } diff --git a/packages/common/index.ts b/packages/common/index.ts index f34f29d18f..cbdd2f8f34 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -3,5 +3,7 @@ export { DateUtil } from './src/utils/DateUtil.js' export { NetworkUtil } from './src/utils/NetworkUtil.js' export { NumberUtil } from './src/utils/NumberUtil.js' export { erc20ABI } from './src/contracts/erc20.js' +export { NavigationUtil } from './src/utils/NavigationUtil.js' +export { ConstantsUtil } from './src/utils/ConstantsUtil.js' export * from './src/utils/ThemeUtil.js' export type * from './src/utils/TypeUtil.js' diff --git a/packages/common/src/utils/ConstantsUtil.ts b/packages/common/src/utils/ConstantsUtil.ts new file mode 100644 index 0000000000..0dc1cf96cc --- /dev/null +++ b/packages/common/src/utils/ConstantsUtil.ts @@ -0,0 +1,3 @@ +export const ConstantsUtil = { + WC_NAME_SUFFIX: '.wcn.id' +} diff --git a/packages/common/src/utils/NavigationUtil.ts b/packages/common/src/utils/NavigationUtil.ts new file mode 100644 index 0000000000..cc9e2e7d35 --- /dev/null +++ b/packages/common/src/utils/NavigationUtil.ts @@ -0,0 +1,5 @@ +export const NavigationUtil = { + URLS: { + FAQ: 'https://walletconnect.com/faq' + } +} diff --git a/packages/core/index.ts b/packages/core/index.ts index 6e454b7e67..b8dbe3182f 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -64,6 +64,9 @@ export type { SendControllerState } from './src/controllers/SendController.js' export { TooltipController } from './src/controllers/TooltipController.js' export type { TooltipControllerState } from './src/controllers/TooltipController.js' +export { EnsController } from './src/controllers/EnsController.js' +export type { EnsControllerState } from './src/controllers/EnsController.js' + // -- Utils ------------------------------------------------------------------- export { AssetUtil } from './src/utils/AssetUtil.js' export { ConstantsUtil } from './src/utils/ConstantsUtil.js' diff --git a/packages/core/src/controllers/BlockchainApiController.ts b/packages/core/src/controllers/BlockchainApiController.ts index 37799eef46..6932251f59 100644 --- a/packages/core/src/controllers/BlockchainApiController.ts +++ b/packages/core/src/controllers/BlockchainApiController.ts @@ -1,6 +1,7 @@ import { ConstantsUtil } from '../utils/ConstantsUtil.js' import { CoreHelperUtil } from '../utils/CoreHelperUtil.js' import { FetchUtil } from '../utils/FetchUtil.js' +import { ConstantsUtil as CommonConstantsUtil } from '@web3modal/common' import type { BlockchainApiTransactionsRequest, BlockchainApiTransactionsResponse, @@ -23,7 +24,10 @@ import type { OnrampQuote, PaymentCurrency, PurchaseCurrency, - BlockchainApiBalanceResponse + BlockchainApiBalanceResponse, + BlockchainApiLookupEnsName, + BlockchainApiSuggestionResponse, + BlockchainApiRegisterNameParams } from '../utils/TypeUtil.js' import { OptionsController } from './OptionsController.js' @@ -242,6 +246,39 @@ export const BlockchainApiController = { }) }, + async lookupEnsName(name: string) { + return api.get({ + path: `/v1/profile/account/${name}${CommonConstantsUtil.WC_NAME_SUFFIX}?projectId=${OptionsController.state.projectId}` + }) + }, + + async reverseLookupEnsName({ address }: { address: string }) { + return api.get({ + path: `/v1/profile/reverse/${address}?projectId=${OptionsController.state.projectId}` + }) + }, + + async getEnsNameSuggestions(name: string) { + return api.get({ + path: `/v1/profile/suggestions/${name}?projectId=${OptionsController.state.projectId}` + }) + }, + + async registerEnsName({ + coinType, + address, + message, + signature + }: BlockchainApiRegisterNameParams) { + return api.post({ + path: `/v1/profile/account`, + body: { coin_type: coinType, address, message, signature }, + headers: { + 'Content-Type': 'application/json' + } + }) + }, + async generateOnRampURL({ destinationWallets, partnerUserId, diff --git a/packages/core/src/controllers/EnsController.ts b/packages/core/src/controllers/EnsController.ts new file mode 100644 index 0000000000..0654183f54 --- /dev/null +++ b/packages/core/src/controllers/EnsController.ts @@ -0,0 +1,162 @@ +import { subscribeKey as subKey } from 'valtio/utils' +import { proxy, subscribe as sub } from 'valtio/vanilla' +import { BlockchainApiController } from './BlockchainApiController.js' +import type { BlockchainApiEnsError } from '../utils/TypeUtil.js' +import { AccountController } from './AccountController.js' +import { ConnectorController } from './ConnectorController.js' +import { RouterController } from './RouterController.js' +import { ConnectionController } from './ConnectionController.js' +import { NetworkController } from './NetworkController.js' +import { NetworkUtil } from '@web3modal/common' +import { EnsUtil } from '../utils/EnsUtil.js' +import { ConstantsUtil } from '@web3modal/common' + +// -- Types --------------------------------------------- // +type Suggestion = { + name: string + registered: boolean +} + +export interface EnsControllerState { + suggestions: Suggestion[] + loading: boolean +} + +type StateKey = keyof EnsControllerState + +// -- State --------------------------------------------- // +const state = proxy({ + suggestions: [], + loading: false +}) + +// -- Controller ---------------------------------------- // +export const EnsController = { + state, + + subscribe(callback: (newState: EnsControllerState) => void) { + return sub(state, () => callback(state)) + }, + + subscribeKey(key: K, callback: (value: EnsControllerState[K]) => void) { + return subKey(state, key, callback) + }, + + async resolveName(name: string) { + try { + return await BlockchainApiController.lookupEnsName(name) + } catch (e) { + const error = e as BlockchainApiEnsError + throw new Error(error?.reasons?.[0]?.description || 'Error resolving name') + } + }, + + async isNameRegistered(name: string) { + try { + await BlockchainApiController.lookupEnsName(name) + + return true + } catch { + return false + } + }, + + async getSuggestions(name: string) { + try { + state.loading = true + state.suggestions = [] + const response = await BlockchainApiController.getEnsNameSuggestions(name) + state.suggestions = + response.suggestions.map(suggestion => ({ + ...suggestion, + name: suggestion.name.replace(ConstantsUtil.WC_NAME_SUFFIX, '') + })) || [] + + return state.suggestions + } catch (e) { + const errorMessage = this.parseEnsApiError(e, 'Error fetching name suggestions') + throw new Error(errorMessage) + } finally { + state.loading = false + } + }, + + async getNamesForAddress(address: string) { + try { + const network = NetworkController.state.caipNetwork + if (!network) { + return [] + } + + const response = await BlockchainApiController.reverseLookupEnsName({ address }) + + return response + } catch (e) { + const errorMessage = this.parseEnsApiError(e, 'Error fetching names for address') + throw new Error(errorMessage) + } + }, + + async registerName(name: string) { + const network = NetworkController.state.caipNetwork + if (!network) { + throw new Error('Network not found') + } + const address = AccountController.state.address + const emailConnector = ConnectorController.getAuthConnector() + if (!address || !emailConnector) { + throw new Error('Address or auth connector not found') + } + + state.loading = true + + try { + const message = JSON.stringify({ + name: `${name}${ConstantsUtil.WC_NAME_SUFFIX}`, + attributes: {}, + timestamp: Math.floor(Date.now() / 1000) + }) + + RouterController.pushTransactionStack({ + view: 'RegisterAccountNameSuccess', + goBack: false, + replace: true, + onCancel() { + state.loading = false + } + }) + + const signature = await ConnectionController.signMessage(message) + const networkId = NetworkUtil.caipNetworkIdToNumber(network.id) + + if (!networkId) { + throw new Error('Network not found') + } + + const coinType = EnsUtil.convertEVMChainIdToCoinType(networkId) + await BlockchainApiController.registerEnsName({ + coinType, + address: address as `0x${string}`, + signature, + message + }) + + AccountController.setProfileName(`${name}${ConstantsUtil.WC_NAME_SUFFIX}`) + RouterController.replace('RegisterAccountNameSuccess') + } catch (e) { + const errorMessage = this.parseEnsApiError(e, `Error registering name ${name}`) + RouterController.replace('RegisterAccountName') + throw new Error(errorMessage) + } finally { + state.loading = false + } + }, + validateName(name: string) { + return /^[a-zA-Z0-9-]{4,}$/u.test(name) + }, + parseEnsApiError(error: unknown, defaultError: string) { + const ensError = error as BlockchainApiEnsError + + return ensError?.reasons?.[0]?.description || defaultError + } +} diff --git a/packages/core/src/controllers/RouterController.ts b/packages/core/src/controllers/RouterController.ts index 20a7c4027e..2c789d84d5 100644 --- a/packages/core/src/controllers/RouterController.ts +++ b/packages/core/src/controllers/RouterController.ts @@ -20,6 +20,7 @@ export interface RouterControllerState { | 'ApproveTransaction' | 'BuyInProgress' | 'WalletCompatibleNetworks' + | 'ChooseAccountName' | 'Connect' | 'ConnectingExternal' | 'ConnectingWalletConnect' @@ -36,6 +37,8 @@ export interface RouterControllerState { | 'OnRampFiatSelect' | 'OnRampProviders' | 'OnRampTokenSelect' + | 'RegisterAccountName' + | 'RegisterAccountNameSuccess' | 'SwitchNetwork' | 'Transactions' | 'UnsupportedChain' diff --git a/packages/core/src/controllers/ThemeController.ts b/packages/core/src/controllers/ThemeController.ts index 24486f8f14..ae4f7fa5b9 100644 --- a/packages/core/src/controllers/ThemeController.ts +++ b/packages/core/src/controllers/ThemeController.ts @@ -43,7 +43,7 @@ export const ThemeController = { } } catch { // eslint-disable-next-line no-console - console.info('Unable to sync theme to email connector') + console.info('Unable to sync theme to auth connector') } }, @@ -63,7 +63,7 @@ export const ThemeController = { } } catch { // eslint-disable-next-line no-console - console.info('Unable to sync theme to email connector') + console.info('Unable to sync theme to auth connector') } }, diff --git a/packages/core/src/utils/EnsUtil.ts b/packages/core/src/utils/EnsUtil.ts new file mode 100644 index 0000000000..123d280c4f --- /dev/null +++ b/packages/core/src/utils/EnsUtil.ts @@ -0,0 +1,11 @@ +const SLIP44_MSB = 0x80000000 + +export const EnsUtil = { + convertEVMChainIdToCoinType(chainId: number): number { + if (chainId >= SLIP44_MSB) { + throw new Error('Invalid chainId') + } + + return (SLIP44_MSB | chainId) >>> 0 + } +} diff --git a/packages/core/src/utils/FetchUtil.ts b/packages/core/src/utils/FetchUtil.ts index 5a543565fb..0c80ed3b42 100644 --- a/packages/core/src/utils/FetchUtil.ts +++ b/packages/core/src/utils/FetchUtil.ts @@ -14,6 +14,19 @@ interface PostArguments extends RequestArguments { body?: Record } +async function fetchData(...args: Parameters) { + const response = await fetch(...args) + if (!response.ok) { + // Create error object and reject if not a 2xx response code + const err = new Error(`HTTP status code: ${response.status}`, { + cause: response + }) + throw err + } + + return response +} + // -- Utility -------------------------------------------------------------------- export class FetchUtil { public baseUrl: Options['baseUrl'] @@ -24,21 +37,21 @@ export class FetchUtil { public async get({ headers, signal, ...args }: RequestArguments) { const url = this.createUrl(args) - const response = await fetch(url, { method: 'GET', headers, signal, cache: 'no-cache' }) + const response = await fetchData(url, { method: 'GET', headers, signal, cache: 'no-cache' }) return response.json() as T } public async getBlob({ headers, signal, ...args }: RequestArguments) { const url = this.createUrl(args) - const response = await fetch(url, { method: 'GET', headers, signal }) + const response = await fetchData(url, { method: 'GET', headers, signal }) return response.blob() } public async post({ body, headers, signal, ...args }: PostArguments) { const url = this.createUrl(args) - const response = await fetch(url, { + const response = await fetchData(url, { method: 'POST', headers, body: body ? JSON.stringify(body) : undefined, @@ -50,7 +63,7 @@ export class FetchUtil { public async put({ body, headers, signal, ...args }: PostArguments) { const url = this.createUrl(args) - const response = await fetch(url, { + const response = await fetchData(url, { method: 'PUT', headers, body: body ? JSON.stringify(body) : undefined, @@ -62,7 +75,7 @@ export class FetchUtil { public async delete({ body, headers, signal, ...args }: PostArguments) { const url = this.createUrl(args) - const response = await fetch(url, { + const response = await fetchData(url, { method: 'DELETE', headers, body: body ? JSON.stringify(body) : undefined, diff --git a/packages/core/src/utils/RouterUtil.ts b/packages/core/src/utils/RouterUtil.ts index 4efacb330f..bf40baca62 100644 --- a/packages/core/src/utils/RouterUtil.ts +++ b/packages/core/src/utils/RouterUtil.ts @@ -1,6 +1,7 @@ import { RouterController } from '../controllers/RouterController.js' import { ModalController } from '../controllers/ModalController.js' import { OptionsController } from '../controllers/OptionsController.js' +import { AccountController } from '../controllers/AccountController.js' export const RouterUtil = { goBackOrCloseModal() { @@ -21,10 +22,13 @@ export const RouterUtil = { }, navigateAfterPreferredAccountTypeSelect() { const { isSiweEnabled } = OptionsController.state + const { profileName } = AccountController.state if (isSiweEnabled) { RouterController.push('ConnectingSiwe') - } else { + } else if (profileName) { RouterController.push('Account') + } else { + RouterController.push('ChooseAccountName') } } } diff --git a/packages/core/src/utils/TypeUtil.ts b/packages/core/src/utils/TypeUtil.ts index 5bbaf2dff2..0c5e8a4a1c 100644 --- a/packages/core/src/utils/TypeUtil.ts +++ b/packages/core/src/utils/TypeUtil.ts @@ -280,6 +280,42 @@ export interface BlockchainApiBalanceResponse { balances: Balance[] } +export interface BlockchainApiLookupEnsName { + name: string + registered: number + updated: number + addresses: Record< + string, + { + address: string + created: string + } + > + attributes: { + avatar?: string + bio?: string + }[] +} + +export interface BlockchainApiRegisterNameParams { + coinType: number + message: string + signature: string + address: `0x${string}` +} + +export interface BlockchainApiSuggestionResponse { + suggestions: { + name: string + registered: boolean + }[] +} + +export interface BlockchainApiEnsError extends BaseError { + status: string + reasons: { name: string; description: string }[] +} + // -- OptionsController Types --------------------------------------------------- export interface Token { address: string diff --git a/packages/core/tests/controllers/ConnectorController.test.ts b/packages/core/tests/controllers/ConnectorController.test.ts index 488a350e24..30cff8fe47 100644 --- a/packages/core/tests/controllers/ConnectorController.test.ts +++ b/packages/core/tests/controllers/ConnectorController.test.ts @@ -89,7 +89,7 @@ describe('ConnectorController', () => { expect(ConnectorController.getAuthConnector()).toEqual(undefined) }) - it('should trigger corresponding sync methods when adding email connector', () => { + it('should trigger corresponding sync methods when adding auth connector', () => { OptionsController.setMetadata(mockDappData.metadata) OptionsController.setSdkVersion(mockDappData.sdkVersion) OptionsController.setProjectId(mockDappData.projectId) diff --git a/packages/core/tests/controllers/EnsController.test.ts b/packages/core/tests/controllers/EnsController.test.ts new file mode 100644 index 0000000000..1d8352ceed --- /dev/null +++ b/packages/core/tests/controllers/EnsController.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AccountController, + ConnectionController, + ConnectorController, + EnsController, + NetworkController +} from '../../index.js' +import { W3mFrameProvider } from '@web3modal/wallet' +import { ConstantsUtil } from '@web3modal/common' +// -- Setup -------------------------------------------------------------------- +const TEST_NAME = { + name: 'test', + registered: true, + updated: 1, + addresses: [ + { + '0x123': { + address: '0x123', + created: '1' + } + } + ], + attributes: [] +} +vi.mock('../../src/controllers/BlockchainApiController.js', async importOriginal => { + const mod = + await importOriginal() + + return { + ...mod, + BlockchainApiController: { + lookupEnsName: async (name: string) => { + if (name !== 'test') { + throw new Error('Error resolving ENS name') + } + + return Promise.resolve(TEST_NAME) + }, + getEnsNameSuggestions: async (name: string) => { + const suggestions = [`${name}1`, `${name}2`, `${name}Something`].map(val => ({ + registered: false, + name: `${val}${ConstantsUtil.WC_NAME_SUFFIX}` + })) + if (name === 'test') { + suggestions.push({ registered: false, name: `${name}${ConstantsUtil.WC_NAME_SUFFIX}` }) + } + + return Promise.resolve({ suggestions }) + }, + reverseLookupEnsName: async ({ address }: { address: string }) => { + if (address === '0x123') { + return Promise.resolve([TEST_NAME]) + } + + return Promise.resolve([]) + }, + + registerEnsName: async (name: string) => { + if (name === 'test') { + throw new Error('Error registering ENS name') + } + + return Promise.resolve({ success: true }) + } + } + } +}) + +// -- Tests -------------------------------------------------------------------- +describe('EnsController', () => { + it('should have valid default state', () => { + expect(EnsController.state).toEqual({ + suggestions: [], + loading: false + }) + }) + + it('should resolve name', async () => { + const result = await EnsController.resolveName('test') + + expect(result).toEqual(TEST_NAME) + }) + + it('should throw error when resolving invalid name', async () => { + try { + await EnsController.resolveName('invalid') + } catch (e) { + expect((e as Error).message).toBe('Error resolving name') + } + }) + + it('should check if name is registered', async () => { + const result = await EnsController.isNameRegistered('test') + + expect(result).toBe(true) + + const result2 = await EnsController.isNameRegistered('invalid') + expect(result2).toBe(false) + }) + + it('should get suggestions', async () => { + // Setup + + const result = await EnsController.getSuggestions('test') + const mockSuggestions = ['test1', 'test2', 'testSomething', 'test'].map(name => ({ + registered: false, + name + })) + expect(result).toEqual(mockSuggestions) + expect(EnsController.state.suggestions).toEqual(mockSuggestions) + expect(EnsController.state.loading).toBe(false) + + const result2 = await EnsController.getSuggestions('name') + const mockSuggestions2 = ['name1', 'name2', 'nameSomething'].map(name => ({ + registered: false, + name + })) + expect(result2).toEqual(mockSuggestions2) + expect(EnsController.state.suggestions).toEqual(mockSuggestions2) + expect(EnsController.state.loading).toBe(false) + }) + + it('should get names for address', async () => { + // No network set + const result = await EnsController.getNamesForAddress('0x123') + expect(result).toEqual([]) + NetworkController.setCaipNetwork({ id: 'test:123' }) + const resultWithNetwork = await EnsController.getNamesForAddress('0x123') + expect(resultWithNetwork).toEqual([TEST_NAME]) + + const result2 = await EnsController.getNamesForAddress('invalid') + expect(result2).toEqual([]) + }) + + it('should register name', async () => { + // Setup + NetworkController.setCaipNetwork({ id: 'test:123' }) + AccountController.setCaipAddress('eip155:1:0x123') + const getAuthConnectorSpy = vi + .spyOn(ConnectorController, 'getAuthConnector') + .mockResolvedValueOnce({ + provider: new W3mFrameProvider(''), + id: 'w3mAuth', + type: 'AUTH' + }) + const signMessageSpy = vi + .spyOn(ConnectionController, 'signMessage') + .mockResolvedValueOnce('0x123123123') + + const message = JSON.stringify({ + name: `newname${ConstantsUtil.WC_NAME_SUFFIX}`, + attributes: {}, + timestamp: Math.floor(Date.now() / 1000) + }) + await EnsController.registerName('newname') + expect(getAuthConnectorSpy).toHaveBeenCalled() + expect(signMessageSpy).toHaveBeenCalledWith(message) + expect(AccountController.state.profileName).toBe(`newname${ConstantsUtil.WC_NAME_SUFFIX}`) + expect(EnsController.state.loading).toBe(false) + }) + + it('should validate name correctly', () => { + const result = EnsController.validateName('test123') + expect(result).toBe(true) + + const result2 = EnsController.validateName('invalid.name') + expect(result2).toBe(false) + + const result3 = EnsController.validateName('valid-name') + expect(result3).toBe(true) + + const result4 = EnsController.validateName('invalid name') + expect(result4).toBe(false) + }) +}) diff --git a/packages/ethers/src/client.ts b/packages/ethers/src/client.ts index b39f78d93d..eee760e7e3 100644 --- a/packages/ethers/src/client.ts +++ b/packages/ethers/src/client.ts @@ -16,6 +16,7 @@ import { Web3ModalScaffold } from '@web3modal/scaffold' import { ConstantsUtil, PresetsUtil, HelpersUtil } from '@web3modal/scaffold-utils' import EthereumProvider, { OPTIONAL_METHODS } from '@walletconnect/ethereum-provider' import type { Web3ModalSIWEClient } from '@web3modal/siwe' +import { ConstantsUtil as CommonConstants } from '@web3modal/common' import type { Address, Metadata, @@ -421,21 +422,26 @@ export class Web3Modal extends Web3ModalScaffold { throw new Error('Contract method is undefined') }, - getEnsAddress: async (value: string) => { - const { chainId } = EthersStoreUtil.state - if (chainId && chainId === 1) { - const ensProvider = new InfuraProvider('mainnet') + try { + const chainId = NetworkUtil.caipNetworkIdToNumber(this.getCaipNetwork()?.id) + let ensName: string | null = null + let wcName: boolean | string = false - const name = await ensProvider.resolveName(value) - if (name) { - return name + if (value?.endsWith(CommonConstants.WC_NAME_SUFFIX)) { + wcName = await this.resolveWalletConnectName(value) } + if (chainId === 1) { + const ensProvider = new InfuraProvider('mainnet') + + ensName = await ensProvider.resolveName(value) + } + + return ensName || wcName || false + } catch { return false } - - return false }, getEnsAvatar: async (value: string) => { @@ -1098,6 +1104,20 @@ export class Web3Modal extends Web3ModalScaffold { } } + private async syncWalletConnectName(address: Address) { + try { + const registeredWcNames = await this.getWalletConnectName(address) + if (registeredWcNames[0]) { + const wcName = registeredWcNames[0] + this.setProfileName(wcName.name) + } else { + this.setProfileName(null) + } + } catch { + this.setProfileName(null) + } + } + private async syncProfile(address: Address) { const chainId = EthersStoreUtil.state.chainId @@ -1107,6 +1127,10 @@ export class Web3Modal extends Web3ModalScaffold { }) this.setProfileName(name) this.setProfileImage(avatar) + + if (!name) { + await this.syncWalletConnectName(address) + } } catch { if (chainId === 1) { const ensProvider = new InfuraProvider('mainnet') @@ -1115,12 +1139,14 @@ export class Web3Modal extends Web3ModalScaffold { if (name) { this.setProfileName(name) + } else { + await this.syncWalletConnectName(address) } if (avatar) { this.setProfileImage(avatar) } } else { - this.setProfileName(null) + await this.syncWalletConnectName(address) this.setProfileImage(null) } } diff --git a/packages/scaffold/index.ts b/packages/scaffold/index.ts index 981e94bcce..d212b39ee9 100644 --- a/packages/scaffold/index.ts +++ b/packages/scaffold/index.ts @@ -13,8 +13,11 @@ export * from './src/views/w3m-buy-in-progress-view/index.js' export * from './src/views/w3m-connect-view/index.js' export * from './src/views/w3m-connecting-external-view/index.js' export * from './src/views/w3m-connecting-wc-view/index.js' +export * from './src/views/w3m-choose-account-name-view/index.js' export * from './src/views/w3m-downloads-view/index.js' export * from './src/views/w3m-get-wallet-view/index.js' +export * from './src/views/w3m-register-account-name-view/index.js' +export * from './src/views/w3m-register-account-name-success-view/index.js' export * from './src/views/w3m-network-switch-view/index.js' export * from './src/views/w3m-networks-view/index.js' export * from './src/views/w3m-onramp-activity-view/index.js' diff --git a/packages/scaffold/src/client.ts b/packages/scaffold/src/client.ts index d44a16f1e8..dfb2af9c1d 100644 --- a/packages/scaffold/src/client.ts +++ b/packages/scaffold/src/client.ts @@ -25,10 +25,12 @@ import { PublicStateController, ThemeController, SnackController, - RouterController + RouterController, + EnsController } from '@web3modal/core' import { setColorTheme, setThemeVariables } from '@web3modal/ui' import type { SIWEControllerClient } from '@web3modal/siwe' +import { ConstantsUtil } from '@web3modal/common' // -- Helpers ------------------------------------------------------------------- let isInitialized = false @@ -257,6 +259,18 @@ export class Web3ModalScaffold { AccountController.setPreferredAccountType(preferredAccountType) } + protected getWalletConnectName: (typeof EnsController)['getNamesForAddress'] = address => { + return EnsController.getNamesForAddress(address) + } + + protected resolveWalletConnectName = async (name: string) => { + const trimmedName = name.replace(ConstantsUtil.WC_NAME_SUFFIX, '') + const wcNameAddress = await EnsController.resolveName(trimmedName) + const networkNameAddresses = Object.values(wcNameAddress?.addresses) || [] + + return networkNameAddresses[0]?.address || false + } + // -- Private ------------------------------------------------------------------ private async initControllers(options: ScaffoldOptions) { NetworkController.setClient(options.networkControllerClient) diff --git a/packages/scaffold/src/modal/w3m-router/index.ts b/packages/scaffold/src/modal/w3m-router/index.ts index c76fb39591..0805c9df3e 100644 --- a/packages/scaffold/src/modal/w3m-router/index.ts +++ b/packages/scaffold/src/modal/w3m-router/index.ts @@ -55,6 +55,18 @@ export class W3mRouter extends LitElement { // -- Private ------------------------------------------- // private viewTemplate() { switch (this.view) { + case 'Account': + return html`` + case 'AccountSettings': + return html`` + case 'AllWallets': + return html`` + case 'ApproveTransaction': + return html`` + case 'BuyInProgress': + return html`` + case 'ChooseAccountName': + return html`` case 'Connect': return html`` case 'ConnectingWalletConnect': @@ -63,32 +75,38 @@ export class W3mRouter extends LitElement { return html`` case 'ConnectingSiwe': return html`` - case 'AllWallets': - return html`` - case 'Networks': - return html`` - case 'SwitchNetwork': - return html`` - case 'Account': - return html`` - case 'AccountSettings': - return html`` - case 'WhatIsAWallet': - return html`` - case 'WhatIsANetwork': - return html`` - case 'GetWallet': - return html`` + case 'ConnectWallets': + return html`` + case 'ConnectSocials': + return html`` + case 'ConnectingSocial': + return html`` case 'Downloads': return html`` case 'EmailVerifyOtp': return html`` case 'EmailVerifyDevice': return html`` - case 'ApproveTransaction': - return html`` + case 'Networks': + return html`` + case 'RegisterAccountName': + return html`` + case 'RegisterAccountNameSuccess': + return html`` + case 'SwitchNetwork': + return html`` + case 'GetWallet': + return html`` case 'Transactions': return html`` + case 'OnRampProviders': + return html`` + case 'OnRampActivity': + return html`` + case 'OnRampTokenSelect': + return html`` + case 'OnRampFiatSelect': + return html`` case 'UpgradeEmailWallet': return html`` case 'UpgradeToSmartAccount': @@ -101,18 +119,6 @@ export class W3mRouter extends LitElement { return html`` case 'UnsupportedChain': return html`` - case 'OnRampProviders': - return html`` - case 'OnRampActivity': - return html`` - case 'OnRampTokenSelect': - return html`` - case 'OnRampFiatSelect': - return html`` - case 'WhatIsABuy': - return html`` - case 'BuyInProgress': - return html`` case 'WalletReceive': return html`` case 'WalletCompatibleNetworks': @@ -129,12 +135,13 @@ export class W3mRouter extends LitElement { return html`` case 'WalletSendPreview': return html`` - case 'ConnectWallets': - return html`` - case 'ConnectSocials': - return html`` - case 'ConnectingSocial': - return html`` + case 'WhatIsABuy': + return html`` + case 'WhatIsANetwork': + return html`` + case 'WhatIsAWallet': + return html`` + default: return html`` } diff --git a/packages/scaffold/src/partials/w3m-email-login-widget/index.ts b/packages/scaffold/src/partials/w3m-email-login-widget/index.ts index f76442886a..873d94aa2a 100644 --- a/packages/scaffold/src/partials/w3m-email-login-widget/index.ts +++ b/packages/scaffold/src/partials/w3m-email-login-widget/index.ts @@ -113,7 +113,7 @@ export class W3mEmailLoginWidget extends LitElement { const authConnector = ConnectorController.getAuthConnector() if (!authConnector) { - throw new Error('w3m-email-login-widget: Email connector not found') + throw new Error('w3m-email-login-widget: Auth connector not found') } const { action } = await authConnector.provider.connectEmail({ email: this.email }) EventsController.sendEvent({ type: 'track', event: 'EMAIL_SUBMITTED' }) diff --git a/packages/scaffold/src/partials/w3m-header/index.ts b/packages/scaffold/src/partials/w3m-header/index.ts index 07d6b5121b..d367178709 100644 --- a/packages/scaffold/src/partials/w3m-header/index.ts +++ b/packages/scaffold/src/partials/w3m-header/index.ts @@ -24,6 +24,7 @@ function headings() { return { Connect: `Connect ${isEmail ? 'Email' : ''} Wallet`, + ChooseAccountName: undefined, Account: undefined, AccountSettings: undefined, ConnectingExternal: name ?? 'Connect Wallet', @@ -52,6 +53,8 @@ function headings() { BuyInProgress: 'Buy', OnRampTokenSelect: 'Select Token', OnRampFiatSelect: 'Select Currency', + RegisterAccountName: 'Choose name', + RegisterAccountNameSuccess: '', WalletReceive: 'Receive', WalletCompatibleNetworks: 'Compatible Networks', Swap: 'Swap', diff --git a/packages/scaffold/src/views/w3m-account-settings-view/index.ts b/packages/scaffold/src/views/w3m-account-settings-view/index.ts index 330b2b8b3f..0fb20b4c8b 100644 --- a/packages/scaffold/src/views/w3m-account-settings-view/index.ts +++ b/packages/scaffold/src/views/w3m-account-settings-view/index.ts @@ -60,13 +60,13 @@ export class W3mAccountSettingsView extends LitElement { } else { ModalController.close() } + }), + NetworkController.subscribeKey('caipNetwork', val => { + if (val?.id) { + this.network = val + } }) - ], - NetworkController.subscribeKey('caipNetwork', val => { - if (val?.id) { - this.network = val - } - }) + ] ) } @@ -92,24 +92,17 @@ export class W3mAccountSettingsView extends LitElement { - ${this.profileName - ? UiHelperUtil.getTruncateString({ - string: this.profileName, - charsStart: 20, - charsEnd: 0, - truncate: 'end' - }) - : UiHelperUtil.getTruncateString({ - string: this.address, - charsStart: 4, - charsEnd: 6, - truncate: 'middle' - })} + ${UiHelperUtil.getTruncateString({ + string: this.address, + charsStart: 4, + charsEnd: 6, + truncate: 'middle' + })} - ${this.togglePreferredAccountBtnTemplate()} + ${this.togglePreferredAccountBtnTemplate()} ${this.chooseNameButtonTemplate()} + Choose account name + + ` + } + private isAllowedNetworkSwitch() { const { requestedCaipNetworks } = NetworkController.state const isMultiNetwork = requestedCaipNetworks ? requestedCaipNetworks.length > 1 : false @@ -228,6 +244,10 @@ export class W3mAccountSettingsView extends LitElement { ` } + private onChooseName() { + RouterController.push('ChooseAccountName') + } + private async changePreferredAccountType() { const smartAccountEnabled = NetworkController.checkIfSmartAccountEnabled() const accountTypeTarget = diff --git a/packages/scaffold/src/views/w3m-account-view/index.ts b/packages/scaffold/src/views/w3m-account-view/index.ts index 1dc8accc41..7f45027ccd 100644 --- a/packages/scaffold/src/views/w3m-account-view/index.ts +++ b/packages/scaffold/src/views/w3m-account-view/index.ts @@ -5,6 +5,7 @@ import { LitElement, html } from 'lit' @customElement('w3m-account-view') export class W3mAccountView extends LitElement { // -- Render -------------------------------------------- // + public override render() { const type = StorageUtil.getConnectedConnector() diff --git a/packages/scaffold/src/views/w3m-choose-account-name-view/index.ts b/packages/scaffold/src/views/w3m-choose-account-name-view/index.ts new file mode 100644 index 0000000000..2440fdba5f --- /dev/null +++ b/packages/scaffold/src/views/w3m-choose-account-name-view/index.ts @@ -0,0 +1,88 @@ +import { customElement } from '@web3modal/ui' +import { CoreHelperUtil, RouterController } from '@web3modal/core' +import { LitElement, html } from 'lit' +import { state } from 'lit/decorators.js' +import styles from './styles.js' +import { NavigationUtil } from '@web3modal/common' + +@customElement('w3m-choose-account-name-view') +export class W3mChooseAccountNameView extends LitElement { + public static override styles = styles + + // -- State & Properties -------------------------------- // + @state() private loading = false + + // -- Render -------------------------------------------- // + public override render() { + return html` + + ${this.onboardingTemplate()} ${this.buttonsTemplate()} + { + CoreHelperUtil.openHref(NavigationUtil.URLS.FAQ, '_blank') + }} + > + Learn more about names + + + + ` + } + + // -- Private ------------------------------------------- // + private onboardingTemplate() { + return html` + + + + + + Choose your account name + + + Finally say goodbye to 0x addresses, name your account to make it easier to exchange + assets + + + ` + } + + private buttonsTemplate() { + return html` + RouterController.push('RegisterAccountName')} + >Choose name + + ` + } +} + +declare global { + interface HTMLElementTagNameMap { + 'w3m-choose-account-name-view': W3mChooseAccountNameView + } +} diff --git a/packages/scaffold/src/views/w3m-choose-account-name-view/styles.ts b/packages/scaffold/src/views/w3m-choose-account-name-view/styles.ts new file mode 100644 index 0000000000..f848485683 --- /dev/null +++ b/packages/scaffold/src/views/w3m-choose-account-name-view/styles.ts @@ -0,0 +1,7 @@ +import { css } from 'lit' + +export default css` + .continue-button-container { + width: 100%; + } +` diff --git a/packages/scaffold/src/views/w3m-email-verify-device-view/index.ts b/packages/scaffold/src/views/w3m-email-verify-device-view/index.ts index ba0ea6ba99..7ba24d8c3c 100644 --- a/packages/scaffold/src/views/w3m-email-verify-device-view/index.ts +++ b/packages/scaffold/src/views/w3m-email-verify-device-view/index.ts @@ -32,7 +32,7 @@ export class W3mEmailVerifyDeviceView extends LitElement { throw new Error('w3m-email-verify-device-view: No email provided') } if (!this.authConnector) { - throw new Error('w3m-email-verify-device-view: No email connector provided') + throw new Error('w3m-email-verify-device-view: No auth connector provided') } return html` diff --git a/packages/scaffold/src/views/w3m-register-account-name-success-view/index.ts b/packages/scaffold/src/views/w3m-register-account-name-success-view/index.ts new file mode 100644 index 0000000000..b7f42aed8d --- /dev/null +++ b/packages/scaffold/src/views/w3m-register-account-name-success-view/index.ts @@ -0,0 +1,82 @@ +import { customElement } from '@web3modal/ui' +import { CoreHelperUtil, RouterController } from '@web3modal/core' +import { LitElement, html } from 'lit' +import styles from './styles.js' +import { NavigationUtil } from '@web3modal/common' + +@customElement('w3m-register-account-name-success-view') +export class W3mRegisterAccountNameSuccess extends LitElement { + public static override styles = styles + + // -- Render -------------------------------------------- // + public override render() { + return html` + + ${this.onboardingTemplate()} ${this.buttonsTemplate()} + { + CoreHelperUtil.openHref(NavigationUtil.URLS.FAQ, '_blank') + }} + > + Learn more + + + + ` + } + + // -- Private ------------------------------------------- // + private onboardingTemplate() { + return html` + + + + + + Account name chosen successfully + + + You can now fund your account and trade crypto + + + ` + } + + private buttonsTemplate() { + return html` + Let's Go! + + ` + } + + private redirectToAccount() { + RouterController.replace('Account') + } +} + +declare global { + interface HTMLElementTagNameMap { + 'w3m-register-account-name-success-view': W3mRegisterAccountNameSuccess + } +} diff --git a/packages/scaffold/src/views/w3m-register-account-name-success-view/styles.ts b/packages/scaffold/src/views/w3m-register-account-name-success-view/styles.ts new file mode 100644 index 0000000000..f848485683 --- /dev/null +++ b/packages/scaffold/src/views/w3m-register-account-name-success-view/styles.ts @@ -0,0 +1,7 @@ +import { css } from 'lit' + +export default css` + .continue-button-container { + width: 100%; + } +` diff --git a/packages/scaffold/src/views/w3m-register-account-name-view/index.ts b/packages/scaffold/src/views/w3m-register-account-name-view/index.ts new file mode 100644 index 0000000000..c571f8dd2f --- /dev/null +++ b/packages/scaffold/src/views/w3m-register-account-name-view/index.ts @@ -0,0 +1,192 @@ +import { customElement } from '@web3modal/ui' +import { LitElement, html } from 'lit' +import { property, state } from 'lit/decorators.js' +import styles from './styles.js' +import { createRef, ref, type Ref } from 'lit/directives/ref.js' +import { CoreHelperUtil, SnackController, EnsController } from '@web3modal/core' + +@customElement('w3m-register-account-name-view') +export class W3mRegisterAccountNameView extends LitElement { + public static override styles = styles + + // -- Members ------------------------------------------- // + private formRef: Ref = createRef() + private usubscribe: (() => void)[] = [] + + // -- State & Properties -------------------------------- // + @property() public errorMessage?: string + + @state() private name = '' + + @state() private error = '' + + @state() private loading = EnsController.state.loading + + @state() private suggestions = EnsController.state.suggestions + + @state() private registered = false + + public constructor() { + super() + this.usubscribe.push( + ...[ + EnsController.subscribe(val => { + this.suggestions = val.suggestions + this.loading = val.loading + }) + ] + ) + } + + // -- Lifecycle ----------------------------------------- // + public override firstUpdated() { + this.formRef.value?.addEventListener('keydown', this.onEnterKey.bind(this)) + } + + public override disconnectedCallback() { + super.disconnectedCallback() + this.usubscribe.forEach(unsub => unsub()) + this.formRef.value?.removeEventListener('keydown', this.onEnterKey.bind(this)) + } + + // -- Render -------------------------------------------- // + public override render() { + return html` + +
+ + + ${this.submitButtonTemplate()} + +
+ ${this.templateSuggestions()} +
+ ` + } + + // -- Private ------------------------------------------- // + private submitButtonTemplate() { + const showSubmit = this.isAllowedToSubmit() + + return showSubmit + ? html` + + + ` + : null + } + + private onDebouncedNameInputChange = CoreHelperUtil.debounce((value: string) => { + if (EnsController.validateName(value)) { + this.error = '' + this.name = value + EnsController.getSuggestions(value) + EnsController.isNameRegistered(value).then(registered => { + this.registered = registered + }) + } else if (value.length < 4) { + this.error = 'Name must be at least 4 characters long' + } else { + this.error = 'Can only contain letters, numbers and - characters' + } + }) + + private onSelectSuggestion(name: string) { + return () => { + this.name = name + this.registered = false + this.requestUpdate() + } + } + + private onNameInputChange(event: CustomEvent) { + this.onDebouncedNameInputChange(event.detail) + } + + private nameSuggestionTagTemplate() { + if (this.loading) { + return html`` + } + + return this.registered + ? html`Registered` + : html`Available` + } + + private templateSuggestions() { + if (!this.name || this.name.length < 4 || this.error) { + return null + } + + const suggestions = this.registered ? this.suggestions.filter(s => s.name !== this.name) : [] + + return html` + + + ${this.name}${this.nameSuggestionTagTemplate()} + + ${suggestions.map(suggestion => this.availableNameTemplate(suggestion.name))} + ` + } + + private availableNameTemplate(suggestion: string) { + return html` + + ${suggestion} + + Available + ` + } + + private isAllowedToSubmit() { + return !this.loading && !this.registered && !this.error && EnsController.validateName(this.name) + } + + private async onSubmitName() { + try { + if (!this.isAllowedToSubmit()) { + return + } + await EnsController.registerName(this.name) + } catch (error) { + SnackController.showError((error as Error).message) + } + } + + private onEnterKey(event: KeyboardEvent) { + if (event.key === 'Enter' && this.isAllowedToSubmit()) { + this.onSubmitName() + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'w3m-register-account-name-view': W3mRegisterAccountNameView + } +} diff --git a/packages/scaffold/src/views/w3m-register-account-name-view/styles.ts b/packages/scaffold/src/views/w3m-register-account-name-view/styles.ts new file mode 100644 index 0000000000..714d8c8139 --- /dev/null +++ b/packages/scaffold/src/views/w3m-register-account-name-view/styles.ts @@ -0,0 +1,34 @@ +import { css } from 'lit' + +export default css` + wui-flex { + width: 100%; + } + + .suggestion { + background: var(--wui-gray-glass-002); + border-radius: var(--wui-border-radius-xs); + } + + .suggestion:hover { + background-color: var(--wui-gray-glass-005); + cursor: pointer; + } + + .suggested-name { + max-width: 75%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + form { + width: 100%; + } + + wui-icon-link { + position: absolute; + right: 20px; + transform: translateY(11px); + } +` diff --git a/packages/scaffold/src/views/w3m-update-email-wallet-view/index.ts b/packages/scaffold/src/views/w3m-update-email-wallet-view/index.ts index 03b70b54fa..90534511b8 100644 --- a/packages/scaffold/src/views/w3m-update-email-wallet-view/index.ts +++ b/packages/scaffold/src/views/w3m-update-email-wallet-view/index.ts @@ -81,7 +81,7 @@ export class W3mUpdateEmailWalletView extends LitElement { const authConnector = ConnectorController.getAuthConnector() if (!authConnector) { - throw new Error('w3m-update-email-wallet: Email connector not found') + throw new Error('w3m-update-email-wallet: Auth connector not found') } const response = await authConnector.provider.updateEmail({ email: this.email }) diff --git a/packages/scaffold/src/views/w3m-upgrade-to-smart-account-view/index.ts b/packages/scaffold/src/views/w3m-upgrade-to-smart-account-view/index.ts index 30f76eecfe..f798ba32cc 100644 --- a/packages/scaffold/src/views/w3m-upgrade-to-smart-account-view/index.ts +++ b/packages/scaffold/src/views/w3m-upgrade-to-smart-account-view/index.ts @@ -4,12 +4,14 @@ import { ConnectorController, ModalController, RouterController, + CoreHelperUtil, RouterUtil, SnackController } from '@web3modal/core' import { LitElement, html } from 'lit' import { state } from 'lit/decorators.js' import { W3mFrameRpcConstants } from '@web3modal/wallet' +import { NavigationUtil } from '@web3modal/common' @customElement('w3m-upgrade-to-smart-account-view') export class W3mUpgradeToSmartAccountView extends LitElement { @@ -28,7 +30,11 @@ export class W3mUpgradeToSmartAccountView extends LitElement { .padding=${['0', '0', 'l', '0'] as const} > ${this.onboardingTemplate()} ${this.buttonsTemplate()} - + { + CoreHelperUtil.openHref(NavigationUtil.URLS.FAQ, '_blank') + }} + > Learn more diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 9abcd76026..707c479cd8 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -23,6 +23,7 @@ export * from './src/composites/wui-cta-button/index.js' export * from './src/composites/wui-details-group/index.js' export * from './src/composites/wui-details-group-item/index.js' export * from './src/composites/wui-email-input/index.js' +export * from './src/composites/wui-ens-input/index.js' export * from './src/composites/wui-icon-box/index.js' export * from './src/composites/wui-icon-link/index.js' export * from './src/composites/wui-input-element/index.js' diff --git a/packages/ui/src/assets/svg/checkmark.ts b/packages/ui/src/assets/svg/checkmark.ts index f790d802d4..59f021c071 100644 --- a/packages/ui/src/assets/svg/checkmark.ts +++ b/packages/ui/src/assets/svg/checkmark.ts @@ -1,16 +1,13 @@ import { svg } from 'lit' export const checkmarkSvg = svg` + width="28" + height="28" + viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg"> ` + d="M25.5297 4.92733C26.1221 5.4242 26.1996 6.30724 25.7027 6.89966L12.2836 22.8997C12.0316 23.2001 11.6652 23.3811 11.2735 23.3986C10.8817 23.4161 10.5006 23.2686 10.2228 22.9919L2.38218 15.1815C1.83439 14.6358 1.83268 13.7494 2.37835 13.2016C2.92403 12.6538 3.81046 12.6521 4.35825 13.1978L11.1183 19.9317L23.5573 5.10036C24.0542 4.50794 24.9372 4.43047 25.5297 4.92733Z" + fill="#26D962"/> + +` diff --git a/packages/ui/src/assets/svg/id.ts b/packages/ui/src/assets/svg/id.ts new file mode 100644 index 0000000000..303b387b19 --- /dev/null +++ b/packages/ui/src/assets/svg/id.ts @@ -0,0 +1,14 @@ +import { svg } from 'lit' + +export const idSvg = svg` + +` diff --git a/packages/ui/src/components/wui-icon/index.ts b/packages/ui/src/components/wui-icon/index.ts index 9080a439bb..8f395af425 100644 --- a/packages/ui/src/components/wui-icon/index.ts +++ b/packages/ui/src/components/wui-icon/index.ts @@ -72,6 +72,7 @@ import { walletPlaceholderSvg } from '../../assets/svg/wallet-placeholder.js' import { walletSvg } from '../../assets/svg/wallet.js' import { walletConnectSvg } from '../../assets/svg/walletconnect.js' import { warningCircleSvg } from '../../assets/svg/warning-circle.js' +import { idSvg } from '../../assets/svg/id.js' import { xSvg } from '../../assets/svg/x.js' const svgOptions: Record> = { @@ -112,6 +113,7 @@ const svgOptions: Record> = { github: githubSvg, google: googleSvg, helpCircle: helpCircleSvg, + id: idSvg, infoCircle: infoCircleSvg, mail: mailSvg, mobile: mobileSvg, diff --git a/packages/ui/src/composites/wui-ens-input/index.ts b/packages/ui/src/composites/wui-ens-input/index.ts new file mode 100644 index 0000000000..64373d2210 --- /dev/null +++ b/packages/ui/src/composites/wui-ens-input/index.ts @@ -0,0 +1,68 @@ +import { html, LitElement } from 'lit' +import { property } from 'lit/decorators.js' +import '../../components/wui-icon/index.js' +import '../../components/wui-text/index.js' +import { resetStyles } from '../../utils/ThemeUtil.js' +import { customElement } from '../../utils/WebComponentsUtil.js' +import '../wui-input-text/index.js' +import styles from './styles.js' +import { ifDefined } from 'lit/directives/if-defined.js' +import { ConstantsUtil } from '@web3modal/common' + +@customElement('wui-ens-input') +export class WuiEnsInput extends LitElement { + public static override styles = [resetStyles, styles] + + // -- State & Properties -------------------------------- // + @property() public errorMessage?: string + + @property({ type: Boolean }) public disabled = false + + @property() public value?: string + + @property({ type: Boolean }) public loading = false + + // -- Render -------------------------------------------- // + public override render() { + return html` + + ${this.baseNameTemplate()} ${this.errorTemplate()}${this.loadingTemplate()} + + ` + } + + // -- Private ------------------------------------------- // + private baseNameTemplate() { + return html` + ${ConstantsUtil.WC_NAME_SUFFIX} + ` + } + + private loadingTemplate() { + return this.loading + ? html`` + : null + } + + private errorTemplate() { + if (this.errorMessage) { + return html`${this.errorMessage}` + } + + return null + } +} + +declare global { + interface HTMLElementTagNameMap { + 'wui-ens-input': WuiEnsInput + } +} diff --git a/packages/ui/src/composites/wui-ens-input/styles.ts b/packages/ui/src/composites/wui-ens-input/styles.ts new file mode 100644 index 0000000000..70e4f83b29 --- /dev/null +++ b/packages/ui/src/composites/wui-ens-input/styles.ts @@ -0,0 +1,21 @@ +import { css } from 'lit' + +export default css` + :host { + position: relative; + width: 100%; + display: inline-block; + color: var(--wui-color-fg-275); + } + + .error { + margin: var(--wui-spacing-xxs) var(--wui-spacing-m) var(--wui-spacing-0) var(--wui-spacing-m); + } + + .base-name { + position: absolute; + right: 45px; + top: 15px; + text-align: right; + } +` diff --git a/packages/ui/src/composites/wui-input-text/index.ts b/packages/ui/src/composites/wui-input-text/index.ts index 00adfe66c3..37d57f0d01 100644 --- a/packages/ui/src/composites/wui-input-text/index.ts +++ b/packages/ui/src/composites/wui-input-text/index.ts @@ -2,9 +2,10 @@ import { html, LitElement } from 'lit' import { property } from 'lit/decorators.js' import { ifDefined } from 'lit/directives/if-defined.js' import { createRef, ref } from 'lit/directives/ref.js' +import { classMap } from 'lit/directives/class-map.js' import '../../components/wui-icon/index.js' import { elementStyles, resetStyles } from '../../utils/ThemeUtil.js' -import type { IconType, InputType, SizeType } from '../../utils/TypeUtil.js' +import type { IconType, InputType, SizeType, SpacingType } from '../../utils/TypeUtil.js' import { customElement } from '../../utils/WebComponentsUtil.js' import styles from './styles.js' @@ -30,14 +31,21 @@ export class WuiInputText extends LitElement { @property() public value?: string = '' + @property() public inputRightPadding?: SpacingType + // -- Render -------------------------------------------- // public override render() { + const inputClass = `wui-padding-right-${this.inputRightPadding}` const sizeClass = `wui-size-${this.size}` + const classes = { + [sizeClass]: true, + [inputClass]: Boolean(this.inputRightPadding) + } - return html` ${this.templateIcon()} + return html`${this.templateIcon()} = 'md' + @property() public size: Exclude = 'md' @property() public name = 'uknown' diff --git a/packages/ui/src/composites/wui-tag/styles.ts b/packages/ui/src/composites/wui-tag/styles.ts index 7679ca4c93..68f73193d1 100644 --- a/packages/ui/src/composites/wui-tag/styles.ts +++ b/packages/ui/src/composites/wui-tag/styles.ts @@ -35,7 +35,7 @@ export default css` } :host([data-size='lg']) { - padding: 9px 5px !important; + padding: 11px 5px !important; } :host([data-size='lg']) > wui-text { diff --git a/packages/ui/src/utils/JSXTypeUtil.ts b/packages/ui/src/utils/JSXTypeUtil.ts index aad314463e..26a4be9308 100644 --- a/packages/ui/src/utils/JSXTypeUtil.ts +++ b/packages/ui/src/utils/JSXTypeUtil.ts @@ -24,6 +24,7 @@ import type { WuiCtaButton } from '../composites/wui-cta-button/index.js' import type { WuiDetailsGroup } from '../composites/wui-details-group/index.js' import type { WuiDetailsGroupItem } from '../composites/wui-details-group-item/index.js' import type { WuiEmailInput } from '../composites/wui-email-input/index.js' +import type { WuiEnsInput } from '../composites/wui-ens-input/index.js' import type { WuiIconBox } from '../composites/wui-icon-box/index.js' import type { WuiIconLink } from '../composites/wui-icon-link/index.js' import type { WuiInputAmount } from '../composites/wui-input-amount/index.js' @@ -104,6 +105,7 @@ declare global { 'wui-details-group-item': CustomElement 'wui-details-group': CustomElement 'wui-email-input': CustomElement + 'wui-ens-input': CustomElement 'wui-icon-box': CustomElement 'wui-icon-link': CustomElement 'wui-input-amount': CustomElement diff --git a/packages/ui/src/utils/ThemeUtil.ts b/packages/ui/src/utils/ThemeUtil.ts index 4497e30cdc..20d0824800 100644 --- a/packages/ui/src/utils/ThemeUtil.ts +++ b/packages/ui/src/utils/ThemeUtil.ts @@ -114,6 +114,7 @@ function createRootStyles(themeVariables?: ThemeVariables) { --wui-spacing-2xl: 32px; --wui-spacing-3xl: 40px; --wui-spacing-4xl: 90px; + --wui-spacing-5xl: 95px; --wui-icon-box-size-xxs: 14px; --wui-icon-box-size-xs: 20px; @@ -130,6 +131,7 @@ function createRootStyles(themeVariables?: ThemeVariables) { --wui-icon-size-mdl: 18px; --wui-icon-size-lg: 20px; --wui-icon-size-xl: 24px; + --wui-icon-size-xxl: 28px; --wui-wallet-image-size-inherit: inherit; --wui-wallet-image-size-sm: 40px; diff --git a/packages/ui/src/utils/TypeUtil.ts b/packages/ui/src/utils/TypeUtil.ts index bc1fb23370..a3639650a0 100644 --- a/packages/ui/src/utils/TypeUtil.ts +++ b/packages/ui/src/utils/TypeUtil.ts @@ -37,7 +37,7 @@ export type TextType = export type TextAlign = 'center' | 'left' | 'right' -export type SizeType = 'inherit' | 'xl' | 'lg' | 'md' | 'mdl' | 'sm' | 'xs' | 'xxs' +export type SizeType = 'inherit' | 'xl' | 'lg' | 'md' | 'mdl' | 'sm' | 'xs' | 'xxs' | 'xxl' export type SpacingType = | '0' @@ -45,6 +45,7 @@ export type SpacingType = | '2xl' | '3xl' | '4xl' + | '5xl' | '3xs' | '4xs' | 'l' @@ -132,6 +133,7 @@ export type IconType = | 'github' | 'google' | 'helpCircle' + | 'id' | 'infoCircle' | 'mail' | 'mobile' diff --git a/packages/wagmi/src/client.ts b/packages/wagmi/src/client.ts index 63f82b7fa6..70e0a827a7 100644 --- a/packages/wagmi/src/client.ts +++ b/packages/wagmi/src/client.ts @@ -20,7 +20,7 @@ import { import { mainnet } from 'viem/chains' import { prepareTransactionRequest, sendTransaction as wagmiSendTransaction } from '@wagmi/core' import type { Chain } from '@wagmi/core/chains' -import type { GetAccountReturnType } from '@wagmi/core' +import type { GetAccountReturnType, GetEnsAddressReturnType } from '@wagmi/core' import type { CaipAddress, CaipNetwork, @@ -40,6 +40,7 @@ import type { Hex } from 'viem' import { Web3ModalScaffold } from '@web3modal/scaffold' import type { Web3ModalSIWEClient } from '@web3modal/siwe' import { ConstantsUtil, PresetsUtil, HelpersUtil } from '@web3modal/scaffold-utils' +import { ConstantsUtil as CommonConstants } from '@web3modal/common' import { getCaipDefaultChain, getEmailCaipNetworks, @@ -297,18 +298,26 @@ export class Web3Modal extends Web3ModalScaffold { }, getEnsAddress: async (value: string) => { - const chainId = NetworkUtil.caipNetworkIdToNumber(this.getCaipNetwork()?.id) + try { + const chainId = NetworkUtil.caipNetworkIdToNumber(this.getCaipNetwork()?.id) + let ensName: boolean | GetEnsAddressReturnType = false + let wcName: boolean | string = false - if (chainId !== mainnet.id) { - return false - } + if (value?.endsWith(CommonConstants.WC_NAME_SUFFIX)) { + wcName = await this.resolveWalletConnectName(value) + } - const address = await wagmiGetEnsAddress(this.wagmiConfig, { - name: normalize(value), - chainId - }) + if (chainId === mainnet.id) { + ensName = await wagmiGetEnsAddress(this.wagmiConfig, { + name: normalize(value), + chainId + }) + } - return address || false + return ensName || wcName || false + } catch { + return false + } }, getEnsAvatar: async (value: string) => { @@ -447,6 +456,20 @@ export class Web3Modal extends Web3ModalScaffold { } } + private async syncWalletConnectName(address: Hex) { + try { + const registeredWcNames = await this.getWalletConnectName(address) + if (registeredWcNames[0]) { + const wcName = registeredWcNames[0] + this.setProfileName(wcName.name) + } else { + this.setProfileName(null) + } + } catch { + this.setProfileName(null) + } + } + private async syncProfile(address: Hex, chainId: Chain['id']) { try { const { name, avatar } = await this.fetchIdentity({ @@ -454,6 +477,10 @@ export class Web3Modal extends Web3ModalScaffold { }) this.setProfileName(name) this.setProfileImage(avatar) + + if (!name) { + await this.syncWalletConnectName(address) + } } catch { if (chainId === mainnet.id) { const profileName = await getEnsName(this.wagmiConfig, { address, chainId }) @@ -466,9 +493,12 @@ export class Web3Modal extends Web3ModalScaffold { if (profileImage) { this.setProfileImage(profileImage) } + } else { + await this.syncWalletConnectName(address) + this.setProfileImage(null) } } else { - this.setProfileName(null) + await this.syncWalletConnectName(address) this.setProfileImage(null) } }