diff --git a/.changeset/web3-wallets-wire-up.md b/.changeset/web3-wallets-wire-up.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/web3-wallets-wire-up.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 52c5fa7fd5f..205f578221e 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -64,7 +64,8 @@ }, "dependencies": { "@clerk/shared": "workspace:^", - "@floating-ui/react": "catalog:repo" + "@floating-ui/react": "catalog:repo", + "@wallet-standard/core": "catalog:module-manager" }, "devDependencies": { "@clerk/clerk-js": "workspace:^", diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi-user.feature.test.tsx b/packages/mosaic/src/__tests__/feature/fake-fapi-user.feature.test.tsx new file mode 100644 index 00000000000..ea046f5df0b --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fake-fapi-user.feature.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { fapiUrl, serveFapi } from './fake-fapi'; +import { fapiClient, fapiSession, fapiUser, fapiWeb3Wallet } from './fapi'; + +describe('fake FAPI user updates', () => { + it('updates profile fields without clearing omitted identification fields', async () => { + const user = fapiUser({ + id: 'user_1', + first_name: 'Austin', + last_name: 'Original', + username: 'austin', + primary_web3_wallet_id: 'wallet_1', + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234' })], + }); + const fapi = serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); + + const response = await fetch(`${fapiUrl('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/v1/me')}?_method=PATCH`, { + method: 'POST', + body: new URLSearchParams({ first_name: 'Updated', last_name: 'Name', username: 'updated' }), + }); + + expect(response.ok).toBe(true); + expect(fapi.client.sessions[0]?.user).toMatchObject({ + first_name: 'Updated', + last_name: 'Name', + username: 'updated', + primary_web3_wallet_id: 'wallet_1', + web3_wallets: user.web3_wallets, + }); + }); +}); diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index d6f0cb70c51..98c916b1c58 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -9,6 +9,7 @@ import type { SessionJSON, UserJSON, UserOrganizationInvitationJSON, + Web3WalletJSON, } from '@clerk/shared/types'; import { http, HttpResponse } from 'msw'; import { setupWorker } from 'msw/browser'; @@ -33,6 +34,7 @@ import { fapiPage, fapiToken, fapiVerification, + fapiWeb3Wallet, } from './fapi'; export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k'; @@ -121,6 +123,20 @@ function deleteOrganization(state: FakeFapiState, organizationId: string): void }; } +function findWeb3Wallet(state: FakeFapiState, id: unknown): Web3WalletJSON | undefined { + return activeUser(state)?.web3_wallets.find(wallet => wallet.id === id); +} + +function updateWeb3Wallet(state: FakeFapiState, wallet: Web3WalletJSON): void { + const user = activeUser(state); + if (user) { + updateUser(state, { + ...user, + web3_wallets: user.web3_wallets.map(current => (current.id === wallet.id ? wallet : current)), + }); + } +} + export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { const { verification, enterpriseLinking, passkeys, ...rest } = seed; const state: FakeFapiState = { @@ -221,6 +237,107 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { updateUser(state, { ...user, external_accounts: user.external_accounts.filter(item => item.id !== account.id) }); return envelope({ ...account, object: 'external_account' }, state.client); }), + http.post(fapiUrl('/v1/me/web3_wallets'), async ({ request }) => { + const user = activeUser(state); + if (!user) { + return missing(); + } + const body = new URLSearchParams(await request.text()); + const address = body.get('web3_wallet'); + if (!address) { + return missing(); + } + const wallet = fapiWeb3Wallet({ + id: `wallet_${user.web3_wallets.length + 1}`, + web3_wallet: address, + verification: fapiVerification('', { expire_at: 0 }), + }); + updateUser(state, { ...user, web3_wallets: [...user.web3_wallets, wallet] }); + return envelope(wallet, state.client); + }), + http.post(fapiUrl('/v1/me/web3_wallets/:id/prepare_verification'), async ({ params, request }) => { + const wallet = findWeb3Wallet(state, params.id); + if (!wallet) { + return missing(); + } + const body = new URLSearchParams(await request.text()); + const prepared = fapiWeb3Wallet({ + ...wallet, + verification: fapiVerification(body.get('strategy') ?? '', { + message: 'Sign this wallet challenge', + expire_at: 0, + }), + }); + updateWeb3Wallet(state, prepared); + return envelope(prepared, state.client); + }), + http.post(fapiUrl('/v1/me/web3_wallets/:id/attempt_verification'), ({ params }) => { + const wallet = findWeb3Wallet(state, params.id); + if (!wallet) { + return missing(); + } + const verified = fapiWeb3Wallet({ + ...wallet, + verification: fapiVerification(wallet.verification?.strategy ?? '', { + ...wallet.verification, + status: 'verified', + verified_at_client: '', + attempts: 1, + }), + }); + updateWeb3Wallet(state, verified); + const user = activeUser(state); + if (user && !user.primary_web3_wallet_id) { + updateUser(state, { ...user, primary_web3_wallet_id: verified.id }); + } + return envelope(verified, state.client); + }), + http.post(fapiUrl('/v1/me/web3_wallets/:id'), ({ params, request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'DELETE') { + return undefined; + } + const user = activeUser(state); + const wallet = findWeb3Wallet(state, params.id); + if (!user || !wallet) { + return missing(); + } + const remainingWallets = user.web3_wallets.filter(current => current.id !== wallet.id); + updateUser(state, { + ...user, + web3_wallets: remainingWallets, + primary_web3_wallet_id: + user.primary_web3_wallet_id === wallet.id + ? (remainingWallets.find(current => current.verification?.status === 'verified')?.id ?? null) + : user.primary_web3_wallet_id, + }); + return envelope({ object: 'web3_wallet', id: wallet.id, deleted: true }, state.client); + }), + http.post(fapiUrl('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/v1/me'), async ({ request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'PATCH') { + return undefined; + } + const user = activeUser(state); + if (!user) { + return missing(); + } + const body = new URLSearchParams(await request.text()); + const updated = { ...user }; + const fields = [ + 'first_name', + 'last_name', + 'username', + 'primary_email_address_id', + 'primary_phone_number_id', + 'primary_web3_wallet_id', + ] as const; + for (const field of fields) { + if (body.has(field)) { + updated[field] = body.get(field); + } + } + updateUser(state, updated); + return envelope(updated, state.client); + }), http.post(fapiUrl('/v1/client/sessions/:id/tokens'), ({ params }) => { const session = findSession(state, params.id); return session diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index 0c0744ea63d..9c562790ce5 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -26,11 +26,20 @@ import type { UserOrganizationInvitationJSON, UserSettingsJSON, VerificationJSON, + Web3Strategy, + Web3WalletJSON, } from '@clerk/shared/types'; type Settings = Omit; -export type FapiUserSettings = Omit, 'social'> & { social: Partial }; +type FapiWeb3Attribute = Omit & { + first_factors: Array; +}; + +export type FapiUserSettings = Omit, 'social' | 'attributes'> & { + social: Partial; + attributes: Omit & { web3_wallet: FapiWeb3Attribute }; +}; export type FapiEnvironment = Omit & { user_settings: FapiUserSettings; @@ -354,6 +363,21 @@ export function fapiEnterpriseAccount( }; } +export function fapiWeb3Wallet( + overrides: Partial & Pick, +): Web3WalletJSON { + return { + object: 'web3_wallet', + verification: fapiVerification('web3_metamask_signature', { + status: 'verified', + verified_at_client: '', + attempts: 1, + expire_at: 0, + }), + ...overrides, + }; +} + export function fapiSession(overrides: Partial & Pick): SessionJSON { const { user } = overrides; const primaryEmail = user.email_addresses.find(email => email.id === user.primary_email_address_id); diff --git a/packages/mosaic/src/components/action-menu/action-menu.test.tsx b/packages/mosaic/src/components/action-menu/action-menu.test.tsx index f30098d6b3a..8786eee5e68 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.test.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.test.tsx @@ -36,4 +36,35 @@ describe('Mosaic ActionMenu', () => { expect(onClick).toHaveBeenCalledOnce(); }); + it('disables both the trigger and existing menu items while busy', async () => { + const onClick = vi.fn(); + const actions = [{ label: 'Remove', onClick }]; + const view = render( + + + , + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Manage wallet' })); + expect(await screen.findByRole('menuitem', { name: 'Remove' })).toBeInTheDocument(); + + view.rerender( + + + , + ); + + expect(screen.getByRole('button', { name: 'Manage wallet' })).toBeDisabled(); + const remove = screen.getByRole('menuitem', { name: 'Remove' }); + expect(remove).toHaveAttribute('aria-disabled', 'true'); + await user.click(remove); + expect(onClick).not.toHaveBeenCalled(); + }); }); diff --git a/packages/mosaic/src/components/action-menu/action-menu.tsx b/packages/mosaic/src/components/action-menu/action-menu.tsx index 480c4655625..2cd36e9fa0b 100644 --- a/packages/mosaic/src/components/action-menu/action-menu.tsx +++ b/packages/mosaic/src/components/action-menu/action-menu.tsx @@ -15,9 +15,10 @@ export interface ActionMenuProps { label: string; actions: ActionMenuAction[]; triggerRef?: Ref; + disabled?: boolean; } -export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) { +export function ActionMenu({ label, actions, triggerRef, disabled }: ActionMenuProps) { if (actions.length === 0) { return null; } @@ -27,6 +28,7 @@ export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) { {actions.map(action => ( @@ -35,6 +37,7 @@ export function ActionMenu({ label, actions, triggerRef }: ActionMenuProps) { color={action.color} label={action.label} onClick={action.onClick} + disabled={disabled} > {action.icon ? ( diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx deleted file mode 100644 index cbc866af4ee..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-actions.test.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { createDeferredPromise } from '@clerk/shared/utils'; -import { act, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; - -import { clerkApiError } from '../../../__tests__/clerk-errors'; -import { MosaicProvider } from '../../../mosaic-provider'; -import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; - -const wallet = { id: 'wallet_1', provider: 'MetaMask', address: 'test', isVerified: true }; - -function renderWallets(onRemove: (id: string) => void | Promise) { - return render( - - - , - ); -} - -async function openRemoval(user: ReturnType) { - await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); -} - -describe('Web3 wallet removal', () => { - it('returns keyboard focus to the menu when canceled', async () => { - const user = userEvent.setup(); - const onRemove = vi.fn(); - renderWallets(onRemove); - const trigger = screen.getByRole('button', { name: 'Manage MetaMask' }); - trigger.focus(); - await user.keyboard('{Enter}'); - await screen.findByRole('menuitem', { name: 'Remove wallet' }); - await user.keyboard('{ArrowDown}{Enter}'); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); - await user.keyboard('{Escape}'); - await waitFor(() => expect(trigger).toHaveFocus()); - expect(onRemove).not.toHaveBeenCalled(); - }); - - it('focuses the remaining wallet and then Connect when the caller removes rows', async () => { - const user = userEvent.setup(); - function Example() { - const [wallets, setWallets] = useState([wallet, { ...wallet, id: 'second', provider: 'Coinbase Wallet' }]); - return ( - - {}} - onRemove={id => setWallets(current => current.filter(item => item.id !== id))} - /> - - ); - } - render(); - await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })).toHaveFocus()); - await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Connect Other' })).toHaveFocus()); - }); - - it('keeps confirmation open while pending and allows retrying a rejected removal', async () => { - const user = userEvent.setup(); - const removal = createDeferredPromise(); - const onRemove = vi - .fn() - .mockImplementationOnce(async () => { - await removal.promise; - }) - .mockResolvedValue(undefined); - renderWallets(onRemove); - await openRemoval(user); - const dialog = screen.getByRole('alertdialog'); - const remove = within(dialog).getByRole('button', { name: 'Remove', exact: true }); - await user.click(remove); - expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_1'); - expect(dialog).toBeInTheDocument(); - expect(remove).toHaveAttribute('aria-busy', 'true'); - await user.click(remove); - expect(onRemove).toHaveBeenCalledOnce(); - await act(async () => { - removal.reject(clerkApiError('web3_wallet_in_use', 'Unable to remove wallet')); - await removal.promise.catch(() => undefined); - }); - expect(within(dialog).getByRole('alert')).toHaveTextContent('Unable to remove wallet'); - await user.click(remove); - expect(onRemove).toHaveBeenNthCalledWith(2, 'wallet_1'); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - }); - - it('uses the newly selected wallet and its warning after canceling another removal', async () => { - const user = userEvent.setup(); - const onRemove = vi.fn(); - render( - - - , - ); - await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); - const dialog = screen.getByRole('alertdialog'); - expect(dialog).toHaveTextContent('second-wallet'); - expect(dialog).not.toHaveTextContent('You will no longer be able to sign in using this web3 wallet.'); - await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); - expect(onRemove).toHaveBeenCalledExactlyOnceWith('wallet_2'); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx new file mode 100644 index 00000000000..9a2ba1d037a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx @@ -0,0 +1,848 @@ +import type { UserJSON } from '@clerk/shared/types'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { WindowAppReadyEventAPI } from '@wallet-standard/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEnterpriseAccount, + fapiEnvironment, + fapiSession, + fapiUser, + fapiVerification, + fapiWeb3Wallet, +} from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { Dialog } from '../../../components/dialog'; +import { MosaicProvider } from '../../../mosaic-provider'; +import { UserProfileSolanaWalletView } from '../user-profile-web3-wallets-section/user-profile-solana-wallet.view'; +import { UserProfileWeb3WalletsSection } from '../user-profile-web3-wallets-section/user-profile-web3-wallets-section'; + +describe('Web3 wallets', () => { + afterEach(() => vi.unstubAllGlobals()); + + function web3Environment() { + const base = fapiEnvironment(); + return fapiEnvironment({ + user_settings: { + attributes: { + ...base.user_settings.attributes, + web3_wallet: { + ...base.user_settings.attributes.web3_wallet, + enabled: true, + used_for_first_factor: true, + first_factors: ['web3_metamask_signature', 'web3_solana_signature'], + }, + }, + }, + }); + } + + async function renderWeb3(overrides: Partial = {}) { + const user = fapiUser({ id: 'user_1', ...overrides }); + const fapi = serveFapi({ + environment: web3Environment(), + client: fapiClient([fapiSession({ id: 'sess_1', user })]), + }); + await renderWithClerk(); + return fapi; + } + + it('shows a saved wallet from the signed-in Clerk user', async () => { + await renderWeb3({ web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })] }); + + expect(await screen.findByText('Web3 wallets')).toBeInTheDocument(); + expect(screen.getByText('0x1234...cdef')).toBeInTheDocument(); + }); + + it.each(['verified', 'unverified'] as const)( + 'warns about sign-in loss only when removing a verified admin wallet (%s)', + async status => { + const address = '0x1234567890abcdef1234567890abcdef12345678'; + await renderWeb3({ + web3_wallets: [ + fapiWeb3Wallet({ + id: 'admin_wallet', + web3_wallet: address, + verification: fapiVerification('admin', { status }), + }), + ], + }); + const user = userEvent.setup(); + expect(screen.getByText('0x1234...5678')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: `Manage ${address}` })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + const warning = 'You will no longer be able to sign in using this web3 wallet.'; + if (status === 'verified') { + expect(screen.getByRole('alertdialog')).toHaveTextContent(warning); + } else { + expect(screen.getByRole('alertdialog')).not.toHaveTextContent(warning); + } + }, + ); + + it('keeps an unverified wallet available to connect without offering to make it primary', async () => { + await renderWeb3({ + web3_wallets: [ + fapiWeb3Wallet({ + id: 'wallet_1', + web3_wallet: '0x1234567890abcdef', + verification: fapiVerification('web3_metamask_signature', { status: 'unverified' }), + }), + ], + }); + expect(screen.getByText('Unverified')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Manage MetaMask' })); + expect(screen.getByRole('menuitem', { name: 'Remove wallet' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).toBeNull(); + }); + + it('hides existing wallets when Web3 is disabled in the Clerk environment', async () => { + const user = fapiUser({ + id: 'user_1', + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }); + serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); + await renderWithClerk(); + + expect(screen.queryByText('Web3 wallets')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Connect MetaMask' })).not.toBeInTheDocument(); + }); + + it('creates and verifies a MetaMask wallet through Clerk and the injected provider', async () => { + const request = vi.fn(({ method }: { method: string }) => { + if (method === 'eth_requestAccounts') { + return Promise.resolve(['0x1234567890abcdef']); + } + if (method === 'personal_sign') { + return Promise.resolve('signature'); + } + throw new Error(`Unexpected wallet method: ${method}`); + }); + vi.stubGlobal('ethereum', { request }); + const fapi = await renderWeb3(); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect MetaMask' })); + + await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified')); + expect(request).toHaveBeenCalledWith({ method: 'eth_requestAccounts' }); + expect(request).toHaveBeenCalledWith({ method: 'personal_sign', params: expect.any(Array) }); + expect(await screen.findByRole('button', { name: 'Manage MetaMask' })).toBeInTheDocument(); + expect(await screen.findByText('Primary')).toBeInTheDocument(); + expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe( + fapi.client.sessions[0]?.user.web3_wallets[0]?.id, + ); + }); + + it('reuses the created Ethereum wallet when Connect is retried after signature rejection', async () => { + const address = '0xabcdef1234567890'; + let accountRequests = 0; + let signatureRequests = 0; + vi.stubGlobal('ethereum', { + request: vi.fn(({ method }: { method: string }) => { + if (method === 'eth_requestAccounts') { + accountRequests += 1; + return Promise.resolve([accountRequests === 1 ? address : address.toUpperCase().replace('0X', '0x')]); + } + if (method === 'personal_sign') { + signatureRequests += 1; + return signatureRequests === 1 + ? Promise.reject(new Error('Signature rejected')) + : Promise.resolve('signature'); + } + throw new Error(`Unexpected wallet method: ${method}`); + }), + }); + const fapi = await renderWeb3(); + const creation = holdRequests('post', '/v1/me/web3_wallets'); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(creation.requests).toHaveLength(1)); + creation.release(); + expect(await screen.findByRole('alert')).toHaveTextContent('Something went wrong. Please try again.'); + const pendingId = fapi.client.sessions[0]?.user.web3_wallets[0]?.id; + expect(pendingId).toBeDefined(); + expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(1); + + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + + await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified')); + expect(creation.requests).toHaveLength(1); + expect(fapi.client.sessions[0]?.user.web3_wallets.map(wallet => wallet.id)).toEqual([pendingId]); + expect(screen.getByText('Primary')).toBeInTheDocument(); + }); + + it('hides immutable wallet mutations but still permits setting a verified wallet as primary', async () => { + const environment = web3Environment(); + serveFapi({ + environment: fapiEnvironment({ + user_settings: { + attributes: { + ...environment.user_settings.attributes, + web3_wallet: { + ...environment.user_settings.attributes.web3_wallet, + immutable: true, + first_factors: ['web3_metamask_signature', 'web3_coinbase_wallet_signature'], + }, + }, + }, + }), + client: fapiClient([ + fapiSession({ + id: 'sess_1', + user: fapiUser({ + id: 'user_1', + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }), + }), + ]), + }); + await renderWithClerk(); + + expect(screen.queryByRole('button', { name: 'Connect Coinbase Wallet' })).toBeNull(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Manage MetaMask' })); + expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Remove wallet' })).toBeNull(); + }); + + it('hides an immutable Web3 section without saved wallets', async () => { + const environment = web3Environment(); + serveFapi({ + environment: fapiEnvironment({ + user_settings: { + attributes: { + ...environment.user_settings.attributes, + web3_wallet: { ...environment.user_settings.attributes.web3_wallet, immutable: true }, + }, + }, + }), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk(); + + expect(screen.queryByRole('group', { name: 'Web3 wallets' })).toBeNull(); + }); + + it('shows a localized fallback when the wallet provider rejects without a message', async () => { + vi.stubGlobal('ethereum', { request: vi.fn(() => Promise.reject(new Error(''))) }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk( + + + , + ); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect MetaMask' })); + + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Connexion impossible.')); + }); + + it('uses the canonical API-code translation for a wallet connection failure', async () => { + vi.stubGlobal('ethereum', { request: vi.fn(() => Promise.resolve(['0x1234567890abcdef'])) }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); + await renderWithClerk( + + + , + ); + const creation = holdRequests('post', '/v1/me/web3_wallets'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(creation.requests).toHaveLength(1)); + creation.fail('verification_invalid_strategy', 'Server copy'); + + expect(await screen.findByRole('alert')).toHaveTextContent('Ce portefeuille est indisponible.'); + expect(screen.queryByText('Server copy')).toBeNull(); + }); + + it.each(['switch', 'sign out'] as const)( + 'aborts wallet creation after %s during the provider prompt', + async change => { + const accountRequest = Promise.withResolvers(); + const request = vi.fn(() => accountRequest.promise); + vi.stubGlobal('ethereum', { request }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2' }) }), + ]), + }); + const { clerk } = await renderWithClerk(); + const original = clerk.user; + if (!original) { + throw new Error('Expected signed-in user'); + } + const create = vi.spyOn(original, 'createWeb3Wallet'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(request).toHaveBeenCalledOnce()); + await act(() => (change === 'switch' ? clerk.setActive({ session: 'sess_2' }) : clerk.signOut())); + expect(clerk.user?.id).toBe(change === 'switch' ? 'user_2' : undefined); + if (change === 'switch') { + expect.soft(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + } + + await act(async () => { + accountRequest.resolve(['0x1234567890abcdef']); + await accountRequest.promise; + }); + + expect(create).not.toHaveBeenCalled(); + if (change === 'switch') { + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + expect(screen.queryByRole('alert')).toBeNull(); + } + }, + ); + + it('rechecks creation policy after the wallet provider prompt', async () => { + const accountRequest = Promise.withResolvers(); + vi.stubGlobal('ethereum', { request: vi.fn(() => accountRequest.promise) }); + const environment = web3Environment(); + const fapi = serveFapi({ + environment: fapiEnvironment({ + user_settings: { + ...environment.user_settings, + enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false }, + }, + }), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ + id: 'sess_2', + user: fapiUser({ id: 'user_1', enterprise_accounts: [fapiEnterpriseAccount({ id: 'sso_1' })] }), + }), + ]), + }); + const { clerk } = await renderWithClerk(); + const initialUser = clerk.user; + if (!initialUser) { + throw new Error('Expected signed-in user'); + } + const create = vi.spyOn(initialUser, 'createWeb3Wallet'); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await act(() => clerk.setActive({ session: 'sess_2' })); + await act(async () => { + accountRequest.resolve(['0x1234567890abcdef']); + await accountRequest.promise; + }); + + expect(create).not.toHaveBeenCalled(); + expect(fapi.client.sessions[1]?.user.web3_wallets).toHaveLength(0); + expect(screen.queryByRole('button', { name: 'Connect MetaMask' })).toBeNull(); + }); + + it('clears the previous user’s provider error and open wallet picker on session change', async () => { + vi.stubGlobal('ethereum', { request: vi.fn(() => Promise.reject(new Error('First user wallet failure'))) }); + serveFapi({ + environment: web3Environment(), + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2' }) }), + ]), + }); + const { clerk } = await renderWithClerk(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + expect(await screen.findByRole('alert')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + await waitFor(() => expect(screen.getByRole('dialog')).toBeVisible()); + + await act(() => clerk.setActive({ session: 'sess_2' })); + + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + }); + + it('holds one provider pending and allows a manual retry after an API error', async () => { + vi.stubGlobal('ethereum', { + request: vi.fn(({ method }: { method: string }) => + Promise.resolve(method === 'personal_sign' ? 'signature' : ['0x1234567890abcdef']), + ), + }); + const fapi = await renderWeb3(); + const hold = holdRequests('post', '/v1/me/web3_wallets'); + const user = userEvent.setup(); + + const metamaskButton = await screen.findByRole('button', { name: 'Connect MetaMask' }); + act(() => { + fireEvent.click(metamaskButton); + fireEvent.click(screen.getByRole('button', { name: 'Connect Solana' })); + }); + expect(screen.queryByRole('dialog')).toBeNull(); + await waitFor(() => expect(hold.requests).toHaveLength(1)); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('button', { name: 'Connect Solana' })).toBeDisabled(); + + hold.fail('wallet_creation_failed'); + expect(await screen.findByRole('alert')).toHaveTextContent('wallet_creation_failed'); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); + const retryFapi = serveFapi(fapi); + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => + expect(retryFapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'), + ); + expect(await screen.findByRole('button', { name: 'Manage MetaMask' })).toBeInTheDocument(); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('clears a provider failure when a wallet action starts', async () => { + vi.stubGlobal('ethereum', { + request: vi.fn(() => Promise.resolve(['0x1234567890abcdef'])), + }); + await renderWeb3({ + web3_wallets: [ + fapiWeb3Wallet({ + id: 'solana_wallet', + web3_wallet: 'SolanaAddress123', + verification: fapiVerification('web3_solana_signature', { status: 'verified' }), + }), + ], + }); + const creation = holdRequests('post', '/v1/me/web3_wallets'); + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); + await waitFor(() => expect(creation.requests).toHaveLength(1)); + creation.fail('wallet_creation_failed'); + expect(await screen.findByRole('alert')).toHaveTextContent('wallet_creation_failed'); + + const primary = holdRequests('post', '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/v1/me'); + await user.click(screen.getByRole('button', { name: 'Manage Solana' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + await waitFor(() => expect(primary.requests).toHaveLength(1)); + try { + expect(screen.queryByRole('alert')).toBeNull(); + } finally { + primary.release(); + } + await waitFor(() => expect(screen.getByText('Primary')).toBeInTheDocument()); + }); + + it('sets a verified wallet as primary through Clerk', async () => { + const fapi = await renderWeb3({ + web3_wallets: [ + fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' }), + fapiWeb3Wallet({ id: 'wallet_2', web3_wallet: '0xabcdef1234567890' }), + ], + primary_web3_wallet_id: 'wallet_1', + }); + const user = userEvent.setup(); + + await waitFor(() => expect(screen.getAllByRole('button', { name: 'Manage MetaMask' })).toHaveLength(2)); + await user.click(screen.getAllByRole('button', { name: 'Manage MetaMask' })[0]); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).toBeNull(); + await user.keyboard('{Escape}'); + await user.click(screen.getAllByRole('button', { name: 'Manage MetaMask' })[1]); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + + await waitFor(() => expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe('wallet_2')); + const selectedRow = screen.getByTitle('0xabcdef1234567890').closest('.cl-section-row'); + expect(selectedRow).not.toBeNull(); + if (selectedRow) { + expect(within(selectedRow).getByText('Primary')).toBeInTheDocument(); + } + }); + + it('shows a primary update failure on the row and allows another attempt', async () => { + const fapi = await renderWeb3({ + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }); + const hold = holdRequests('post', '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/v1/me'); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + await waitFor(() => expect(hold.requests).toHaveLength(1)); + expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toBeDisabled(); + hold.fail('primary_update_failed'); + + expect(await screen.findByRole('alert')).toHaveTextContent('primary_update_failed'); + expect(screen.queryByText('Cannot verify your account')).not.toBeInTheDocument(); + const retryFapi = serveFapi(fapi); + await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + await waitFor(() => expect(retryFapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe('wallet_1')); + expect(screen.getByText('Primary')).toBeInTheDocument(); + }); + + it('confirms removal and focuses Connect after deleting the last wallet', async () => { + const fapi = await renderWeb3({ + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + primary_web3_wallet_id: 'wallet_1', + }); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + expect(screen.getByRole('alertdialog')).toHaveTextContent('0x1234...cdef'); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + + await waitFor(() => expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(0)); + expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBeNull(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toHaveFocus()); + }); + + it('keeps a failed wallet removal in its confirmation for a manual retry', async () => { + const fapi = await renderWeb3({ + web3_wallets: [fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' })], + }); + const hold = holdRequests('post', '/v1/me/web3_wallets/:id'); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Manage MetaMask' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + const confirm = within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }); + await user.click(confirm); + await waitFor(() => expect(hold.requests).toHaveLength(1)); + expect(confirm).toHaveAttribute('aria-busy', 'true'); + hold.fail('wallet_removal_failed'); + + expect(await within(screen.getByRole('alertdialog')).findByRole('alert')).toHaveTextContent( + 'wallet_removal_failed', + ); + await waitFor(() => expect(confirm).not.toHaveAttribute('aria-busy', 'true')); + const retryFapi = serveFapi(fapi); + await user.click(confirm); + await waitFor(() => expect(retryFapi.client.sessions[0]?.user.web3_wallets).toHaveLength(0)); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + + it('cancels removal, then removes the newly selected wallet and focuses the remaining row', async () => { + const fapi = await renderWeb3({ + web3_wallets: [ + fapiWeb3Wallet({ id: 'wallet_1', web3_wallet: '0x1234567890abcdef' }), + fapiWeb3Wallet({ id: 'wallet_2', web3_wallet: '0xabcdef1234567890' }), + ], + primary_web3_wallet_id: 'wallet_2', + }); + const user = userEvent.setup(); + const first = screen.getAllByRole('button', { name: 'Manage MetaMask' })[1]; + first.focus(); + await user.keyboard('{Enter}'); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + await user.keyboard('{Escape}'); + await waitFor(() => expect(first).toHaveFocus()); + expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(2); + await user.click(screen.getAllByRole('button', { name: 'Manage MetaMask' })[0]); + await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); + expect(screen.getByRole('alertdialog')).toHaveTextContent('0xabcd...7890'); + await user.click(screen.getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(fapi.client.sessions[0]?.user.web3_wallets.map(wallet => wallet.id)).toEqual(['wallet_1']); + expect(fapi.client.sessions[0]?.user.primary_web3_wallet_id).toBe('wallet_1'); + expect(screen.getByText('Primary')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toHaveFocus()); + }); + + it('distinguishes localized discovery loading and failure from no installed wallets', async () => { + const props = { onConnect: vi.fn() }; + const retry = vi.fn(); + const localization = { + messages: { + userProfileWeb3Wallets: { + solanaDialog: { + loading: 'Recherche des portefeuilles…', + loadError: 'Chargement impossible.', + retry: 'Réessayer', + }, + }, + }, + }; + const { rerender } = render( + + + + + + + , + ); + + expect(await screen.findByRole('status')).toHaveTextContent('Recherche des portefeuilles…'); + expect(screen.queryByText('No Solana wallets are available.')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Find a Solana wallet' })).not.toBeInTheDocument(); + + rerender( + + + + + + + , + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Chargement impossible.'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + expect(screen.queryByText('No Solana wallets are available.')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Find a Solana wallet' })).not.toBeInTheDocument(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Réessayer' })); + expect(retry).toHaveBeenCalledOnce(); + expect(props.onConnect).not.toHaveBeenCalled(); + }); + + it('renders caller-supplied Solana wallets and forwards the chosen name', async () => { + const props = { onConnect: vi.fn() }; + const { rerender } = render( + + + + + + + , + ); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Supplied Solana' })); + expect(props.onConnect).toHaveBeenCalledExactlyOnceWith('Supplied Solana'); + + rerender( + + + + + + + , + ); + expect(screen.queryByRole('button', { name: 'Supplied Solana' })).toBeNull(); + expect(screen.getByText('No Solana wallets are available.')).toBeInTheDocument(); + }); + + it('shows only eligible Solana wallets and refreshes when wallets register or unregister', async () => { + const wallet = { + version: '1.0.0' as const, + name: 'Eligible Solana', + icon: 'data:image/svg+xml;base64,' as const, + chains: ['solana:mainnet' as const], + accounts: [], + features: { + 'standard:connect': { version: '1.0.0' as const, connect: () => Promise.resolve({ accounts: [] }) }, + 'solana:signMessage': { version: '1.0.0' as const, signMessage: () => Promise.resolve([]) }, + }, + }; + const registries: WindowAppReadyEventAPI[] = []; + const unregister: Array<() => void> = []; + const register = (api: WindowAppReadyEventAPI) => { + registries.push(api); + unregister.push( + api.register( + { + ...wallet, + name: 'Cannot connect', + features: { 'solana:signMessage': wallet.features['solana:signMessage'] }, + }, + { ...wallet, name: 'Cannot sign', features: { 'standard:connect': wallet.features['standard:connect'] } }, + { ...wallet, name: 'Wrong chain', chains: ['ethereum:mainnet'] }, + ), + ); + }; + const onAppReady = (event: Event & { detail?: WindowAppReadyEventAPI }) => { + if (event.detail) { + register(event.detail); + } + }; + window.addEventListener('wallet-standard:app-ready', onAppReady); + window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: register })); + let unregisterEligible: Array<() => void> = []; + try { + await renderWeb3(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect Solana' })); + expect(screen.queryByRole('button', { name: 'Cannot connect' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Cannot sign' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Wrong chain' })).toBeNull(); + expect(screen.getByText('No Solana wallets are available.')).toBeInTheDocument(); + + act(() => { + unregisterEligible = registries.map(api => api.register(wallet)); + }); + expect(await screen.findByRole('button', { name: 'Eligible Solana' })).toBeEnabled(); + act(() => unregisterEligible.forEach(remove => remove())); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Eligible Solana' })).toBeNull()); + expect(screen.getByText('No Solana wallets are available.')).toBeInTheDocument(); + } finally { + window.removeEventListener('wallet-standard:app-ready', onAppReady); + unregister.forEach(remove => remove()); + unregisterEligible.forEach(remove => remove()); + } + }); + + it('connects the chosen Solana wallet and verifies it through Clerk', async () => { + const account = { + address: 'SolanaAddress123', + publicKey: new Uint8Array([1, 2, 3]), + chains: ['solana:mainnet' as const], + features: ['solana:signMessage' as const], + }; + const connect = vi.fn(() => Promise.resolve({ accounts: [account] })); + const signMessage = vi.fn(() => Promise.resolve([{ signature: new Uint8Array([4, 5, 6]) }])); + const wallet = { + version: '1.0.0' as const, + name: 'Test Solana', + icon: 'data:image/svg+xml;base64,' as const, + chains: ['solana:mainnet' as const], + accounts: [account], + features: { + 'standard:connect': { version: '1.0.0' as const, connect }, + 'solana:signMessage': { version: '1.0.0' as const, signMessage }, + }, + }; + const secondAccount = { ...account, address: 'SecondSolanaAddress456' }; + const secondConnect = vi.fn(() => Promise.resolve({ accounts: [secondAccount] })); + const secondSignMessage = vi.fn(() => Promise.resolve([{ signature: new Uint8Array([7, 8, 9]) }])); + const secondWallet = { + ...wallet, + name: 'Second Solana', + accounts: [secondAccount], + features: { + 'standard:connect': { version: '1.0.0' as const, connect: secondConnect }, + 'solana:signMessage': { version: '1.0.0' as const, signMessage: secondSignMessage }, + }, + }; + const unregister: Array<() => void> = []; + const register = (api: WindowAppReadyEventAPI) => unregister.push(api.register(wallet), api.register(secondWallet)); + const onAppReady = (event: Event & { detail?: WindowAppReadyEventAPI }) => { + if (event.detail) { + register(event.detail); + } + }; + window.addEventListener('wallet-standard:app-ready', onAppReady); + window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: register })); + try { + const fapi = await renderWeb3(); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Connect Solana' })); + await user.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + const creation = holdRequests('post', '/v1/me/web3_wallets'); + await user.click(screen.getByRole('button', { name: 'Second Solana' })); + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Second Solana' })); + }); + await waitFor(() => expect(creation.requests).toHaveLength(1)); + expect(screen.getByRole('button', { name: 'Second Solana' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('button', { name: 'Second Solana' })).toHaveFocus(); + expect(screen.getByRole('button', { name: 'Test Solana' })).toBeDisabled(); + await user.keyboard('{Escape}'); + expect(screen.getByRole('dialog')).toBeVisible(); + creation.fail('wallet_creation_failed'); + expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent('wallet_creation_failed'); + expect(screen.getAllByRole('alert', { hidden: true })).toHaveLength(1); + const retryFapi = serveFapi(fapi); + await user.click(screen.getByRole('button', { name: 'Second Solana' })); + await waitFor(() => + expect(retryFapi.client.sessions[0]?.user.web3_wallets[0]?.verification?.status).toBe('verified'), + ); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(retryFapi.client.sessions[0]?.user.web3_wallets[0]?.web3_wallet).toBe('SecondSolanaAddress456'); + expect(connect).not.toHaveBeenCalled(); + expect(signMessage).not.toHaveBeenCalled(); + expect(secondConnect).toHaveBeenCalledTimes(2); + expect(secondSignMessage).toHaveBeenCalledOnce(); + expect(await screen.findByRole('button', { name: 'Manage Solana' })).toBeInTheDocument(); + } finally { + window.removeEventListener('wallet-standard:app-ready', onAppReady); + unregister.forEach(remove => remove()); + } + }); + + it.each(['Cancel', 'Close', 'Escape'] as const)( + 'clears the previous Solana failure when reopened after %s', + async dismissal => { + const connect = vi.fn(() => Promise.reject(new Error('Wallet rejected connection'))); + const wallet = { + version: '1.0.0' as const, + name: 'Test Solana', + icon: 'data:image/svg+xml;base64,' as const, + chains: ['solana:mainnet' as const], + accounts: [], + features: { + 'standard:connect': { version: '1.0.0' as const, connect }, + 'solana:signMessage': { + version: '1.0.0' as const, + signMessage: () => Promise.resolve([{ signature: new Uint8Array([4, 5, 6]) }]), + }, + }, + }; + const unregister: Array<() => void> = []; + const register = (api: WindowAppReadyEventAPI) => unregister.push(api.register(wallet)); + const onAppReady = (event: Event & { detail?: WindowAppReadyEventAPI }) => { + if (event.detail) { + register(event.detail); + } + }; + window.addEventListener('wallet-standard:app-ready', onAppReady); + window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: register })); + try { + await renderWeb3(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + await user.click(await screen.findByRole('button', { name: 'Test Solana' })); + expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent( + 'Something went wrong. Please try again.', + ); + + if (dismissal === 'Escape') { + await user.keyboard('{Escape}'); + } else { + await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: dismissal })); + } + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong. Please try again.'); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + + expect(within(screen.getByRole('dialog')).queryByRole('alert')).toBeNull(); + expect(screen.getByRole('button', { name: 'Test Solana' })).toBeEnabled(); + await user.click(screen.getByRole('button', { name: 'Test Solana' })); + expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent( + 'Something went wrong. Please try again.', + ); + expect(connect).toHaveBeenCalledTimes(2); + } finally { + window.removeEventListener('wallet-standard:app-ready', onAppReady); + unregister.forEach(remove => remove()); + } + }, + ); + + // TODO: Add session reverification for wallet connection, primary updates, and removal; + // surface API errors until then. + // TODO: Share Solana discovery and filtering only after verifying parity with the legacy UI. + // TODO: Share identification sorting only after verifying parity for legacy email, phone, and wallet sections. + // TODO: Add recovery for abandoned MetaMask connections. In Brave, closing the locked wallet prompt + // left wallet_requestPermissions pending. Refresh cleared Clerk's error, but a new connection returned -32002. + // Clerk cancellation must release the UI and ignore late responses without claiming to cancel the wallet request. + // The injected MetaMask API exposes no supported method to cancel that request, and clerk_go cannot clear it. +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx deleted file mode 100644 index 359c4674d02..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.view.test.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; - -describe('Web3 wallets section', () => { - it.each([{ availableProviders: [] }, { availableProviders: [{ id: 'metamask', provider: 'MetaMask' }] }])( - 'hides the entire section without wallets or actionable providers (%j)', - ({ availableProviders }) => { - const { container } = render( - , - ); - expect(container).toBeEmptyDOMElement(); - }, - ); - - it.each([ - { isPrimary: true, isVerified: true, canSetPrimary: false }, - { isPrimary: false, isVerified: true, canSetPrimary: true }, - { isPrimary: false, isVerified: false, canSetPrimary: false }, - ])( - 'offers only applicable legacy wallet actions ($isPrimary, $isVerified)', - async ({ isPrimary, isVerified, canSetPrimary }) => { - const user = userEvent.setup(); - render( - , - ); - expect(screen.getByText('MetaMask')).toBeVisible(); - expect(screen.queryByRole('img')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); - expect(screen.getByRole('menuitem', { name: 'Remove wallet' })).toBeVisible(); - expect(screen.getAllByRole('menuitem')).toHaveLength(canSetPrimary ? 2 : 1); - if (canSetPrimary) { - expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeVisible(); - } else { - expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); - } - }, - ); - - it.each([true, false])( - 'renders an admin wallet and limits the removal warning to verified wallets (%s)', - async isVerified => { - const user = userEvent.setup(); - const address = '0x1234567890abcdef1234567890abcdef12345678'; - render( - , - ); - expect(screen.getByText('0x1234...5678')).toBeVisible(); - expect(screen.queryByRole('img')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: `Manage ${address}` })); - await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' })); - const dialog = screen.getByRole('alertdialog'); - const warning = 'You will no longer be able to sign in using this web3 wallet.'; - if (isVerified) { - expect(dialog).toHaveTextContent(warning); - } else { - expect(dialog).not.toHaveTextContent(warning); - } - }, - ); - - it('shows connection errors while keeping Connect available', () => { - render( - , - ); - expect(screen.getByRole('alert')).toHaveTextContent('Wallet extension not found'); - expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); - expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); - }); - - it('shows primary errors while keeping Set as primary available', async () => { - const user = userEvent.setup(); - render( - , - ); - expect(screen.getByRole('alert')).toHaveTextContent('Unable to set primary'); - await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); - expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeEnabled(); - }); - - it('shows an unverified wallet alongside its available provider', () => { - render( - , - ); - expect(screen.getByText('Unverified')).toBeVisible(); - expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); - expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toBeVisible(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx index 933a4dd5c02..66adbffbe91 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallet-row.view.tsx @@ -4,7 +4,7 @@ import type { Ref } from 'react'; import type { ActionMenuAction } from '../../components/action-menu'; import { ActionMenu } from '../../components/action-menu'; import { Badge } from '../../components/badge'; -import { Button } from '../../components/button'; +import { SubmitButton } from '../../components/button'; import { Icon, IconFrame } from '../../components/icon'; import { Section } from '../../components/section'; import { fill, useMessages } from '../../localization'; @@ -15,12 +15,16 @@ import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-prof export function UserProfileWeb3WalletRowView({ wallet, triggerRef, + isPending = false, + isDisabled = false, onConnect, onSetPrimary, onRemove, }: { wallet: UserProfileWeb3Wallet | UserProfileWeb3Provider; triggerRef?: Ref; + isPending?: boolean; + isDisabled?: boolean; onConnect?: (id: string) => void; onSetPrimary?: (id: string) => void; onRemove?: (wallet: UserProfileWeb3Wallet) => void; @@ -85,10 +89,12 @@ export function UserProfileWeb3WalletRowView({ {onConnect ? ( - + ) : actions.length > 0 ? ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx index 6ed2cb678ee..cf7905b5721 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section.view.tsx @@ -1,3 +1,4 @@ +import type { Web3Strategy } from '@clerk/shared/types'; import { useMemo, useRef } from 'react'; import { Confirmation } from '../../blocks/confirmation'; @@ -8,7 +9,8 @@ import { truncateWithEndVisible } from '../../utils/truncate-text-with-end-visib import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; export interface UserProfileWeb3Provider { - id: string; + id: Web3Strategy; + walletPicker?: 'solana'; provider: string; iconUrl?: string; connectError?: string; @@ -29,6 +31,7 @@ export interface UserProfileWeb3WalletsSectionViewProps { fallbackFocus?: () => HTMLElement | null; wallets: UserProfileWeb3Wallet[]; availableProviders?: UserProfileWeb3Provider[]; + pendingId?: string; onConnect?: (id: string) => void; onSetPrimary?: (id: string) => void; onRemove?: (id: string) => void | Promise; @@ -38,6 +41,7 @@ export function UserProfileWeb3WalletsSectionView({ wallets, fallbackFocus, availableProviders = [], + pendingId, onConnect, onSetPrimary, onRemove, @@ -55,6 +59,7 @@ export function UserProfileWeb3WalletsSectionView({ }); const removeWallet = useMemo(() => Confirmation.createHandle(), []); const hasRows = wallets.length > 0 || (availableProviders.length > 0 && Boolean(onConnect)); + const isBusy = pendingId !== undefined; return ( <> @@ -75,6 +80,7 @@ export function UserProfileWeb3WalletsSectionView({ key={wallet.id} wallet={wallet} triggerRef={removalFocus.registerTrigger(wallet.id)} + isDisabled={isBusy} onSetPrimary={onSetPrimary} onRemove={onRemove ? wallet => removeWallet.open(wallet) : undefined} /> @@ -84,6 +90,8 @@ export function UserProfileWeb3WalletsSectionView({ )) diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx new file mode 100644 index 00000000000..e4f723c7fa6 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.dialog.tsx @@ -0,0 +1,50 @@ +import type { ComponentProps } from 'react'; + +import { Dialog } from '../../../components/dialog'; +import { useUserProfileSolanaWalletsModel } from './user-profile-solana-wallet.model'; +import { UserProfileSolanaWalletView } from './user-profile-solana-wallet.view'; + +type SolanaWalletContentProps = Omit, 'discovery'>; + +export function UserProfileSolanaWalletDialog({ + open, + pendingWalletName, + error, + onOpenChange, + onConnect, +}: SolanaWalletContentProps & { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const pending = pendingWalletName !== undefined; + + return ( + { + if (!nextOpen && pending) { + return; + } + onOpenChange(nextOpen); + }} + > + + + + + ); +} + +function SolanaWalletContent(props: SolanaWalletContentProps) { + const discovery = useUserProfileSolanaWalletsModel(); + return ( + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.lifecycle.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.lifecycle.test.tsx new file mode 100644 index 00000000000..2616c77d86c --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.lifecycle.test.tsx @@ -0,0 +1,90 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { Wallet } from '@wallet-standard/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../mosaic-provider'; +import { UserProfileWeb3WalletsSection } from './user-profile-web3-wallets-section'; +import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; + +const registry = vi.hoisted(() => { + const wallets: Wallet[] = []; + const listeners = { register: new Set<() => void>(), unregister: new Set<() => void>() }; + const getWallets = vi.fn(() => ({ + get: () => wallets, + on: (event: 'register' | 'unregister', listener: () => void) => { + listeners[event].add(listener); + return () => listeners[event].delete(listener); + }, + })); + return { wallets, listeners, getWallets, loadModule: vi.fn() }; +}); + +vi.mock('@wallet-standard/core', () => { + registry.loadModule(); + return { getWallets: registry.getWallets }; +}); +vi.mock('./user-profile-web3-wallets-section.model', () => ({ + useUserProfileWeb3WalletsModel: () => + ({ + status: 'ready', + userId: 'user_1', + wallets: [], + availableProviders: [{ id: 'web3_solana_signature', provider: 'Solana', walletPicker: 'solana' }], + connect: async () => {}, + setPrimary: async () => {}, + }) satisfies ReadyWeb3WalletsModel, +})); + +function wallet(name: string): Wallet { + return { + name, + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains: ['solana:mainnet'], + accounts: [], + features: { 'standard:connect': {}, 'solana:signMessage': {} }, + }; +} + +describe('Solana picker discovery lifetime', () => { + it('discovers wallets only while the picker content is mounted and refreshes after reopening', async () => { + registry.wallets.push(wallet('Phantom')); + const user = userEvent.setup(); + const { unmount } = render( + + + , + ); + + await act(async () => { + await vi.dynamicImportSettled(); + }); + expect(registry.loadModule).not.toHaveBeenCalled(); + expect(registry.getWallets).not.toHaveBeenCalled(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + expect(await screen.findByRole('button', { name: 'Phantom' })).toBeInTheDocument(); + expect(registry.loadModule).toHaveBeenCalledOnce(); + expect(registry.getWallets).toHaveBeenCalledOnce(); + expect(registry.listeners.register.size).toBe(1); + expect(registry.listeners.unregister.size).toBe(1); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + + registry.wallets.splice(0, registry.wallets.length, wallet('Backpack')); + await user.click(screen.getByRole('button', { name: 'Connect Solana' })); + expect(await screen.findByRole('button', { name: 'Backpack' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Phantom' })).not.toBeInTheDocument(); + expect(registry.getWallets).toHaveBeenCalledTimes(2); + + unmount(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model-errors.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model-errors.test.tsx new file mode 100644 index 00000000000..44fdbc3f457 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model-errors.test.tsx @@ -0,0 +1,77 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { Wallet } from '@wallet-standard/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { deferred } from '../../../__tests__/async'; +import { useUserProfileSolanaWalletsModel } from './user-profile-solana-wallet.model'; + +afterEach(() => { + vi.doUnmock('@wallet-standard/core'); +}); + +describe('Solana wallet discovery failures', () => { + it('handles a registry import that rejects after unmount', async () => { + const load = deferred(); + const unhandled = vi.fn(); + window.addEventListener('unhandledrejection', unhandled); + const loadRegistry = vi.fn(() => load.promise); + vi.doMock('@wallet-standard/core', loadRegistry); + + try { + const { result, unmount } = renderHook(useUserProfileSolanaWalletsModel); + expect(result.current).toEqual({ status: 'loading' }); + await waitFor(() => expect(loadRegistry).toHaveBeenCalledOnce()); + unmount(); + await act(async () => { + load.reject(new Error('Registry import failed after unmount')); + await vi.dynamicImportSettled(); + }); + expect(result.current).toEqual({ status: 'loading' }); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('unhandledrejection', unhandled); + } + }); + + it('reports a failed registry import instead of an empty wallet list and can retry', async () => { + vi.doMock('@wallet-standard/core', () => { + throw new Error('Failed to fetch the wallet registry chunk'); + }); + + const { result } = renderHook(useUserProfileSolanaWalletsModel); + expect(result.current).toEqual({ status: 'loading' }); + await act(async () => { + await vi.dynamicImportSettled(); + }); + expect(result.current).toMatchObject({ status: 'error' }); + + vi.doMock('@wallet-standard/core', () => ({ + getWallets: () => ({ + get: (): Wallet[] => [ + { + name: 'Recovered Solana', + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains: ['solana:mainnet'], + accounts: [], + features: { 'standard:connect': {}, 'solana:signMessage': {} }, + }, + ], + on: () => () => {}, + }), + })); + + act(() => { + if (result.current.status === 'error') { + result.current.retry(); + } + }); + expect(result.current).toEqual({ status: 'loading' }); + await waitFor(() => + expect(result.current).toEqual({ + status: 'ready', + wallets: [{ name: 'Recovered Solana', icon: 'data:image/svg+xml;base64,' }], + }), + ); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.test.tsx new file mode 100644 index 00000000000..cde8f80714d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.test.tsx @@ -0,0 +1,124 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { Wallet } from '@wallet-standard/core'; +import { createElement } from 'react'; +import { renderToString } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useUserProfileSolanaWalletsModel } from './user-profile-solana-wallet.model'; + +const registry = vi.hoisted(() => { + const wallets: Wallet[] = []; + const listeners = { register: new Set<() => void>(), unregister: new Set<() => void>() }; + const getWallets = vi.fn(() => ({ + get: () => wallets, + on: (event: 'register' | 'unregister', listener: () => void) => { + listeners[event].add(listener); + return () => listeners[event].delete(listener); + }, + })); + return { wallets, listeners, getWallets }; +}); + +vi.mock('@wallet-standard/core', () => ({ getWallets: registry.getWallets })); + +function wallet( + name: string, + features: Wallet['features'] = { 'standard:connect': {}, 'solana:signMessage': {} }, + chains: Wallet['chains'] = ['solana:mainnet'], +): Wallet { + return { + name, + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains, + accounts: [], + features, + }; +} + +describe('useUserProfileSolanaWalletsModel', () => { + beforeEach(() => { + registry.wallets.splice(0); + registry.listeners.register.clear(); + registry.listeners.unregister.clear(); + registry.getWallets.mockClear(); + }); + + it('renders a loading server snapshot without initializing the browser registry', () => { + registry.wallets.push(wallet('Phantom')); + function WalletNames() { + return createElement('div', null, useUserProfileSolanaWalletsModel().status); + } + + expect(renderToString(createElement(WalletNames))).toBe('
loading
'); + expect(registry.getWallets).not.toHaveBeenCalled(); + }); + + it('starts loading and projects only eligible wallet names and icons after loading the registry', async () => { + registry.wallets.push(wallet('Phantom'), wallet('Signer only', { 'solana:signMessage': {} })); + const { result } = renderHook(useUserProfileSolanaWalletsModel); + + expect(result.current).toEqual({ status: 'loading' }); + await waitFor(() => + expect(result.current).toEqual({ + status: 'ready', + wallets: [{ name: 'Phantom', icon: 'data:image/svg+xml;base64,' }], + }), + ); + }); + + it.each([ + { chains: ['solana:mainnet'], features: { 'standard:connect': {}, 'solana:signMessage': {} }, eligible: true }, + { chains: ['solana:mainnet'], features: { 'solana:signMessage': {} }, eligible: false }, + { chains: ['solana:mainnet'], features: { 'standard:connect': {} }, eligible: false }, + { chains: ['eip155:1'], features: { 'standard:connect': {}, 'solana:signMessage': {} }, eligible: false }, + { chains: [], features: { 'standard:connect': {}, 'solana:signMessage': {} }, eligible: false }, + ] as const)('requires a Solana chain and both sign-in capabilities for $chains and $features', async entry => { + registry.wallets.push(wallet('Candidate', entry.features, entry.chains)); + const { result } = renderHook(useUserProfileSolanaWalletsModel); + await act(async () => { + await vi.dynamicImportSettled(); + }); + expect(registry.getWallets).toHaveBeenCalled(); + expect(result.current).toEqual({ + status: 'ready', + wallets: entry.eligible ? [{ name: 'Candidate', icon: 'data:image/svg+xml;base64,' }] : [], + }); + }); + + it('refreshes on registration and unregistration and removes both subscriptions on unmount', async () => { + const { result, unmount } = renderHook(useUserProfileSolanaWalletsModel); + await waitFor(() => expect(registry.listeners.register.size).toBe(1)); + + act(() => { + registry.wallets.push(wallet('Backpack')); + registry.listeners.register.forEach(listener => listener()); + }); + expect(result.current).toEqual({ + status: 'ready', + wallets: [{ name: 'Backpack', icon: 'data:image/svg+xml;base64,' }], + }); + + act(() => { + registry.wallets.splice(0); + registry.listeners.unregister.forEach(listener => listener()); + }); + expect(result.current).toEqual({ status: 'ready', wallets: [] }); + + unmount(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + }); + + it('does not initialize or subscribe when unmounted before the module loads', async () => { + const { unmount } = renderHook(useUserProfileSolanaWalletsModel); + unmount(); + await act(async () => { + await vi.dynamicImportSettled(); + }); + + expect(registry.getWallets).not.toHaveBeenCalled(); + expect(registry.listeners.register.size).toBe(0); + expect(registry.listeners.unregister.size).toBe(0); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts new file mode 100644 index 00000000000..a38b65586b8 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useState } from 'react'; + +export type InstalledSolanaWallet = { name: string; icon: string }; + +export type SolanaWalletDiscovery = + | { status: 'loading' } + | { status: 'ready'; wallets: readonly InstalledSolanaWallet[] } + | { status: 'error'; retry: () => void }; + +function isSolanaSignInWallet(wallet: { + chains: readonly string[]; + features: Readonly>; +}): boolean { + return ( + wallet.chains.some(chain => chain.startsWith('solana:')) && + 'standard:connect' in wallet.features && + 'solana:signMessage' in wallet.features + ); +} + +export function useUserProfileSolanaWalletsModel(): SolanaWalletDiscovery { + const [discovery, setDiscovery] = useState({ status: 'loading' }); + const [attempt, setAttempt] = useState(0); + const retry = useCallback(() => { + setDiscovery({ status: 'loading' }); + setAttempt(attempt => attempt + 1); + }, []); + + useEffect(() => { + let disposed = false; + let unsubscribe: (() => void) | undefined; + + void import('@wallet-standard/core') + .then(({ getWallets }) => { + if (disposed) { + return; + } + + const registry = getWallets(); + const update = () => { + setDiscovery({ + status: 'ready', + wallets: registry + .get() + .filter(isSolanaSignInWallet) + .map(({ name, icon }) => ({ name, icon })), + }); + }; + const offRegister = registry.on('register', update); + const offUnregister = registry.on('unregister', update); + unsubscribe = () => { + offRegister(); + offUnregister(); + }; + update(); + }) + .catch(() => { + if (disposed) { + return; + } + setDiscovery({ status: 'error', retry }); + }); + + return () => { + disposed = true; + unsubscribe?.(); + }; + }, [attempt, retry]); + + return discovery; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx new file mode 100644 index 00000000000..6decc33aa2a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.view.tsx @@ -0,0 +1,114 @@ +import { Banner } from '../../../components/banner'; +import { Button, SubmitButton } from '../../../components/button'; +import { Card } from '../../../components/card'; +import { Dialog } from '../../../components/dialog'; +import { useMessages } from '../../../localization'; +import type { SolanaWalletDiscovery } from './user-profile-solana-wallet.model'; + +export function UserProfileSolanaWalletView({ + discovery, + pendingWalletName, + error, + onConnect, +}: { + discovery: SolanaWalletDiscovery; + pendingWalletName?: string; + error?: string; + onConnect: (walletName: string) => void; +}) { + const m = useMessages('userProfileWeb3Wallets'); + const pending = pendingWalletName !== undefined; + + return ( + + + {m.solanaDialog.title} + {m.solanaDialog.description} + + + {error ? ( + + {error} + + ) : null} + {discovery.status === 'loading' ?

