Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/@divvi/mobile/jest_setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
},
}
})
1 change: 1 addition & 0 deletions packages/@divvi/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/@divvi/mobile/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -10,6 +11,8 @@ import { withIosUserAgent } from './withIosUserAgent'
*/
const withMobileApp: ConfigPlugin<{ appName?: string }> = (config, props = {}) => {
return withPlugins(config, [
withAppIconBase64,

// iOS
withIosAppDelegateResetKeychain,
[withIosUserAgent, props],
Expand Down
42 changes: 42 additions & 0 deletions packages/@divvi/mobile/plugin/src/withAppIconBase64.ts
Original file line number Diff line number Diff line change
@@ -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')
Original file line number Diff line number Diff line change
@@ -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
})
Expand Down Expand Up @@ -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)
})
})
})
28 changes: 28 additions & 0 deletions packages/@divvi/mobile/src/ethereumProvider/injectedProvider.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
`
Expand Down
Loading