diff --git a/packages/@divvi/mobile/jest_setup.ts b/packages/@divvi/mobile/jest_setup.ts index 1313ef5781..c0fc1b3625 100644 --- a/packages/@divvi/mobile/jest_setup.ts +++ b/packages/@divvi/mobile/jest_setup.ts @@ -59,3 +59,16 @@ jest.mock('@react-native-firebase/messaging', () => jest.fn()) // this mock defaults to granting all permissions jest.mock('react-native-permissions', () => require('react-native-permissions/mock')) + +jest.mock('expo-constants', () => { + return { + __esModule: true, + default: { + expoConfig: { + extra: { + appIconBase64: 'data:image/png;base64,test-icon', + }, + }, + }, + } +}) diff --git a/packages/@divvi/mobile/package.json b/packages/@divvi/mobile/package.json index ab0a802717..0df3c9d56d 100644 --- a/packages/@divvi/mobile/package.json +++ b/packages/@divvi/mobile/package.json @@ -148,6 +148,7 @@ "date-fns": "^4.1.0", "dot-prop-immutable": "^2.1.1", "expo": "^53.0.22", + "expo-constants": "~17.1.7", "expo-image": "~2.4.0", "fast-levenshtein": "^3.0.0", "fp-ts": "2.16.9", diff --git a/packages/@divvi/mobile/plugin/src/index.ts b/packages/@divvi/mobile/plugin/src/index.ts index e236cb5d50..a9d1a641dc 100644 --- a/packages/@divvi/mobile/plugin/src/index.ts +++ b/packages/@divvi/mobile/plugin/src/index.ts @@ -2,6 +2,7 @@ import { ConfigPlugin, withPlugins } from '@expo/config-plugins' import { withAndroidUserAgent } from './withAndroidUserAgent' import { withAndroidWindowSoftInputModeAdjustNothing } from './withAndroidWindowSoftInputModeAdjustNothing' +import withAppIconBase64 from './withAppIconBase64' import { withIosAppDelegateResetKeychain } from './withIosAppDelegateResetKeychain' import { withIosUserAgent } from './withIosUserAgent' @@ -10,6 +11,8 @@ import { withIosUserAgent } from './withIosUserAgent' */ const withMobileApp: ConfigPlugin<{ appName?: string }> = (config, props = {}) => { return withPlugins(config, [ + withAppIconBase64, + // iOS withIosAppDelegateResetKeychain, [withIosUserAgent, props], diff --git a/packages/@divvi/mobile/plugin/src/withAppIconBase64.ts b/packages/@divvi/mobile/plugin/src/withAppIconBase64.ts new file mode 100644 index 0000000000..18635fddba --- /dev/null +++ b/packages/@divvi/mobile/plugin/src/withAppIconBase64.ts @@ -0,0 +1,42 @@ +import { ConfigPlugin, createRunOncePlugin, WarningAggregator } from '@expo/config-plugins' +import fs from 'fs' +import path from 'path' + +function addWarning(text: string) { + const property = 'appIconBase64' + WarningAggregator.addWarningAndroid(property, text) + WarningAggregator.addWarningIOS(property, text) +} + +const withAppIconBase64: ConfigPlugin = (config) => { + const iconPath = config.icon + + if (!iconPath) { + addWarning('No icon defined in the Expo config. Skipping base64 embedding.') + return config + } + + const projectRoot = config._internal?.projectRoot + const resolvedIconPath = path.resolve(projectRoot, iconPath) + + if (!fs.existsSync(resolvedIconPath)) { + addWarning(`Could not find the icon file at "${resolvedIconPath}"`) + return config + } + + try { + const base64 = fs.readFileSync(resolvedIconPath).toString('base64') + const dataUrl = `data:image/png;base64,${base64}` + + config.extra = { + ...config.extra, + appIconBase64: dataUrl, + } + } catch (error) { + addWarning(`Failed to read icon file at "${resolvedIconPath}": ${error}`) + } + + return config +} + +export default createRunOncePlugin(withAppIconBase64, '@divvi/mobile/withAppIconBase64') diff --git a/packages/@divvi/mobile/src/ethereumProvider/injectedProvider.test.ts b/packages/@divvi/mobile/src/ethereumProvider/injectedProvider.test.ts index 3925750eca..860fc4cbe1 100644 --- a/packages/@divvi/mobile/src/ethereumProvider/injectedProvider.test.ts +++ b/packages/@divvi/mobile/src/ethereumProvider/injectedProvider.test.ts @@ -1,18 +1,45 @@ +import { APP_BUNDLE_ID, APP_NAME } from 'src/config' import { getInjectedProviderScript } from './injectedProvider' +const mockAppIconBase64 = 'data:image/png;base64,test-icon' + describe('injectedProvider', () => { let mockWindow: any let originalWindow: any + let eventListeners: { [key: string]: Array<(event: any) => void> } + let dispatchEventCalls: CustomEvent[] beforeAll(() => { originalWindow = global.window }) beforeEach(() => { + eventListeners = {} + dispatchEventCalls = [] mockWindow = { ReactNativeWebView: { postMessage: jest.fn(), }, + addEventListener: jest.fn((event: string, handler: (event: any) => void) => { + if (!eventListeners[event]) { + eventListeners[event] = [] + } + eventListeners[event].push(handler) + }), + dispatchEvent: jest.fn((event: CustomEvent) => { + dispatchEventCalls.push(event) + const handlers = eventListeners[event.type] || [] + handlers.forEach((handler) => handler(event)) + return true + }), + CustomEvent: class CustomEvent { + type: string + detail: any + constructor(type: string, options?: { detail?: any }) { + this.type = type + this.detail = options?.detail + } + }, } global.window = mockWindow }) @@ -98,4 +125,63 @@ describe('injectedProvider', () => { expect(connectListener).toHaveBeenCalledWith({ chainId: '0x1' }) }) + + describe('EIP-6963 Provider Discovery', () => { + it('should announce provider immediately when script is injected', () => { + const script = getInjectedProviderScript({ isConnected: false, chainId: null }) + // eslint-disable-next-line no-eval + eval(script) + + expect(mockWindow.dispatchEvent).toHaveBeenCalled() + + // Find the announcement event + const announcementEvent = dispatchEventCalls.find( + (event) => event.type === 'eip6963:announceProvider' + ) + expect(announcementEvent).toBeDefined() + expect(announcementEvent?.detail).toBeDefined() + expect(announcementEvent?.detail.info).toBeDefined() + expect(announcementEvent?.detail.provider).toBe(mockWindow.ethereum) + + // Verify the info structure + const info = announcementEvent?.detail.info + expect(info).toHaveProperty('name', APP_NAME) + expect(info).toHaveProperty('rdns', APP_BUNDLE_ID) + expect(info).toHaveProperty('icon', mockAppIconBase64) + expect(info).toHaveProperty('uuid') + expect(typeof info.uuid).toBe('string') + expect(info.uuid.length).toBeGreaterThan(0) + }) + + it('should respond to eip6963:requestProvider event with announcement', () => { + const script = getInjectedProviderScript({ isConnected: true, chainId: '0x1' }) + // eslint-disable-next-line no-eval + eval(script) + + // Clear previous dispatch calls + dispatchEventCalls = [] + + // Dispatch the request event + const requestEvent = new mockWindow.CustomEvent('eip6963:requestProvider') + mockWindow.dispatchEvent(requestEvent) + + // Verify that an announcement was dispatched + const announcementEvent = dispatchEventCalls.find( + (event) => event.type === 'eip6963:announceProvider' + ) + expect(announcementEvent).toBeDefined() + expect(announcementEvent?.detail).toBeDefined() + expect(announcementEvent?.detail.info).toBeDefined() + expect(announcementEvent?.detail.provider).toBe(mockWindow.ethereum) + + // Verify the info structure + const info = announcementEvent?.detail.info + expect(info).toHaveProperty('name', APP_NAME) + expect(info).toHaveProperty('rdns', APP_BUNDLE_ID) + expect(info).toHaveProperty('icon', mockAppIconBase64) + expect(info).toHaveProperty('uuid') + expect(typeof info.uuid).toBe('string') + expect(info.uuid.length).toBeGreaterThan(0) + }) + }) }) diff --git a/packages/@divvi/mobile/src/ethereumProvider/injectedProvider.ts b/packages/@divvi/mobile/src/ethereumProvider/injectedProvider.ts index f683a09a5f..b766833fa4 100644 --- a/packages/@divvi/mobile/src/ethereumProvider/injectedProvider.ts +++ b/packages/@divvi/mobile/src/ethereumProvider/injectedProvider.ts @@ -1,5 +1,11 @@ +import Constants from 'expo-constants' +import { APP_BUNDLE_ID, APP_NAME } from 'src/config' +import { v4 as uuidv4 } from 'uuid' import { Hex } from 'viem' +const UUID = uuidv4() +const ICON_BASE64 = Constants.expoConfig?.extra?.appIconBase64 as string | undefined + export function getInjectedProviderScript({ isConnected = false, chainId = null, @@ -132,6 +138,28 @@ export function getInjectedProviderScript({ _handleResponse: handleResponse, _handleEvent: handleEvent, }; + + // EIP-6963 Provider Discovery + function announceProvider() { + var info = { + uuid: ${JSON.stringify(UUID)}, + name: ${JSON.stringify(APP_NAME)}, + rdns: ${JSON.stringify(APP_BUNDLE_ID)}, + icon: ${JSON.stringify(ICON_BASE64)}, + }; + + window.dispatchEvent( + new CustomEvent('eip6963:announceProvider', { + detail: { info: info, provider: window.ethereum } + }) + ); + } + + // Listen for dapp requests for providers + window.addEventListener('eip6963:requestProvider', announceProvider); + + // Announce immediately + announceProvider(); })(); true; // Required for injection to work `