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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/@divvi/mobile/src/ethereumProvider/events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { rpcError } from 'src/walletConnect/constants'
import { emitConnect, emitDisconnect } from './events'
import { createMockWebViewRef } from './testUtils'

describe('ethereumProvider events', () => {
describe('emitConnect', () => {
it('injects the connect event with chain id', () => {
const webViewRef = createMockWebViewRef()
emitConnect(webViewRef, '0x1')
expect(webViewRef.current?.injectJavaScript).toHaveBeenCalledWith(
expect.stringContaining(`event: 'connect'`)
)
expect(webViewRef.current?.injectJavaScript).toHaveBeenCalledWith(
expect.stringContaining(`data: ${JSON.stringify({ chainId: '0x1' })}`)
)
})
})

describe('emitDisconnect', () => {
it('injects the disconnect event with error payload', () => {
const webViewRef = createMockWebViewRef()
emitDisconnect(webViewRef)
expect(webViewRef.current?.injectJavaScript).toHaveBeenCalledWith(
expect.stringContaining(`event: 'disconnect'`)
)
expect(webViewRef.current?.injectJavaScript).toHaveBeenCalledWith(
expect.stringContaining(`data: ${JSON.stringify({ error: rpcError.DISCONNECTED })}`)
)
})
})
})
24 changes: 24 additions & 0 deletions packages/@divvi/mobile/src/ethereumProvider/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { WebViewRef } from 'src/components/WebView'
import { rpcError } from 'src/walletConnect/constants'
import { Hex } from 'viem'

function emitEvent(webViewRef: React.RefObject<WebViewRef>, event: string, data: any): void {
const script = `
if (window.ethereum && window.ethereum._handleEvent) {
window.ethereum._handleEvent({
event: '${event}',
data: ${JSON.stringify(data)}
});
}
true; // Required for injection to work
`
webViewRef.current?.injectJavaScript(script)
}

export function emitConnect(webViewRef: React.RefObject<WebViewRef>, chainId: Hex): void {
emitEvent(webViewRef, 'connect', { chainId })
}

export function emitDisconnect(webViewRef: React.RefObject<WebViewRef>): void {
emitEvent(webViewRef, 'disconnect', { error: rpcError.DISCONNECTED })
}
101 changes: 101 additions & 0 deletions packages/@divvi/mobile/src/ethereumProvider/injectedProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { getInjectedProviderScript } from './injectedProvider'

describe('injectedProvider', () => {
let mockWindow: any
let originalWindow: any

beforeAll(() => {
originalWindow = global.window
})

beforeEach(() => {
mockWindow = {
ReactNativeWebView: {
postMessage: jest.fn(),
},
}
global.window = mockWindow
})

afterEach(() => {
global.window = originalWindow
jest.clearAllMocks()
})

it('should inject the window.ethereum object', () => {
const script = getInjectedProviderScript({ isConnected: false, chainId: null })
// eslint-disable-next-line no-eval
eval(script)
expect(mockWindow.ethereum).toBeDefined()
})

it('should send a request message', () => {
const script = getInjectedProviderScript({ isConnected: true, chainId: '0x1' })
// eslint-disable-next-line no-eval
eval(script)
mockWindow.ethereum.request({ method: 'eth_requestAccounts', params: [] })

expect(mockWindow.ReactNativeWebView.postMessage).toHaveBeenCalledWith(
expect.stringContaining('"type":"request"')
)
expect(mockWindow.ReactNativeWebView.postMessage).toHaveBeenCalledWith(
expect.stringContaining('"method":"eth_requestAccounts"')
)
})

it('should handle a successful response', async () => {
const script = getInjectedProviderScript({ isConnected: true, chainId: '0x1' })
// eslint-disable-next-line no-eval
eval(script)
const requestPromise = mockWindow.ethereum.request({
method: 'eth_requestAccounts',
params: [],
})

const message = JSON.parse(mockWindow.ReactNativeWebView.postMessage.mock.calls[0][0])
const requestId = message.data.id

mockWindow.ethereum._handleResponse({
id: requestId,
result: ['0x123'],
})

await expect(requestPromise).resolves.toEqual(['0x123'])
})

it('should handle an error response', async () => {
const script = getInjectedProviderScript({ isConnected: true, chainId: '0x1' })
// eslint-disable-next-line no-eval
eval(script)
const requestPromise = mockWindow.ethereum.request({
method: 'eth_requestAccounts',
params: [],
})

const message = JSON.parse(mockWindow.ReactNativeWebView.postMessage.mock.calls[0][0])
const requestId = message.data.id
const error = { code: 4001, message: 'User Rejected Request' }

mockWindow.ethereum._handleResponse({
id: requestId,
error,
})

await expect(requestPromise).rejects.toEqual(error)
})

it('should handle events', () => {
const script = getInjectedProviderScript({ isConnected: false, chainId: null })
// eslint-disable-next-line no-eval
eval(script)
const connectListener = jest.fn()
mockWindow.ethereum.on('connect', connectListener)

mockWindow.ethereum._handleEvent({
event: 'connect',
data: { chainId: '0x1' },
})

expect(connectListener).toHaveBeenCalledWith({ chainId: '0x1' })
})
})
138 changes: 138 additions & 0 deletions packages/@divvi/mobile/src/ethereumProvider/injectedProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { Hex } from 'viem'