{m.solanaDialog.loading}

: null} + {discovery.status === 'error' ? ( + <> + + {m.solanaDialog.loadError} + + + + ) : null} + {discovery.status === 'ready' && discovery.wallets.length === 0 ? ( +

+ {m.solanaDialog.noneAvailable}{' '} + + {m.solanaDialog.findWallet} + +

+ ) : null} + {discovery.status === 'ready' + ? discovery.wallets.map(wallet => ( + onConnect(wallet.name)} + > + {wallet.icon ? ( + + ) : null} + {wallet.name} + + )) + : null} +
+ + + {m.solanaDialog.cancel} + + } + /> + +
+ ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.test.ts new file mode 100644 index 00000000000..fe35d1f01f5 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { clerkApiError } from '../../../__tests__/clerk-errors'; +import { toLocalizableError } from '../../../utils/errors'; +import { userProfileWeb3WalletsMessages } from '../user-profile-web3-wallets.messages'; +import { web3WalletFeedback } from './user-profile-web3-wallets-feedback'; +import { Web3WalletActionError } from './user-profile-web3-wallets-section.types'; + +describe('Web3 wallet feedback', () => { + it('preserves Clerk error codes for the shared localization boundary', () => { + const cause = clerkApiError('verification_invalid_strategy', 'Server copy'); + const error = web3WalletFeedback(cause, userProfileWeb3WalletsMessages); + + expect(toLocalizableError(error)).toMatchObject({ + code: 'verification_invalid_strategy', + message: 'Server copy', + }); + }); + + it.each([ + 'providerUnavailable', + 'extensionUnavailable', + 'creationFailed', + 'messageUnavailable', + 'signatureUnavailable', + ] as const)('preserves owned %s feedback through the shared boundary', code => { + const error = web3WalletFeedback(new Web3WalletActionError(code), userProfileWeb3WalletsMessages); + + expect(toLocalizableError(error)).toEqual({ code, message: userProfileWeb3WalletsMessages.errors[code] }); + }); + + it('keeps unexpected provider failures unlocalizable', () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const cause = new Error('Private provider details'); + try { + const error = web3WalletFeedback(cause, userProfileWeb3WalletsMessages); + + expect(toLocalizableError(error)).toEqual({ cause }); + } finally { + log.mockRestore(); + } + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.ts new file mode 100644 index 00000000000..116704ac97e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-feedback.ts @@ -0,0 +1,10 @@ +import type { MosaicMessages } from '../../../localization'; +import { SaveError } from '../../../utils/errors'; +import { Web3WalletActionError } from './user-profile-web3-wallets-section.types'; + +export function web3WalletFeedback(error: unknown, messages: MosaicMessages['userProfileWeb3Wallets']): unknown { + if (error instanceof Web3WalletActionError) { + return new SaveError({ global: { code: error.code, message: messages.errors[error.code] } }); + } + return error; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts new file mode 100644 index 00000000000..338df2b2764 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.controller.ts @@ -0,0 +1,72 @@ +import { useState } from 'react'; + +import { usePendingAction } from '../../../hooks/use-pending-action'; +import { useMessages } from '../../../localization'; +import type { UserProfileWeb3Provider } from '../user-profile-web3-wallets-section.view'; +import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; + +export function useUserProfileWeb3WalletsController({ + wallets, + availableProviders, + connect, + setPrimary, + remove, +}: Pick) { + const messages = useMessages('userProfileWeb3Wallets'); + const action = usePendingAction({ errorFallback: messages.errors.generic }); + const [picker, setPicker] = useState(null); + const [pendingWalletName, setPendingWalletName] = useState(); + + const onConnect = (id: string) => { + const provider = availableProviders.find(candidate => candidate.id === id); + if (!provider) { + return; + } + return action.run(provider.id, () => { + if (provider.walletPicker === 'solana') { + setPicker(provider); + return; + } + return connect(provider.id); + }); + }; + + const connectSolana = (walletName: string) => { + if (!picker) { + return; + } + return action.run(picker.id, async () => { + setPendingWalletName(walletName); + try { + await connect(picker.id, walletName); + setPicker(null); + } finally { + setPendingWalletName(undefined); + } + }); + }; + + return { + wallets: wallets.map(wallet => + action.errorKey === wallet.id ? { ...wallet, primaryError: action.error } : wallet, + ), + availableProviders: availableProviders.map(provider => + action.errorKey === provider.id && picker?.id !== provider.id + ? { ...provider, connectError: action.error } + : provider, + ), + pendingId: action.pendingKey, + solanaPickerOpen: picker !== null, + solanaPickerError: picker?.id === action.errorKey ? action.error : undefined, + pendingWalletName, + onConnect, + onSetPrimary: (walletId: string) => action.run(walletId, () => setPrimary(walletId)), + onRemove: remove, + connectSolana, + closeSolanaPicker: () => { + if (!action.isPending) { + setPicker(null); + } + }, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.test.ts new file mode 100644 index 00000000000..a38886c68b1 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizedWeb3Wallet, projectWeb3Wallets } from './user-profile-web3-wallets-section.model'; + +const metamask = { + id: 'wallet_metamask', + web3Wallet: '0x1234567890abcdef', + verification: { strategy: 'web3_metamask_signature', status: 'verified', expireAt: null }, +} as const; + +describe('Web3 wallet projection', () => { + it('matches the backend wallet sanitizer without folding Solana address case', () => { + expect(normalizedWeb3Wallet(' 0XAbCd ')).toBe('0xabcd'); + expect(normalizedWeb3Wallet(' SolanaAbCd ')).toBe('SolanaAbCd'); + expect(normalizedWeb3Wallet(' Solanaabcd ')).toBe('Solanaabcd'); + }); + + it('WEB3-01 shows configured providers that do not have a verified wallet', () => { + expect( + projectWeb3Wallets({ + wallets: [metamask], + primaryId: null, + enabledStrategies: ['web3_metamask_signature', 'web3_coinbase_wallet_signature'], + allowCreation: true, + }), + ).toMatchObject({ + status: 'ready', + availableProviders: [{ id: 'web3_coinbase_wallet_signature', provider: 'Coinbase Wallet' }], + }); + }); + + it('WEB3-02 keeps unverified wallets and their provider available', () => { + const result = projectWeb3Wallets({ + wallets: [{ ...metamask, verification: { ...metamask.verification, status: 'unverified' } }], + primaryId: null, + enabledStrategies: ['web3_metamask_signature'], + allowCreation: true, + }); + expect(result).toMatchObject({ + wallets: [{ id: 'wallet_metamask', provider: 'MetaMask', isVerified: false }], + availableProviders: [{ id: 'web3_metamask_signature' }], + }); + }); + + it('WEB3-03 shows admin wallets without a provider and filters unknown strategies', () => { + const result = projectWeb3Wallets({ + wallets: [ + { id: 'admin', web3Wallet: '0xadmin', verification: { strategy: 'admin', status: 'verified', expireAt: null } }, + { + id: 'unknown', + web3Wallet: '0xunknown', + verification: { strategy: 'unknown', status: 'verified', expireAt: null }, + }, + ], + primaryId: null, + enabledStrategies: [], + allowCreation: false, + }); + expect(result).toMatchObject({ status: 'ready', wallets: [{ id: 'admin', address: '0xadmin' }] }); + if (result.status === 'ready') { + expect(result.wallets[0].provider).toBeUndefined(); + } + }); + + it('WEB3-04 orders primary, verified, then unverified wallets without mutating the input', () => { + const wallets = Object.freeze([ + { + ...metamask, + id: 'unverified_later', + verification: { ...metamask.verification, status: 'unverified' as const, expireAt: new Date(200) }, + }, + { ...metamask, id: 'verified_z' }, + { ...metamask, id: 'verified_a' }, + { + ...metamask, + id: 'primary', + verification: { ...metamask.verification, status: 'unverified' as const }, + }, + { + ...metamask, + id: 'unverified_earlier', + verification: { ...metamask.verification, status: 'unverified' as const, expireAt: new Date(100) }, + }, + ]); + const result = projectWeb3Wallets({ + wallets, + primaryId: 'primary', + enabledStrategies: ['web3_metamask_signature'], + allowCreation: false, + }); + expect(result.status).toBe('ready'); + if (result.status === 'ready') { + expect(result.wallets.map(wallet => wallet.id)).toEqual([ + 'primary', + 'verified_a', + 'verified_z', + 'unverified_earlier', + 'unverified_later', + ]); + expect(result.availableProviders).toEqual([]); + } + expect(wallets.map(wallet => wallet.id)).toEqual([ + 'unverified_later', + 'verified_z', + 'verified_a', + 'primary', + 'unverified_earlier', + ]); + }); + + it('WEB3-04 keeps wallets without a verification status after expiring unverified wallets', () => { + const result = projectWeb3Wallets({ + wallets: [ + { ...metamask, id: 'missing', verification: { ...metamask.verification, status: null } }, + { + ...metamask, + id: 'unverified_without_expiry', + verification: { ...metamask.verification, status: 'unverified' }, + }, + { + ...metamask, + id: 'unverified_with_expiry', + verification: { ...metamask.verification, status: 'unverified', expireAt: new Date('2026-01-02') }, + }, + ], + primaryId: null, + enabledStrategies: [], + allowCreation: false, + }); + expect(result.status).toBe('ready'); + if (result.status === 'ready') { + expect(result.wallets.map(wallet => wallet.id)).toEqual([ + 'unverified_without_expiry', + 'unverified_with_expiry', + 'missing', + ]); + } + }); + + it('WEB3-05 hides the section without creation or existing wallets', () => { + expect(projectWeb3Wallets({ wallets: [], primaryId: null, enabledStrategies: [], allowCreation: false })).toEqual({ + status: 'hidden', + }); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.ts new file mode 100644 index 00000000000..992bb0502e6 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.ts @@ -0,0 +1,219 @@ +import { iconImageUrl } from '@clerk/shared/constants'; +import { createWeb3 } from '@clerk/shared/internal/clerk-js/web3'; +import { useClerk, useUser } from '@clerk/shared/react'; +import type { VerificationResource, Web3WalletResource } from '@clerk/shared/types'; +import { WEB3_PROVIDERS } from '@clerk/shared/web3'; + +import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; +import { useMessages } from '../../../localization'; +import { allowsIdentificationCreation } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.model'; +import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; +import { web3WalletFeedback } from './user-profile-web3-wallets-feedback'; +import type { UserProfileWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; +import { Web3WalletActionError } from './user-profile-web3-wallets-section.types'; + +export type Web3WalletEntry = Pick & { + verification: Pick; +}; + +export type Web3WalletsProjection = + | { status: 'hidden' } + | { status: 'ready'; wallets: UserProfileWeb3Wallet[]; availableProviders: UserProfileWeb3Provider[] }; + +export function normalizedWeb3Wallet(identifier: string): string { + const trimmed = identifier.trim(); + return /^0x/i.test(trimmed) ? trimmed.toLowerCase() : trimmed; +} + +export function projectWeb3Wallets({ + wallets, + primaryId, + enabledStrategies, + allowCreation, +}: { + wallets: readonly Web3WalletEntry[]; + primaryId: string | null; + enabledStrategies: readonly string[]; + allowCreation: boolean; +}): Web3WalletsProjection { + if (!allowCreation && wallets.length === 0) { + return { status: 'hidden' }; + } + + const providers = WEB3_PROVIDERS.filter(provider => enabledStrategies.includes(provider.strategy)); + const knownWallets = wallets.filter( + wallet => + wallet.verification.strategy === 'admin' || + WEB3_PROVIDERS.some(provider => provider.strategy === wallet.verification.strategy), + ); + const primary = knownWallets.filter(wallet => wallet.id === primaryId); + const remaining = knownWallets.filter(wallet => wallet.id !== primaryId); + const verified = remaining.filter(wallet => wallet.verification.status === 'verified'); + const unverified = remaining.filter( + wallet => wallet.verification.status !== null && wallet.verification.status !== 'verified', + ); + const withoutStatus = remaining.filter(wallet => wallet.verification.status === null); + + verified.sort((first, second) => first.id.localeCompare(second.id)); + unverified.sort((first, second) => { + const firstExpiry = first.verification.expireAt; + const secondExpiry = second.verification.expireAt; + return firstExpiry && secondExpiry ? firstExpiry.getTime() - secondExpiry.getTime() : 0; + }); + + const sorted = [...primary, ...verified, ...unverified, ...withoutStatus]; + + const connectedStrategies = new Set( + wallets.filter(wallet => wallet.verification.status === 'verified').map(wallet => wallet.verification.strategy), + ); + + return { + status: 'ready', + wallets: sorted.map(wallet => { + const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === wallet.verification.strategy); + return { + id: wallet.id, + address: wallet.web3Wallet, + provider: provider?.name, + iconUrl: provider ? iconImageUrl(provider.provider) : undefined, + isPrimary: wallet.id === primaryId, + isVerified: wallet.verification.status === 'verified', + }; + }), + availableProviders: allowCreation + ? providers + .filter(provider => !connectedStrategies.has(provider.strategy)) + .map(provider => ({ + id: provider.strategy, + walletPicker: provider.provider === 'solana' ? 'solana' : undefined, + provider: provider.name, + iconUrl: iconImageUrl(provider.provider), + })) + : [], + }; +} + +export function useUserProfileWeb3WalletsModel(): UserProfileWeb3WalletsModel { + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + const environment = useMosaicEnvironment(); + const messages = useMessages('userProfileWeb3Wallets'); + + if (!isLoaded || !environment) { + return { status: 'loading' }; + } + if (!user || !environment.userSettings.attributes.web3_wallet?.enabled) { + return { status: 'hidden' }; + } + const web3Attribute = environment.userSettings.attributes.web3_wallet; + const projection = projectWeb3Wallets({ + wallets: user.web3Wallets, + primaryId: user.primaryWeb3WalletId, + enabledStrategies: environment.userSettings.web3FirstFactors, + allowCreation: + !web3Attribute.immutable && allowsIdentificationCreation(user, environment.userSettings.enterpriseSSO), + }); + if (projection.status === 'hidden') { + return projection; + } + const userId = user.id; + const requireCurrentUser = () => { + const current = clerk.user; + if (!current || current.id !== userId) { + throw new Web3WalletActionError('providerUnavailable'); + } + return current; + }; + const runAction = async (action: () => Promise): Promise => { + try { + await action(); + } catch (error) { + throw web3WalletFeedback(error, messages); + } + }; + + return { + ...projection, + userId, + connect: (strategy, walletName) => + runAction(async () => { + requireCurrentUser(); + const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === strategy); + const available = projection.availableProviders.some(candidate => candidate.id === strategy); + const manager = clerk.__internal_moduleManager; + if (!provider || !available || !manager) { + throw new Web3WalletActionError('providerUnavailable'); + } + const web3 = createWeb3(manager); + const identifier = await web3.getWeb3Identifier({ provider: provider.provider, walletName }); + if (!identifier) { + throw new Web3WalletActionError('extensionUnavailable'); + } + const current = requireCurrentUser(); + const currentProjection = projectWeb3Wallets({ + wallets: current.web3Wallets, + primaryId: current.primaryWeb3WalletId, + enabledStrategies: environment.userSettings.web3FirstFactors, + allowCreation: + Boolean(environment.userSettings.attributes.web3_wallet?.enabled) && + !environment.userSettings.attributes.web3_wallet?.immutable && + allowsIdentificationCreation(current, environment.userSettings.enterpriseSSO), + }); + if ( + currentProjection.status !== 'ready' || + !currentProjection.availableProviders.some(candidate => candidate.id === strategy) + ) { + throw new Web3WalletActionError('providerUnavailable'); + } + const normalizedIdentifier = normalizedWeb3Wallet(identifier); + const existing = current.web3Wallets.find( + wallet => + wallet.verification.status !== 'verified' && + normalizedWeb3Wallet(wallet.web3Wallet) === normalizedIdentifier, + ); + const wallet = existing ?? (await current.createWeb3Wallet({ web3Wallet: identifier })); + requireCurrentUser(); + if (!wallet) { + throw new Web3WalletActionError('creationFailed'); + } + const prepared = await wallet.prepareVerification({ strategy: provider.strategy }); + requireCurrentUser(); + const nonce = prepared.verification.message; + if (!nonce) { + throw new Web3WalletActionError('messageUnavailable'); + } + const signature = await web3.generateWeb3Signature({ + identifier, + nonce, + provider: provider.provider, + walletName, + }); + requireCurrentUser(); + if (!signature) { + throw new Web3WalletActionError('signatureUnavailable'); + } + await prepared.attemptVerification({ signature }); + }), + setPrimary: walletId => + runAction(async () => { + const current = requireCurrentUser(); + if (!current.web3Wallets.some(wallet => wallet.id === walletId && wallet.verification.status === 'verified')) { + throw new Web3WalletActionError('providerUnavailable'); + } + await current.update({ primaryWeb3WalletId: walletId }); + }), + remove: web3Attribute.immutable + ? undefined + : walletId => + runAction(async () => { + if (environment.userSettings.attributes.web3_wallet?.immutable) { + throw new Web3WalletActionError('providerUnavailable'); + } + const wallet = requireCurrentUser().web3Wallets.find(wallet => wallet.id === walletId); + if (!wallet) { + throw new Web3WalletActionError('providerUnavailable'); + } + await wallet.destroy(); + }), + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx new file mode 100644 index 00000000000..a6294740ebc --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react'; + +import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; +import { UserProfileSolanaWalletDialog } from './user-profile-solana-wallet.dialog'; +import { useUserProfileWeb3WalletsController } from './user-profile-web3-wallets-section.controller'; +import { useUserProfileWeb3WalletsModel } from './user-profile-web3-wallets-section.model'; +import type { ReadyWeb3WalletsModel } from './user-profile-web3-wallets-section.types'; + +export interface UserProfileWeb3WalletsSectionProps { + fallback?: ReactNode; + fallbackFocus?: () => HTMLElement | null; +} + +export function UserProfileWeb3WalletsSection({ fallback, fallbackFocus }: UserProfileWeb3WalletsSectionProps) { + const model = useUserProfileWeb3WalletsModel(); + if (model.status === 'loading') { + return fallback ?? null; + } + if (model.status === 'hidden') { + return null; + } + return ( + + ); +} + +function Web3Wallets({ + model, + fallbackFocus, +}: { + model: ReadyWeb3WalletsModel; + fallbackFocus?: () => HTMLElement | null; +}) { + const controller = useUserProfileWeb3WalletsController(model); + return ( + <> + { + void controller.onConnect(id); + }} + onSetPrimary={id => { + void controller.onSetPrimary(id); + }} + onRemove={controller.onRemove} + /> + { + if (!open) { + controller.closeSolanaPicker(); + } + }} + onConnect={walletName => { + void controller.connectSolana(walletName); + }} + /> + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts new file mode 100644 index 00000000000..cdb5a5da66d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts @@ -0,0 +1,34 @@ +import type { Web3Strategy } from '@clerk/shared/types'; + +import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.view'; + +export type UserProfileWeb3WalletsModel = + | { status: 'loading' } + | { status: 'hidden' } + | { + status: 'ready'; + userId: string; + wallets: UserProfileWeb3Wallet[]; + availableProviders: UserProfileWeb3Provider[]; + connect: (strategy: Web3Strategy, walletName?: string) => Promise; + setPrimary: (walletId: string) => Promise; + remove?: (walletId: string) => Promise; + }; + +export type ReadyWeb3WalletsModel = Extract; + +export class Web3WalletActionError extends Error { + constructor( + readonly code: + | 'providerUnavailable' + | 'extensionUnavailable' + | 'creationFailed' + | 'messageUnavailable' + | 'signatureUnavailable', + message: string = code, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'Web3WalletActionError'; + } +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts index abdfb66e137..aa38e73ea96 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets.messages.ts @@ -7,6 +7,24 @@ export const userProfileWeb3WalletsMessages = { setPrimary: 'Set as primary', primary: 'Primary', unverified: 'Unverified', + errors: { + generic: 'Something went wrong. Please try again.', + providerUnavailable: 'This wallet provider is unavailable.', + extensionUnavailable: 'A Web3 Wallet extension cannot be found. Please install one to continue.', + creationFailed: 'The wallet could not be created.', + messageUnavailable: 'The wallet verification message is unavailable.', + signatureUnavailable: 'The wallet signature is unavailable.', + }, + solanaDialog: { + title: 'Select a Solana wallet', + description: 'Choose an installed wallet to connect to your account.', + loading: 'Loading wallets…', + loadError: 'Wallets could not be loaded. Please try again.', + retry: 'Retry', + noneAvailable: 'No Solana wallets are available.', + findWallet: 'Find a Solana wallet', + cancel: 'Cancel', + }, removeDialog: { title: 'Remove wallet?', description: '{wallet} will be removed from this account.', diff --git a/packages/mosaic/src/hooks/__tests__/use-pending-action.test.tsx b/packages/mosaic/src/hooks/__tests__/use-pending-action.test.tsx index e4febb92444..d7f1e423571 100644 --- a/packages/mosaic/src/hooks/__tests__/use-pending-action.test.tsx +++ b/packages/mosaic/src/hooks/__tests__/use-pending-action.test.tsx @@ -76,6 +76,52 @@ describe('usePendingAction', () => { expect(result.current.isPending).toBe(false); }); + it('holds the pending action until a redirect grace period completes', async () => { + vi.useFakeTimers(); + try { + const { result } = renderHook(() => usePendingAction({ errorFallback: 'Try again.' })); + act(() => { + void result.current.run('oauth', () => new Promise(resolve => setTimeout(resolve, 2000))); + }); + await act(() => vi.advanceTimersByTimeAsync(1999)); + expect(result.current.pendingKey).toBe('oauth'); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(result.current.pendingKey).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it.each([ + ['success', () => Promise.resolve()], + ['failure', () => Promise.reject(blocked())], + ])('blocks actions from the same render and releases them after %s', async (_label, settle) => { + const pending = deferred(); + const { result } = renderHook(() => usePendingAction()); + const action = result.current; + const second = vi.fn(() => Promise.resolve()); + let run: Promise | undefined; + let blockedRun: Promise | undefined; + + act(() => { + run = action.run('a', () => pending.promise.then(settle)); + blockedRun = action.run('b', second); + }); + expect(second).not.toHaveBeenCalled(); + expect(await blockedRun).toBe(false); + expect(result.current.isPending).toBe(true); + + await act(async () => { + pending.resolve(); + await run; + }); + expect(result.current.isPending).toBe(false); + await act(async () => { + expect(await action.run('b', second)).toBe(true); + }); + expect(second).toHaveBeenCalledOnce(); + }); + it('reports which key failed', async () => { const { result } = renderHook(() => usePendingAction()); diff --git a/packages/mosaic/vitest.config.mts b/packages/mosaic/vitest.config.mts index fd1a0353c67..4c14168ce9d 100644 --- a/packages/mosaic/vitest.config.mts +++ b/packages/mosaic/vitest.config.mts @@ -52,6 +52,7 @@ export default defineConfig({ optimizeDeps: { include: [ '@clerk/clerk-js', + '@wallet-standard/core', '@testing-library/jest-dom/matchers', '@testing-library/react', '@testing-library/user-event', diff --git a/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx b/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx new file mode 100644 index 00000000000..d268f2082fc --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/web3-wallets/page.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { UserProfileWeb3WalletsSection } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section'; +import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function Web3WalletsLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Web3 wallets

+

+ Connect, verify, and manage the signed-in user's wallets. +

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to manage wallets. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index f58a02ff76d..5b464dffcbc 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -18,6 +18,7 @@ const liveRoutes = new Map([ ['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/user-profile/user-profile-danger-section', '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/live/user-danger'], ['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/organization-profile/organization-profile-danger-section', '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/live/organization-danger'], ['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/user-profile/user-profile-passkeys-section', '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/live/passkeys'], + ['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/user-profile/user-profile-web3wallets-section', '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/live/web3-wallets'], ['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/reverification/reverification', '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/live/reverification'], ]); diff --git a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts index a02fd0c09dc..bf5fe3e718e 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts @@ -12,13 +12,17 @@ interface DemoWallet extends UserProfileWeb3Wallet { } const providers: UserProfileWeb3Provider[] = [ - { id: 'metamask', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg' }, - { id: 'coinbase-wallet', provider: 'Coinbase Wallet', iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg' }, + { id: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg' }, + { + id: 'web3_coinbase_wallet_signature', + provider: 'Coinbase Wallet', + iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg', + }, ]; export const primaryWallet: DemoWallet = { id: 'wallet_1', - providerId: 'metamask', + providerId: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg', address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', @@ -28,7 +32,7 @@ export const primaryWallet: DemoWallet = { export const secondaryWallet: DemoWallet = { id: 'wallet_2', - providerId: 'coinbase-wallet', + providerId: 'web3_coinbase_wallet_signature', provider: 'Coinbase Wallet', iconUrl: 'https://img.clerk.com/static/coinbase_wallet.svg', address: '0x1234567890abcdef1234567890abcdef12345678', diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx index 894cdd5963a..34ed28e8f18 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx @@ -9,7 +9,6 @@ export { default as __source } from './user-profile-web3-wallets-section.stories export const meta: StoryMeta = { group: 'User Profile', status: 'wip', - substatus: 'needs wire-up', title: 'UserProfileWeb3WalletsSection', label: 'Web3 wallets', navigation: { category: 'Sections' }, @@ -34,7 +33,7 @@ export function ConnectOnly() { export function UnverifiedWallet() { const fixture = useWeb3WalletsFixture({ initialWallets: [{ ...primaryWallet, isPrimary: false, isVerified: false }], - availableProviders: [{ id: 'metamask', provider: 'MetaMask', iconUrl: primaryWallet.iconUrl }], + availableProviders: [{ id: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: primaryWallet.iconUrl }], }); return ( ({ - wallets: [] as Wallet[], - listeners: { register: new Set<() => void>(), unregister: new Set<() => void>() }, -})); - -vi.mock('@wallet-standard/core', () => ({ - getWallets: () => ({ - get: () => registry.wallets, - on: (event: 'register' | 'unregister', listener: () => void) => { - registry.listeners[event].add(listener); - return () => registry.listeners[event].delete(listener); - }, - }), -})); - -const makeWallet = (name: string, chains: string[], features: string[]): Wallet => - ({ - name, - icon: 'data:image/svg+xml;base64,', - version: '1.0.0', - chains, - accounts: [], - features: Object.fromEntries(features.map(feature => [feature, {}])), - }) as unknown as Wallet; +let installedWallets: Wallet[] = []; +const unregister: Array<() => void> = []; + +function register(api: WindowAppReadyEventAPI) { + unregister.push(api.register(...installedWallets)); +} + +function onAppReady(event: Event & { detail?: WindowAppReadyEventAPI }) { + if (event.detail) { + register(event.detail); + } +} + +function installWallets(wallets: Wallet[]) { + installedWallets = wallets; + window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: register })); +} + +const makeWallet = (name: string, chains: Wallet['chains'], features: string[]): Wallet => ({ + name, + icon: 'data:image/svg+xml;base64,', + version: '1.0.0', + chains, + accounts: [], + features: Object.fromEntries(features.map(feature => [feature, {}])), +}); const SIGN_IN_FEATURES = ['standard:connect', 'solana:signMessage']; @@ -41,18 +43,22 @@ const Buttons = withCardStateProvider(Web3SolanaWalletButtons); describe('Web3SolanaWalletButtons', () => { beforeEach(() => { - registry.wallets = []; - registry.listeners.register.clear(); - registry.listeners.unregister.clear(); + installedWallets = []; + window.addEventListener('wallet-standard:app-ready', onAppReady); + }); + + afterEach(() => { + window.removeEventListener('wallet-standard:app-ready', onAppReady); + unregister.splice(0).forEach(off => off()); }); it('lists only Solana wallets that can connect and sign messages', async () => { - registry.wallets = [ + installWallets([ makeWallet('Phantom', ['solana:mainnet'], SIGN_IN_FEATURES), makeWallet('Solana Viewer', ['solana:mainnet'], ['standard:connect']), makeWallet('Solana Signer', ['solana:mainnet'], ['solana:signMessage']), makeWallet('MetaMask', ['eip155:1'], SIGN_IN_FEATURES), - ]; + ]); const { wrapper } = await createFixtures(); render(, { wrapper }); @@ -71,15 +77,20 @@ describe('Web3SolanaWalletButtons', () => { expect(await screen.findByText(/No Solana Web3 wallets detected/)).toBeInTheDocument(); act(() => { - registry.wallets = [makeWallet('Backpack', ['solana:mainnet'], SIGN_IN_FEATURES)]; - registry.listeners.register.forEach(listener => listener()); + installWallets([makeWallet('Backpack', ['solana:mainnet'], SIGN_IN_FEATURES)]); }); expect(await screen.findByText('Continue with Backpack')).toBeInTheDocument(); + + act(() => { + installedWallets = []; + unregister.splice(0).forEach(off => off()); + }); + expect(await screen.findByText(/No Solana Web3 wallets detected/)).toBeInTheDocument(); }); it('passes the chosen wallet name to the auth callback', async () => { - registry.wallets = [makeWallet('Phantom', ['solana:mainnet'], SIGN_IN_FEATURES)]; + installWallets([makeWallet('Phantom', ['solana:mainnet'], SIGN_IN_FEATURES)]); const web3AuthCallback = vi.fn().mockResolvedValue(undefined); const { wrapper } = await createFixtures(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4904ce74827..5f7d51c9f87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -810,6 +810,9 @@ importers: '@floating-ui/react': specifier: catalog:repo version: 0.27.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wallet-standard/core': + specifier: catalog:module-manager + version: 1.1.1 devDependencies: '@clerk/clerk-js': specifier: workspace:^