export function getInjectedProviderScript({
isConnected = false,
chainId = null,
}: {
isConnected: boolean
chainId: Hex | null
}): string {
return `
(function() {
// Prevent multiple injections
if (window.ethereum) {
return;
}

var _isConnected = ${isConnected};
var _chainId = ${JSON.stringify(chainId)};

var requestId = 0;
var pendingRequests = {};
var eventListeners = {};

// Generate unique request ID
function generateRequestId() {
return 'req_' + (++requestId) + '_' + Date.now();
}

// Execute callback with error handling
function executeCallback(callback, data) {
try {
callback(data);
} catch (error) {
console.error('Error in event listener:', error);
}
}

// Update internal state based on events
function updateInternalState(event, data) {
switch (event) {
case 'connect':
if (data && data.chainId) {
_chainId = data.chainId;
_isConnected = true;
}
break;
case 'disconnect':
_isConnected = false;
break;
}
}

// Handle responses from React Native
function handleResponse(response) {
var id = response.id;
var result = response.result;
var error = response.error;
var resolver = pendingRequests[id];

if (resolver) {
delete pendingRequests[id];
if (error) {
resolver.reject(error);
} else {
resolver.resolve(result);
}
}
}

// Handle events from React Native
function handleEvent(eventData) {
var event = eventData.event;
var data = eventData.data;

// Update internal state
updateInternalState(event, data);

var listeners = eventListeners[event] || [];
for (var i = 0; i < listeners.length; i++) {
executeCallback(listeners[i], data);
}
}

// Ethereum provider
window.ethereum = {
request: function(args) {
var method = args.method;
var params = args.params || [];

return new Promise(function(resolve, reject) {
var id = generateRequestId();
var request = {
id: id,
method: method,
params: params
};

// Store the promise resolvers
pendingRequests[id] = { resolve: resolve, reject: reject };

// Send request to React Native
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'request',
data: request
}));
});
},

on: function(event, callback) {
if (!eventListeners[event]) {
eventListeners[event] = [];
}
eventListeners[event].push(callback);

// Auto-fire 'connect' event for late subscribers
if (event === 'connect' && _isConnected && _chainId) {
executeCallback(callback, { chainId: _chainId });
}
},

removeListener: function(event, callback) {
var listeners = eventListeners[event];
if (listeners) {
var index = listeners.indexOf(callback);
if (index > -1) {
listeners.splice(index, 1);
}
}
},

// Internal methods for React Native communication
_handleResponse: handleResponse,
_handleEvent: handleEvent,
};
})();
true; // Required for injection to work
`
}
46 changes: 46 additions & 0 deletions packages/@divvi/mobile/src/ethereumProvider/requests.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { rpcError } from 'src/walletConnect/constants'
import { handleProviderRequest } from './requests'
import { createMockWebViewRef } from './testUtils'
import { EthereumProviderRequest } from './types'

describe('handleProviderRequest', () => {
it('should send an unsupported method error response', () => {
const webViewRef = createMockWebViewRef()
const request: EthereumProviderRequest = {
id: 'testId',
method: 'eth_requestAccounts',
params: [],
}

handleProviderRequest(webViewRef, request, true)

const expectedResponse = {
id: request.id,
error: rpcError.UNSUPPORTED_METHOD,
}

expect(webViewRef.current?.injectJavaScript).toHaveBeenCalledWith(
expect.stringContaining(JSON.stringify(expectedResponse))
)
})

it('should send a disconnected error response when offline', () => {
const webViewRef = createMockWebViewRef()
const request: EthereumProviderRequest = {
id: 'testId',
method: 'eth_requestAccounts',
params: [],
}

handleProviderRequest(webViewRef, request, false)

const expectedResponse = {
id: request.id,
error: rpcError.DISCONNECTED,
}

expect(webViewRef.current?.injectJavaScript).toHaveBeenCalledWith(
expect.stringContaining(JSON.stringify(expectedResponse))
)
})
})
48 changes: 48 additions & 0 deletions packages/@divvi/mobile/src/ethereumProvider/requests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { WebViewRef } from 'src/components/WebView'
import { rpcError } from 'src/walletConnect/constants'
import { EthereumProviderRequest, EthereumProviderResponse } from './types'

function sendResponseToWebView(
webViewRef: React.RefObject<WebViewRef>,
response: EthereumProviderResponse
): void {
const script = `
if (window.ethereum && window.ethereum._handleResponse) {
window.ethereum._handleResponse(${JSON.stringify(response)});
}
true; // Required for injection to work
`
webViewRef.current?.injectJavaScript(script)
}

export function handleProviderRequest(
webViewRef: React.RefObject<WebViewRef>,
request: EthereumProviderRequest,
isNetworkConnected: boolean
): void {
const { id, method: _ } = request
try {
if (!isNetworkConnected) {
const response: EthereumProviderResponse = {
id,
error: rpcError.DISCONNECTED,
}
sendResponseToWebView(webViewRef, response)
return
}

// TODO: Implement actual methods
const response: EthereumProviderResponse = {
id,
error: rpcError.UNSUPPORTED_METHOD,
}
sendResponseToWebView(webViewRef, response)
return
} catch (error) {
const response: EthereumProviderResponse = {
id,
error: rpcError.INTERNAL_ERROR,
}
sendResponseToWebView(webViewRef, response)
}
}
Loading
Loading