From a7e9252f6dcee80b8d578866fa33c36eed4726d8 Mon Sep 17 00:00:00 2001 From: zzggo Date: Tue, 10 Jun 2025 16:01:11 +1000 Subject: [PATCH 1/5] fixed: check the routing of method and route it to flow if connected. Closes #1040 --- src/content-script/pageProvider/eth/index.ts | 175 ++++++++++++++++--- 1 file changed, 151 insertions(+), 24 deletions(-) diff --git a/src/content-script/pageProvider/eth/index.ts b/src/content-script/pageProvider/eth/index.ts index 39bc74104..930e56def 100644 --- a/src/content-script/pageProvider/eth/index.ts +++ b/src/content-script/pageProvider/eth/index.ts @@ -3,6 +3,7 @@ import { EventEmitter } from 'events'; import { ethErrors, serializeError } from 'eth-rpc-errors'; +import { SAFE_RPC_METHODS } from '@/constant'; import { consoleError, consoleLog } from '@/shared/utils/console-log'; import DedupePromise from './pageProvider/dedupePromise'; @@ -334,7 +335,7 @@ declare global { web3: any; frw: EthereumProvider; flowWalletRouter: { - frwProvider: EthereumProvider; + flowProvider: EthereumProvider; lastInjectedProvider?: EthereumProvider; currentProvider: EthereumProvider; providers: EthereumProvider[]; @@ -346,7 +347,7 @@ declare global { const provider = new EthereumProvider(); patchProvider(provider); -const frwProvider = new Proxy(provider, { +const flowProvider = new Proxy(provider, { deleteProperty: (target, prop) => { if (typeof prop === 'string' && ['on', 'isFrw', 'isMetaMask', '_isFrw'].includes(prop)) { delete target[prop]; @@ -369,24 +370,92 @@ const requestIsDefaultWallet = () => { }) as Promise; }; +// Check if user is connected to Flow Wallet +const isConnectedToFlowWallet = (flowProvider: EthereumProvider) => { + return flowProvider.selectedAddress !== null && flowProvider.selectedAddress !== undefined; +}; + +// Determine if a method should be routed to Flow Wallet +const shouldRouteToFlowWallet = (method: string, connectedToFlowWallet: boolean) => { + // Read-only operations that should always be routed to Flow Wallet + const alwaysFlowWalletMethods = [ + 'eth_call', + 'eth_getBalance', + 'eth_getCode', + 'eth_blockNumber', + 'eth_gasPrice', + 'eth_getTransactionByHash', + 'eth_getTransactionReceipt', + 'eth_chainId', + 'net_version', + ]; + + // Methods that should only be routed to Flow Wallet when connected + const conditionalFlowWalletMethods = [ + 'eth_accounts', + 'eth_requestAccounts', + 'eth_sendTransaction', + 'eth_estimateGas', + 'eth_signTransaction', + 'eth_signTypedData', + 'eth_signTypedData_v3', + 'eth_signTypedData_v4', + 'personal_sign', + 'wallet_requestPermissions', + 'wallet_getPermissions', + 'wallet_revokePermissions', + 'wallet_switchEthereumChain', + 'wallet_watchAsset', + ]; + + // Always route certain methods to Flow Wallet + if (alwaysFlowWalletMethods.includes(method)) { + log('[routing]', `${method} -> Flow Wallet (always)`); + return true; + } + + // Route conditional methods only if connected to Flow Wallet + if (connectedToFlowWallet && conditionalFlowWalletMethods.includes(method)) { + log('[routing]', `${method} -> Flow Wallet (connected: ${connectedToFlowWallet})`); + return true; + } + + // For any other safe RPC methods, route to Flow Wallet if connected, otherwise to default provider + if (SAFE_RPC_METHODS.includes(method)) { + if (connectedToFlowWallet) { + log( + '[routing]', + `${method} -> Flow Wallet (safe method, connected: ${connectedToFlowWallet})` + ); + return true; + } else { + log('[routing]', `${method} -> Default provider (safe method, not connected)`); + return false; + } + } + + log('[routing]', `${method} -> Default provider (not in safe methods)`); + return false; +}; + const initOperaProvider = () => { - window.ethereum = frwProvider; - frwProvider._isReady = true; - window.frw = frwProvider; - patchProvider(frwProvider); - frwProvider.on('frw:chainChanged', switchChainNotice); + window.ethereum = flowProvider; + flowProvider._isReady = true; + window.frw = flowProvider; + patchProvider(flowProvider); + flowProvider.on('frw:chainChanged', switchChainNotice); }; const initProvider = () => { - frwProvider._isReady = true; - frwProvider.on('defaultWalletChanged', switchWalletNotice); - patchProvider(frwProvider); + flowProvider._isReady = true; + flowProvider.on('defaultWalletChanged', switchWalletNotice); + patchProvider(flowProvider); if (window.ethereum) { requestHasOtherProvider(); } if (!window.web3) { window.web3 = { - currentProvider: frwProvider, + currentProvider: flowProvider, }; } const descriptor = Object.getOwnPropertyDescriptor(window, 'ethereum'); @@ -395,13 +464,71 @@ const initProvider = () => { try { Object.defineProperties(window, { frw: { - value: frwProvider, + value: flowProvider, configurable: false, writable: false, }, ethereum: { get() { - return window.flowWalletRouter.currentProvider; + // Proxy that routes specific methods + const currentProvider = window.flowWalletRouter.currentProvider; + const flowProvider = window.flowWalletRouter.flowProvider; + + // If current provider is Flow Wallet, return it directly + if (currentProvider === flowProvider) { + return currentProvider; + } + + // Proxy that intercepts specific methods + return new Proxy(currentProvider, { + get(target, prop) { + // Handle request method specially + if (prop === 'request') { + return async (data) => { + // Check if user is connected to Flow Wallet + const connectedToFlowWallet = isConnectedToFlowWallet(flowProvider); + + if (data && shouldRouteToFlowWallet(data.method, connectedToFlowWallet)) { + return flowProvider.request(data); + } + // Route other methods to the default provider + return target.request(data); + }; + } + // Handle sendAsync method + if (prop === 'sendAsync') { + return async (payload, callback) => { + const connectedToFlowWallet = isConnectedToFlowWallet(flowProvider); + + if ( + payload && + !Array.isArray(payload) && + shouldRouteToFlowWallet(payload.method, connectedToFlowWallet) + ) { + return flowProvider.sendAsync(payload, callback); + } + return target.sendAsync(payload, callback); + }; + } + // Handle send method + if (prop === 'send') { + return async (payload, callback) => { + const connectedToFlowWallet = isConnectedToFlowWallet(flowProvider); + + if ( + typeof payload === 'object' && + payload.method && + shouldRouteToFlowWallet(payload.method, connectedToFlowWallet) + ) { + return flowProvider.send(payload, callback); + } + return target.send(payload, callback); + }; + } + // For other properties, return from the target + return target[prop]; + }, + }); }, set(newProvider) { window.flowWalletRouter.addProvider(newProvider); @@ -410,10 +537,10 @@ const initProvider = () => { }, flowWalletRouter: { value: { - frwProvider, + flowProvider, lastInjectedProvider: window.ethereum, - currentProvider: frwProvider, - providers: [frwProvider, ...(window.ethereum ? [window.ethereum] : [])], + currentProvider: flowProvider, + providers: [flowProvider, ...(window.ethereum ? [window.ethereum] : [])], setDefaultProvider(frwAsDefault: boolean) { if (frwAsDefault) { window.flowWalletRouter.currentProvider = window.frw; @@ -427,7 +554,7 @@ const initProvider = () => { if (!window.flowWalletRouter.providers.includes(provider)) { window.flowWalletRouter.providers.push(provider); } - if (frwProvider !== provider) { + if (flowProvider !== provider) { requestHasOtherProvider(); window.flowWalletRouter.lastInjectedProvider = provider; } @@ -441,12 +568,12 @@ const initProvider = () => { // think that defineProperty failed means there is any other wallet requestHasOtherProvider(); consoleError(e); - window.ethereum = frwProvider; - window.frw = frwProvider; + window.ethereum = flowProvider; + window.frw = flowProvider; } } else { - window.ethereum = frwProvider; - window.frw = frwProvider; + window.ethereum = flowProvider; + window.frw = flowProvider; } }; @@ -459,7 +586,7 @@ if (isOpera) { requestIsDefaultWallet().then((frwAsDefault) => { window.flowWalletRouter?.setDefaultProvider(frwAsDefault); if (frwAsDefault) { - window.ethereum = frwProvider; + window.ethereum = flowProvider; } }); @@ -482,9 +609,9 @@ const announceEip6963Provider = (provider: EthereumProvider) => { }; window.addEventListener('eip6963:requestProvider', (event: EIP6963RequestProviderEvent) => { - announceEip6963Provider(frwProvider); + announceEip6963Provider(flowProvider); }); -announceEip6963Provider(frwProvider); +announceEip6963Provider(flowProvider); window.dispatchEvent(new Event('ethereum#initialized')); From cf7725ff448494f1efb6be2468d530c489e58792 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:57:20 +1000 Subject: [PATCH 2/5] refactor: update manifest permissions and implement content script injection service - Added 'scripting' permission to both development and production manifests. - Updated 'host_permissions' to allow ''. - Introduced ContentScriptInjectionService to manage content script registration dynamically. - Removed legacy content script and replaced it with a message bridge for communication between the background script and the main world. - Updated EthereumProvider to utilize the new message bridge for communication. Closes #1040 --- _raw/manifest/manifest.dev.json | 18 +- _raw/manifest/manifest.pro.json | 19 +- build/webpack.common.config.ts | 1 + src/background/index.ts | 2 + .../service/content-script-injection.ts | 103 ++++++ src/background/service/index.ts | 1 + src/content-script/index.ts | 138 +------- src/content-script/message-bridge.ts | 208 ++++++++++++ src/content-script/pageProvider/eth/index.ts | 299 +++++++++++++++--- .../utils/message/externally-connectable.ts | 89 ++++++ .../eth/utils/message/post-message.ts | 60 ++++ 11 files changed, 744 insertions(+), 194 deletions(-) create mode 100644 src/background/service/content-script-injection.ts create mode 100644 src/content-script/message-bridge.ts create mode 100644 src/content-script/pageProvider/eth/utils/message/externally-connectable.ts create mode 100644 src/content-script/pageProvider/eth/utils/message/post-message.ts diff --git a/_raw/manifest/manifest.dev.json b/_raw/manifest/manifest.dev.json index 6d61dbaf2..3325f4f37 100644 --- a/_raw/manifest/manifest.dev.json +++ b/_raw/manifest/manifest.dev.json @@ -30,17 +30,19 @@ "background": { "service_worker": "background.js" }, - "content_scripts": [ - { - "js": ["content-script.js", "script.js"], - "matches": ["file://*/*", "http://*/*", "https://*/*"] - } - ], "content_security_policy": { "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self' 'wasm-unsafe-eval';" }, - "permissions": ["storage", "activeTab", "tabs", "notifications", "identity", "camera"], - "host_permissions": ["https://api.mixpanel.com/*"], + "permissions": [ + "storage", + "activeTab", + "tabs", + "notifications", + "identity", + "camera", + "scripting" + ], + "host_permissions": [""], "web_accessible_resources": [ { "resources": [ diff --git a/_raw/manifest/manifest.pro.json b/_raw/manifest/manifest.pro.json index 461ae5614..af9a93907 100644 --- a/_raw/manifest/manifest.pro.json +++ b/_raw/manifest/manifest.pro.json @@ -30,17 +30,20 @@ "background": { "service_worker": "sw.js" }, - "content_scripts": [ - { - "js": ["content-script.js", "script.js"], - "matches": ["file://*/*", "http://*/*", "https://*/*"] - } - ], "content_security_policy": { "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self' 'wasm-unsafe-eval';" }, - "permissions": ["storage", "activeTab", "tabs", "notifications", "identity", "camera", "*://*/*"], - "host_permissions": ["https://api.mixpanel.com/*"], + "permissions": [ + "storage", + "activeTab", + "tabs", + "notifications", + "identity", + "camera", + "scripting", + "*://*/*" + ], + "host_permissions": [""], "web_accessible_resources": [ { "resources": [ diff --git a/build/webpack.common.config.ts b/build/webpack.common.config.ts index 31d43410e..711e13cab 100644 --- a/build/webpack.common.config.ts +++ b/build/webpack.common.config.ts @@ -26,6 +26,7 @@ const config = (env: { config: 'dev' | 'pro' | 'none' }): webpack.Configuration entry: { background: paths.rootResolve('src/background/index.ts'), 'content-script': paths.rootResolve('src/content-script/index.ts'), + 'message-bridge': paths.rootResolve('src/content-script/message-bridge.ts'), pageProvider: paths.rootResolve('src/content-script/pageProvider/eth/index.ts'), // pageProvider: paths.rootResolve( // 'node_modules/@rabby-wallet/page-provider/dist/index.js' diff --git a/src/background/index.ts b/src/background/index.ts index 7273698b6..f4e092d79 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -40,6 +40,7 @@ import { tokenListService, remoteConfigService, newsService, + contentScriptInjectionService, } from './service'; import { getFirbaseConfig } from './utils/firebaseConfig'; import { setEnvironmentBadge } from './utils/setEnvironmentBadge'; @@ -131,6 +132,7 @@ async function restoreAppState() { await tokenListService.init(); await remoteConfigService.init(); await newsService.init(); + await contentScriptInjectionService.init(); // rpcCache.start(); appStoreLoaded = true; diff --git a/src/background/service/content-script-injection.ts b/src/background/service/content-script-injection.ts new file mode 100644 index 000000000..a721d8cb0 --- /dev/null +++ b/src/background/service/content-script-injection.ts @@ -0,0 +1,103 @@ +import { consoleError, consoleLog } from '@/shared/utils/console-log'; + +/* + * This content script is injected programmatically because + * MAIN world injection does not work properly via manifest + * https://bugs.chromium.org/p/chromium/issues/detail?id=634381 + */ + +class ContentScriptInjectionService { + private registeredScripts = new Set(); + + init = async () => { + // Register message bridge first and wait a bit to ensure it's loaded + await this.registerMessageBridge(); + + // Small delay to ensure message bridge is loaded and has injected UUIDs + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Then register the scripts that run in MAIN world + await this.registerPageProvider(); + await this.registerFlowScript(); + }; + + private registerMessageBridge = async () => { + try { + await chrome.scripting.registerContentScripts([ + { + id: 'messageBridge', + matches: ['file://*/*', 'http://*/*', 'https://*/*'], + js: ['message-bridge.js'], + runAt: 'document_start', + world: 'ISOLATED', + allFrames: true, + }, + ]); + this.registeredScripts.add('messageBridge'); + consoleLog('Successfully registered message bridge content script'); + } catch (err) { + consoleError(`Failed to register message bridge content script: ${err}`); + } + }; + + private registerPageProvider = async () => { + try { + await chrome.scripting.registerContentScripts([ + { + id: 'pageProvider', + matches: ['file://*/*', 'http://*/*', 'https://*/*'], + js: ['pageProvider.js'], + runAt: 'document_start', + world: 'MAIN', + allFrames: true, + }, + ]); + this.registeredScripts.add('pageProvider'); + consoleLog('Successfully registered pageProvider content script'); + } catch (err) { + consoleError(`Failed to register pageProvider content script: ${err}`); + } + }; + + private registerFlowScript = async () => { + try { + await chrome.scripting.registerContentScripts([ + { + id: 'flowScript', + matches: ['file://*/*', 'http://*/*', 'https://*/*'], + js: ['script.js'], + runAt: 'document_start', + world: 'MAIN', + allFrames: true, + }, + ]); + this.registeredScripts.add('flowScript'); + consoleLog('Successfully registered flow script content script'); + } catch (err) { + consoleError(`Failed to register flow script content script: ${err}`); + } + }; + + // Method to unregister scripts if needed + unregisterAllScripts = async () => { + try { + const registeredIds = Array.from(this.registeredScripts); + if (registeredIds.length > 0) { + await chrome.scripting.unregisterContentScripts({ + ids: registeredIds, + }); + this.registeredScripts.clear(); + consoleLog('Successfully unregistered all content scripts'); + } + } catch (err) { + consoleError(`Failed to unregister content scripts: ${err}`); + } + }; + + // Method to check if scripts are registered + getRegisteredScripts = () => { + return Array.from(this.registeredScripts); + }; +} + +export default new ContentScriptInjectionService(); diff --git a/src/background/service/index.ts b/src/background/service/index.ts index d44f81ac7..cc7ea054f 100644 --- a/src/background/service/index.ts +++ b/src/background/service/index.ts @@ -15,6 +15,7 @@ export { default as evmNftService } from './nft-evm'; export { default as googleDriveService } from './googleDrive'; export { default as googleSafeHostService } from './googleSafeHost'; export { default as signTextHistoryService } from './signTextHistory'; +export { default as contentScriptInjectionService } from './content-script-injection'; export { default as i18n } from './i18n'; export { default as newsService } from './news'; export { default as tokenListService } from './token-list'; diff --git a/src/content-script/index.ts b/src/content-script/index.ts index 0eebe0dde..e8a604417 100644 --- a/src/content-script/index.ts +++ b/src/content-script/index.ts @@ -1,134 +1,10 @@ -import { nanoid } from 'nanoid'; -import { v4 as uuid } from 'uuid'; +// This content script is no longer used. +// We now inject scripts directly from the background service worker using chrome.scripting API. +// This file is kept as a stub to avoid build issues. -import { Message } from '@/shared/utils/messaging'; +import { consoleLog } from '@/shared/utils/console-log'; -const channelName = nanoid(); +consoleLog('Legacy content script loaded but not used.'); -const injectProviderScript = async (isDefaultWallet) => { - // Set local storage variables - await localStorage.setItem('frw:channelName', channelName); - await localStorage.setItem('frw:isDefaultWallet', isDefaultWallet); - await localStorage.setItem('frw:uuid', uuid()); - - const container = document.head || document.documentElement; - const scriptElement = document.createElement('script'); - scriptElement.id = 'injectedScript'; - scriptElement.setAttribute('src', chrome.runtime.getURL('pageProvider.js')); - - container.insertBefore(scriptElement, container.children[0]); - - return scriptElement; -}; - -injectProviderScript(true); // Initial call to check and inject if needed - -const initListener = (channelName: string) => { - const { BroadcastChannelMessage, PortMessage } = Message; - const pm = new PortMessage().connect(); - const bcm = new BroadcastChannelMessage(channelName).listen((data) => pm.request(data)); - - // background notification - pm.on('message', (data) => bcm.send('message', data)); - - // pm.request({ - // type: EVENTS.UIToBackground, - // method: 'getScreen', - // params: { availHeight: screen.availHeight }, - // }); - - document.addEventListener('beforeunload', () => { - bcm.dispose(); - pm.dispose(); - }); -}; - -initListener(channelName); - -// because the content script run at document start -setTimeout(() => { - document.body.setAttribute('data-channel-name', channelName); -}, 0); - -/** - * Inject script - */ -// Listener for messages from window/FCL - -function injectScript(file_path, tag) { - const node = document.getElementsByTagName(tag)[0]; - const script = document.createElement('script'); - script.setAttribute('type', 'text/javascript'); - script.setAttribute('src', file_path); - node.appendChild(script); - chrome.runtime.sendMessage({ type: 'LILICO:CS:LOADED' }); -} - -injectScript(chrome.runtime.getURL('script.js'), 'body'); - -// Listener for messages from window/FCL -window.addEventListener('message', function (event) { - chrome.runtime.sendMessage(event.data); -}); - -// Listener for Custom Flow Transaction event from FCL send -// window.addEventListener('FLOW::TX', function (event) { -// // eslint-disable-next-line @typescript-eslint/ban-ts-comment -// // @ts-ignore: Event detail -// chrome.runtime.sendMessage({type: 'FLOW::TX', ...event.detail}) -// }) - -const extMessageHandler = (msg, _sender) => { - if (msg.type === 'FCL:VIEW:READY') { - if (window) { - window.postMessage(JSON.parse(JSON.stringify(msg || {})), '*'); - } - } - - if (msg.f_type && msg.f_type === 'PollingResponse') { - if (window) { - window.postMessage(JSON.parse(JSON.stringify({ ...msg, type: 'FCL:VIEW:RESPONSE' })), '*'); - } - } - - if (msg.data?.f_type && msg.data?.f_type === 'PreAuthzResponse') { - if (window) { - window.postMessage(JSON.parse(JSON.stringify({ ...msg, type: 'FCL:VIEW:RESPONSE' })), '*'); - } - } - - if (msg.type === 'FCL:VIEW:CLOSE') { - if (window) { - window.postMessage(JSON.parse(JSON.stringify(msg || {})), '*'); - } - } - - if (msg.type === 'FLOW::TX') { - if (window) { - window.postMessage(JSON.parse(JSON.stringify(msg || {})), '*'); - } - } - - if (msg.type === 'LILICO:NETWORK') { - if (window) { - window.postMessage(JSON.parse(JSON.stringify(msg || {})), '*'); - } - } - - return true; -}; - -/** - * Fired when a message is sent from either an extension process or another content script. - */ -chrome.runtime.onMessage.addListener(extMessageHandler); - -const wakeup = function () { - setTimeout(function () { - chrome.runtime.sendMessage('ping', function () { - return false; - }); - wakeup(); - }, 2000); -}; -wakeup(); +// Export empty object to satisfy module requirements +export {}; diff --git a/src/content-script/message-bridge.ts b/src/content-script/message-bridge.ts new file mode 100644 index 000000000..ba35d7dbb --- /dev/null +++ b/src/content-script/message-bridge.ts @@ -0,0 +1,208 @@ +import { consoleLog, consoleWarn, consoleError } from '@/shared/utils/console-log'; +import { Message } from '@/shared/utils/messaging'; + +const { PortMessage } = Message; + +/** + * This content script acts as a bridge between the MAIN world pageProvider + * and the extension background script. It runs in the ISOLATED world and + * can communicate with both. + */ + +class MessageBridge { + private pm: InstanceType | null = null; + private uuid: string; + private connectionEstablished = false; + + constructor() { + this.uuid = this.generateUUID(); + + consoleLog('[Flow Wallet Bridge] Initializing bridge with UUID:', this.uuid); + + // Inject UUID immediately before anything else using custom events + this.injectUUID(); + + // Try to initialize message passing, but don't fail if it doesn't work + this.initializeConnection(); + this.init(); + } + + private generateUUID(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } + + private initializeConnection() { + try { + this.pm = new PortMessage().connect(); + this.connectionEstablished = true; + consoleLog('[Flow Wallet Bridge] Successfully connected to background script'); + } catch (error) { + consoleWarn('[Flow Wallet Bridge] Failed to connect to background script:', error); + this.connectionEstablished = false; + this.pm = null; + } + } + + private init() { + // Listen for messages from the MAIN world pageProvider + window.addEventListener('message', this.handlePageMessage); + + // Listen for messages from the background script if connected + if (this.pm && this.connectionEstablished) { + try { + this.pm.on('message', this.handleBackgroundMessage); + } catch (error) { + consoleWarn('[Flow Wallet Bridge] Failed to set up background message listener:', error); + } + } + + // Clean up on unload + window.addEventListener('beforeunload', this.cleanup); + } + + private injectUUID() { + // Generate a stable wallet UUID based on the extension ID + const extensionId = chrome.runtime.id; + const walletUUID = `${extensionId}-flow-wallet`; + + consoleLog( + '[Flow Wallet Bridge] Injecting UUIDs via custom events - Bridge:', + this.uuid, + 'Wallet:', + walletUUID + ); + + // Store UUIDs in a global object for late-loading scripts + (window as any).__frwUUIDs = { + bridgeUUID: this.uuid, + walletUUID: walletUUID, + }; + + // Dispatch event for scripts that are already loaded + document.dispatchEvent( + new CustomEvent('frw:bridge-uuid', { + detail: { + bridgeUUID: this.uuid, + walletUUID: walletUUID, + }, + }) + ); + + // Also dispatch on DOM ready for late-loading scripts + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + document.dispatchEvent( + new CustomEvent('frw:bridge-uuid', { + detail: { + bridgeUUID: this.uuid, + walletUUID: walletUUID, + }, + }) + ); + }); + } + + consoleLog('[Flow Wallet Bridge] UUID injection complete via custom events and global storage'); + } + + private handlePageMessage = (event: MessageEvent) => { + // Only handle messages from our pageProvider + if ( + !event.data || + !event.data.source || + event.data.source !== 'frw-provider' || + event.data.uuid !== this.uuid + ) { + return; + } + + const { type, data } = event.data; + + if (type === 'frw:request') { + // Check if we have a working connection + if (!this.pm || !this.connectionEstablished) { + consoleWarn( + '[Flow Wallet Bridge] No connection to background script, cannot forward request' + ); + // Send error back to pageProvider + window.postMessage( + { + type: 'frw:response', + data: { ident: data.ident, err: { message: 'No connection to background script' } }, + uuid: this.uuid, + source: 'frw-bridge', + }, + '*' + ); + return; + } + + // Forward request to background script + this.pm + .request(data.data) + .then((response) => { + // Send response back to pageProvider + window.postMessage( + { + type: 'frw:response', + data: { ident: data.ident, res: response }, + uuid: this.uuid, + source: 'frw-bridge', + }, + '*' + ); + }) + .catch((error) => { + consoleWarn('[Flow Wallet Bridge] Request failed:', error); + // Send error back to pageProvider + window.postMessage( + { + type: 'frw:response', + data: { ident: data.ident, err: error }, + uuid: this.uuid, + source: 'frw-bridge', + }, + '*' + ); + }); + } + }; + + private handleBackgroundMessage = (data: any) => { + // Forward messages from background to pageProvider + window.postMessage( + { + type: 'frw:message', + data, + uuid: this.uuid, + source: 'frw-bridge', + }, + '*' + ); + }; + + private cleanup = () => { + window.removeEventListener('message', this.handlePageMessage); + window.removeEventListener('beforeunload', this.cleanup); + if (this.pm) { + try { + this.pm.dispose(); + } catch (error) { + consoleWarn('[Flow Wallet Bridge] Error during cleanup:', error); + } + } + }; +} + +// Initialize the bridge +consoleLog('[Flow Wallet Bridge] Starting message bridge initialization'); +try { + new MessageBridge(); + consoleLog('[Flow Wallet Bridge] Message bridge initialized successfully'); +} catch (error) { + consoleError('[Flow Wallet Bridge] Failed to initialize message bridge:', error); +} diff --git a/src/content-script/pageProvider/eth/index.ts b/src/content-script/pageProvider/eth/index.ts index 930e56def..dcc0abf24 100644 --- a/src/content-script/pageProvider/eth/index.ts +++ b/src/content-script/pageProvider/eth/index.ts @@ -13,6 +13,7 @@ import PushEventHandlers from './pageProvider/pushEventHandlers'; import ReadyPromise from './pageProvider/readyPromise'; import { domReadyCall, $ } from './pageProvider/utils'; import BroadcastChannelMessage from './utils/message/broadcastChannelMessage'; +import PostMessage from './utils/message/post-message'; import { patchProvider } from './utils/metamask'; declare const __frw__channelName; @@ -36,6 +37,60 @@ let isDefaultWallet = let isOpera = typeof __frw__isOpera !== 'undefined' ? __frw__isOpera : false; let uuid = typeof __frw__uuid !== 'undefined' ? __frw__uuid : ''; +// Check if we're running in MAIN world by looking for the bridge UUID event +let isMainWorld = false; +let bridgeUUID = ''; + +log('PageProvider starting - checking for bridge UUID'); + +// First check if UUIDs are already available from global storage +const existingUUIDs = (window as any).__frwUUIDs; +if (existingUUIDs?.bridgeUUID && existingUUIDs?.walletUUID) { + isMainWorld = true; + bridgeUUID = existingUUIDs.bridgeUUID; + uuid = existingUUIDs.walletUUID; + + // Store in window for easy access + (window as any).__frwBridgeUUID = bridgeUUID; + (window as any).__frwWalletUUID = uuid; + + log('Found existing UUIDs - bridge UUID:', bridgeUUID, 'wallet UUID:', uuid); +} else { + log('No existing UUIDs found, waiting for bridge UUID event'); +} + +// Function to wait for wallet UUID +const waitForWalletUUID = (): Promise => { + return new Promise((resolve) => { + if (uuid) { + log('UUID already available:', uuid); + resolve(uuid); + return; + } + + let checkCount = 0; + + const timeoutId = setTimeout(() => { + clearInterval(checkInterval); + const fallbackUUID = uuid || 'flow-wallet-default'; + log(`UUID wait timed out after ${checkCount} checks, using fallback:`, fallbackUUID); + resolve(fallbackUUID); + }, 1000); + + // Check periodically for the UUID + const checkInterval = setInterval(() => { + checkCount++; + if (typeof (window as any).__frwWalletUUID !== 'undefined') { + uuid = (window as any).__frwWalletUUID; + log(`Got UUID after ${checkCount} checks:`, uuid); + clearInterval(checkInterval); + clearTimeout(timeoutId); + resolve(uuid); + } + }, 10); + }); +}; + const getParams = () => { if (localStorage.getItem('frw:channelName')) { channelName = localStorage.getItem('frw:channelName') as string; @@ -123,17 +178,37 @@ export class EthereumProvider extends EventEmitter { private _pushEventHandlers: PushEventHandlers; private _requestPromise = new ReadyPromise(2); private _dedupePromise = new DedupePromise([]); - private _bcm = new BroadcastChannelMessage(channelName); + private _bcm: BroadcastChannelMessage | PostMessage; constructor({ maxListeners = 100 } = {}) { super(); this.setMaxListeners(maxListeners); + + // Initialize communication based on execution context + log( + 'EthereumProvider constructor - isMainWorld:', + isMainWorld, + 'bridgeUUID:', + bridgeUUID, + 'channelName:', + channelName + ); + + if (isMainWorld && bridgeUUID) { + log('Creating PostMessage with bridgeUUID:', bridgeUUID); + this._bcm = new PostMessage(bridgeUUID); + } else { + log('Creating BroadcastChannelMessage with channelName:', channelName); + this._bcm = new BroadcastChannelMessage(channelName); + } + this.initialize(); this.shimLegacy(); this._pushEventHandlers = new PushEventHandlers(this); } initialize = async () => { + log('EthereumProvider - initialize'); document.addEventListener('visibilitychange', this._requestPromiseCheckVisibility); this._bcm.connect().on('message', this._handleBackgroundMessage); @@ -182,6 +257,7 @@ export class EthereumProvider extends EventEmitter { }; private _requestPromiseCheckVisibility = () => { + log('EthereumProvider - _requestPromiseCheckVisibility', document.visibilityState); if (document.visibilityState === 'visible') { this._requestPromise.check(1); } else { @@ -199,11 +275,13 @@ export class EthereumProvider extends EventEmitter { }; isConnected = () => { + log('EthereumProvider - isConnected', this._isConnected); return true; }; // TODO: support multi request! request = async (data) => { + log('EthereumProvider - request', data); if (!this._isReady) { const promise = new Promise((resolve, reject) => { this._cacheRequestsBeforeReady.push({ @@ -218,6 +296,7 @@ export class EthereumProvider extends EventEmitter { }; _request = async (data) => { + log('EthereumProvider - _request', data); if (!data) { throw ethErrors.rpc.invalidRequest(); } @@ -247,11 +326,13 @@ export class EthereumProvider extends EventEmitter { }; requestInternalMethods = (data) => { + log('EthereumProvider - requestInternalMethods', data); return this._dedupePromise.call(data.method, () => this._request(data)); }; // shim to matamask legacy api sendAsync = (payload, callback) => { + log('EthereumProvider - sendAsync', payload); if (Array.isArray(payload)) { return Promise.all( payload.map( @@ -272,6 +353,7 @@ export class EthereumProvider extends EventEmitter { }; send = (payload, callback?) => { + log('EthereumProvider - send', payload); if (typeof payload === 'string' && (!callback || Array.isArray(callback))) { // send(method, params? = []) return this.request({ @@ -310,6 +392,7 @@ export class EthereumProvider extends EventEmitter { }; shimLegacy = () => { + log('EthereumProvider - shimLegacy'); const legacyMethods = [ ['enable', 'eth_requestAccounts'], ['net_version', 'net_version'], @@ -345,25 +428,69 @@ declare global { } } -const provider = new EthereumProvider(); -patchProvider(provider); -const flowProvider = new Proxy(provider, { - deleteProperty: (target, prop) => { - if (typeof prop === 'string' && ['on', 'isFrw', 'isMetaMask', '_isFrw'].includes(prop)) { - delete target[prop]; - } - return true; - }, +// Will be initialized asynchronously +let flowProvider: EthereumProvider | null = null; +let resolveProvider: (provider: EthereumProvider) => void; +const flowProviderPromise = new Promise((resolve) => { + resolveProvider = resolve; }); -const requestHasOtherProvider = () => { +// Listen for the bridge event to set config and initialize the provider +document.addEventListener( + 'frw:bridge-uuid', + ((event: CustomEvent) => { + log('Received bridge UUID event, setting config', event.detail); + if (event.detail?.bridgeUUID && event.detail?.walletUUID) { + isMainWorld = true; + bridgeUUID = event.detail.bridgeUUID; + uuid = event.detail.walletUUID; + + // Store in window for easy access + (window as any).__frwBridgeUUID = bridgeUUID; + (window as any).__frwWalletUUID = uuid; + + log('Updated from event - bridge UUID:', bridgeUUID, 'wallet UUID:', uuid); + } + // Initialize the provider now that we have the config + initializeFlowProvider(); + }) as EventListener, + { once: true } +); // Important: only handle this once + +const createProvider = () => { + const provider = new EthereumProvider(); + patchProvider(provider); + return new Proxy(provider, { + deleteProperty: (target, prop) => { + if (typeof prop === 'string' && ['on', 'isFrw', 'isMetaMask', '_isFrw'].includes(prop)) { + delete target[prop]; + } + return true; + }, + }); +}; + +const initializeFlowProvider = () => { + // Prevent double-initialization + if (flowProvider) { + return; + } + log('Initializing Flow provider...'); + flowProvider = createProvider(); + resolveProvider(flowProvider); // Resolve the promise for other parts of the script + return flowProvider; +}; + +const requestHasOtherProvider = async () => { + const provider = await flowProviderPromise; return provider.requestInternalMethods({ method: 'hasOtherProvider', params: [], }); }; -const requestIsDefaultWallet = () => { +const requestIsDefaultWallet = async () => { + const provider = await flowProviderPromise; return provider.requestInternalMethods({ method: 'isDefaultWallet', params: [], @@ -438,24 +565,26 @@ const shouldRouteToFlowWallet = (method: string, connectedToFlowWallet: boolean) return false; }; -const initOperaProvider = () => { - window.ethereum = flowProvider; - flowProvider._isReady = true; - window.frw = flowProvider; - patchProvider(flowProvider); - flowProvider.on('frw:chainChanged', switchChainNotice); +const initOperaProvider = async () => { + const provider = await flowProviderPromise; + window.ethereum = provider; + provider._isReady = true; + window.frw = provider; + patchProvider(provider); + provider.on('frw:chainChanged', switchChainNotice); }; -const initProvider = () => { - flowProvider._isReady = true; - flowProvider.on('defaultWalletChanged', switchWalletNotice); - patchProvider(flowProvider); +const initProvider = async () => { + const provider = await flowProviderPromise; + provider._isReady = true; + provider.on('defaultWalletChanged', switchWalletNotice); + patchProvider(provider); if (window.ethereum) { - requestHasOtherProvider(); + await requestHasOtherProvider(); } if (!window.web3) { window.web3 = { - currentProvider: flowProvider, + currentProvider: provider, }; } const descriptor = Object.getOwnPropertyDescriptor(window, 'ethereum'); @@ -464,12 +593,13 @@ const initProvider = () => { try { Object.defineProperties(window, { frw: { - value: flowProvider, + value: provider, configurable: false, writable: false, }, ethereum: { get() { + log('ethereum get'); // Proxy that routes specific methods const currentProvider = window.flowWalletRouter.currentProvider; const flowProvider = window.flowWalletRouter.flowProvider; @@ -537,10 +667,10 @@ const initProvider = () => { }, flowWalletRouter: { value: { - flowProvider, + flowProvider: provider, lastInjectedProvider: window.ethereum, - currentProvider: flowProvider, - providers: [flowProvider, ...(window.ethereum ? [window.ethereum] : [])], + currentProvider: provider, + providers: [provider, ...(window.ethereum ? [window.ethereum] : [])], setDefaultProvider(frwAsDefault: boolean) { if (frwAsDefault) { window.flowWalletRouter.currentProvider = window.frw; @@ -566,34 +696,66 @@ const initProvider = () => { }); } catch (e) { // think that defineProperty failed means there is any other wallet - requestHasOtherProvider(); + await requestHasOtherProvider(); consoleError(e); - window.ethereum = flowProvider; - window.frw = flowProvider; + window.ethereum = provider; + window.frw = provider; } } else { - window.ethereum = flowProvider; - window.frw = flowProvider; + window.ethereum = provider; + window.frw = provider; } }; -if (isOpera) { - initOperaProvider(); -} else { - initProvider(); -} +// Set up initialization promises +const providerInitPromise = flowProviderPromise.then((provider) => { + return new Promise((resolve) => { + const checkInit = () => { + if (provider._initialized) { + resolve(); + } else { + provider.once('_initialized', resolve); + } + }; + checkInit(); + }); +}); -requestIsDefaultWallet().then((frwAsDefault) => { - window.flowWalletRouter?.setDefaultProvider(frwAsDefault); - if (frwAsDefault) { - window.ethereum = flowProvider; +(async () => { + try { + if (isOpera) { + await initOperaProvider(); + } else { + await initProvider(); + } + } catch (e) { + consoleError('An error occurred while initializing the Flow wallet provider', e); } -}); +})(); + +(async () => { + try { + const provider = await flowProviderPromise; + const frwAsDefault = await requestIsDefaultWallet(); + window.flowWalletRouter?.setDefaultProvider(frwAsDefault); + if (frwAsDefault) { + window.ethereum = provider; + } + } catch (e) { + consoleError('An error occurred while checking if the Flow wallet is the default wallet', e); + } +})(); const EIP6963Icon = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjUwIiBoZWlnaHQ9IjI1MCIgdmlld0JveD0iMCAwIDI1MCAyNTAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMzc2MV8zNTIxKSI+CjxyZWN0IHdpZHRoPSIyNTAiIGhlaWdodD0iMjUwIiByeD0iNDYuODc1IiBmaWxsPSJ3aGl0ZSIvPgo8ZyBjbGlwLXBhdGg9InVybCgjY2xpcDFfMTM3NjFfMzUyMSkiPgo8cmVjdCB3aWR0aD0iMjUwIiBoZWlnaHQ9IjI1MCIgZmlsbD0idXJsKCNwYWludDBfbGluZWFyXzEzNzYxXzM1MjEpIi8+CjxwYXRoIGQ9Ik0xMjUgMjE3LjUyOUMxNzYuMTAyIDIxNy41MjkgMjE3LjUyOSAxNzYuMTAyIDIxNy41MjkgMTI1QzIxNy41MjkgNzMuODk3NSAxNzYuMTAyIDMyLjQ3MDcgMTI1IDMyLjQ3MDdDNzMuODk3NSAzMi40NzA3IDMyLjQ3MDcgNzMuODk3NSAzMi40NzA3IDEyNUMzMi40NzA3IDE3Ni4xMDIgNzMuODk3NSAyMTcuNTI5IDEyNSAyMTcuNTI5WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTE2NS4zODIgMTEwLjQyMkgxMzkuNTg1VjEzNi43OEgxNjUuMzgyVjExMC40MjJaIiBmaWxsPSJibGFjayIvPgo8cGF0aCBkPSJNMTEzLjIyNyAxMzYuNzhIMTM5LjU4NVYxMTAuNDIySDExMy4yMjdWMTM2Ljc4WiIgZmlsbD0iIzQxQ0M1RCIvPgo8L2c+CjwvZz4KPGRlZnM+CjxsaW5lYXJHcmFkaWVudCBpZD0icGFpbnQwX2xpbmVhcl8xMzc2MV8zNTIxIiB4MT0iMCIgeTE9IjAiIHgyPSIyNTAiIHkyPSIyNTAiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KPHN0b3Agc3RvcC1jb2xvcj0iIzFDRUI4QSIvPgo8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiM0MUNDNUQiLz4KPC9saW5lYXJHcmFkaWVudD4KPGNsaXBQYXRoIGlkPSJjbGlwMF8xMzc2MV8zNTIxIj4KPHJlY3Qgd2lkdGg9IjI1MCIgaGVpZ2h0PSIyNTAiIHJ4PSI0Ni44NzUiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjxjbGlwUGF0aCBpZD0iY2xpcDFfMTM3NjFfMzUyMSI+CjxyZWN0IHdpZHRoPSIyNTAiIGhlaWdodD0iMjUwIiBmaWxsPSJ3aGl0ZSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo='; const announceEip6963Provider = (provider: EthereumProvider) => { + // Ensure we have a valid UUID before announcing + if (!uuid) { + log('EIP-6963: No UUID available, skipping announcement'); + return; + } + const info: EIP6963ProviderInfo = { uuid: uuid, name: 'Flow Wallet', @@ -601,6 +763,8 @@ const announceEip6963Provider = (provider: EthereumProvider) => { rdns: 'com.flowfoundation.wallet', }; + log('EIP-6963: Announcing provider with UUID', uuid); + window.dispatchEvent( new CustomEvent('eip6963:announceProvider', { detail: Object.freeze({ info, provider }), @@ -608,10 +772,51 @@ const announceEip6963Provider = (provider: EthereumProvider) => { ); }; -window.addEventListener('eip6963:requestProvider', (event: EIP6963RequestProviderEvent) => { - announceEip6963Provider(flowProvider); -}); +// Set up EIP-6963 event listener +window.addEventListener( + 'eip6963:requestProvider', + async (event: EIP6963RequestProviderEvent) => { + log('eip6963:requestProvider', event); + try { + const provider = await flowProviderPromise; + // Only announce if provider is ready + if (provider._isReady) { + announceEip6963Provider(provider); + } else { + // If not ready, wait for it + await providerInitPromise; + announceEip6963Provider(provider); + } + } catch (e) { + log('Error handling eip6963:requestProvider:', e); + } + } +); + +// Wait for both UUIDs and provider initialization +Promise.all([waitForWalletUUID(), flowProviderPromise]) + .then(([walletUUID, provider]) => { + providerInitPromise.then(() => { + log('Provider initialized and got wallet UUID:', walletUUID); -announceEip6963Provider(flowProvider); + // Announce immediately when both are ready + announceEip6963Provider(provider); + + // Also announce when DOM is ready (for late-loading dApps) + domReadyCall(() => { + log('DOM ready, announcing EIP-6963 provider again'); + announceEip6963Provider(provider); + }); + + // Announce after a short delay to catch very late-loading dApps + setTimeout(() => { + log('Delayed EIP-6963 announcement'); + announceEip6963Provider(provider); + }, 100); + }); + }) + .catch((e) => { + log('Error in EIP-6963 setup:', e); + }); window.dispatchEvent(new Event('ethereum#initialized')); diff --git a/src/content-script/pageProvider/eth/utils/message/externally-connectable.ts b/src/content-script/pageProvider/eth/utils/message/externally-connectable.ts new file mode 100644 index 000000000..03b6d24dd --- /dev/null +++ b/src/content-script/pageProvider/eth/utils/message/externally-connectable.ts @@ -0,0 +1,89 @@ +import { consoleError, consoleWarn } from '@/shared/utils/console-log'; + +import Message from './index'; + +/** + * This uses the externally_connectable pattern where the webpage + * can directly send messages to the extension if the manifest allows it. + * This eliminates the need for a content script bridge. + */ +export default class ExternallyConnectableMessage extends Message { + private port: chrome.runtime.Port | null = null; + private extensionId: string; + + constructor(extensionId: string) { + super(); + this.extensionId = extensionId; + } + + connect = () => { + // Check if we can use chrome.runtime (might not be available in all contexts) + if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.connect) { + try { + this.port = chrome.runtime.connect(this.extensionId, { name: 'pageProvider' }); + + this.port.onMessage.addListener((message) => { + const { type, data } = message; + if (type === 'message') { + this.emit('message', data); + } else if (type === 'response') { + this.onResponse(data); + } + }); + + this.port.onDisconnect.addListener(() => { + consoleWarn('Flow Wallet: Port disconnected, attempting reconnect...'); + setTimeout(() => this.connect(), 1000); + }); + } catch (error) { + consoleError('Flow Wallet: Failed to connect to extension', error); + // Fall back to postMessage if direct connection fails + this.connectViaPostMessage(); + } + } else { + // Use postMessage as fallback + this.connectViaPostMessage(); + } + + return this; + }; + + private connectViaPostMessage = () => { + window.addEventListener('message', (event) => { + if (event.source !== window) return; + if (!event.data || event.data.source !== 'frw-content-script') return; + + const { type, data } = event.data; + if (type === 'frw:message') { + this.emit('message', data); + } else if (type === 'frw:response') { + this.onResponse(data); + } + }); + }; + + send = (type: string, data: any) => { + if (this.port) { + this.port.postMessage({ type, data }); + } else { + // Fallback to postMessage + window.postMessage( + { + type: `frw:${type}`, + data, + source: 'frw-provider', + target: 'frw-content-script', + }, + '*' + ); + } + }; + + dispose = () => { + this._dispose(); + if (this.port) { + this.port.disconnect(); + this.port = null; + } + }; +} diff --git a/src/content-script/pageProvider/eth/utils/message/post-message.ts b/src/content-script/pageProvider/eth/utils/message/post-message.ts new file mode 100644 index 000000000..ea4290dd6 --- /dev/null +++ b/src/content-script/pageProvider/eth/utils/message/post-message.ts @@ -0,0 +1,60 @@ +import Message from './index'; + +export default class PostMessage extends Message { + private _targetOrigin: string; + private _uuid: string; + + constructor(uuid?: string) { + super(); + if (!uuid) { + throw new Error('UUID is required for PostMessage communication'); + } + this._uuid = uuid; + this._targetOrigin = '*'; // We'll restrict this in production + } + + connect = () => { + window.addEventListener('message', this._handleMessage); + return this; + }; + + listen = (listenCallback) => { + this.listenCallback = listenCallback; + window.addEventListener('message', this._handleMessage); + return this; + }; + + private _handleMessage = (event: MessageEvent) => { + // Basic security check - in production we'd want to be more restrictive + if (!event.data || !event.data.type || event.data.uuid !== this._uuid) { + return; + } + + const { type, data } = event.data; + + if (type === 'frw:message') { + this.emit('message', data); + } else if (type === 'frw:response') { + this.onResponse(data); + } else if (type === 'frw:request') { + this.onRequest(data); + } + }; + + send = (type: string, data: any) => { + window.postMessage( + { + type: `frw:${type}`, + data, + uuid: this._uuid, + source: 'frw-provider', + }, + this._targetOrigin + ); + }; + + dispose = () => { + this._dispose(); + window.removeEventListener('message', this._handleMessage); + }; +} From 6feecec206365d6f9fc2f9208ac4fbdb424e1d86 Mon Sep 17 00:00:00 2001 From: zzggo Date: Wed, 11 Jun 2025 16:56:54 +1000 Subject: [PATCH 3/5] fixed: connection conflict Closes #1040 --- src/background/controller/provider/rpcFlow.ts | 1 - src/content-script/pageProvider/eth/index.ts | 56 +++++++++++-------- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/src/background/controller/provider/rpcFlow.ts b/src/background/controller/provider/rpcFlow.ts index 2579e395f..1bf1165c5 100644 --- a/src/background/controller/provider/rpcFlow.ts +++ b/src/background/controller/provider/rpcFlow.ts @@ -60,7 +60,6 @@ const flowContext = flow mapMethod !== 'walletWatchAsset' && mapMethod !== 'walletConnect' && mapMethod !== 'walletDisconnect' && - mapMethod !== 'walletConnect' && !Reflect.getMetadata('SAFE', providerController, mapMethod) ) { if (!permissionService.hasPermission(origin) || !(await Wallet.isUnlocked())) { diff --git a/src/content-script/pageProvider/eth/index.ts b/src/content-script/pageProvider/eth/index.ts index dcc0abf24..daaedd35b 100644 --- a/src/content-script/pageProvider/eth/index.ts +++ b/src/content-script/pageProvider/eth/index.ts @@ -499,13 +499,27 @@ const requestIsDefaultWallet = async () => { // Check if user is connected to Flow Wallet const isConnectedToFlowWallet = (flowProvider: EthereumProvider) => { - return flowProvider.selectedAddress !== null && flowProvider.selectedAddress !== undefined; + const connected = + flowProvider.selectedAddress !== null && flowProvider.selectedAddress !== undefined; + log( + '[connection]', + 'Flow Wallet connected:', + connected, + 'selectedAddress:', + flowProvider.selectedAddress + ); + return connected; }; // Determine if a method should be routed to Flow Wallet const shouldRouteToFlowWallet = (method: string, connectedToFlowWallet: boolean) => { - // Read-only operations that should always be routed to Flow Wallet - const alwaysFlowWalletMethods = [ + // Connection methods that should only be handled by Flow Wallet if user is already connected + // Otherwise let other wallets handle their own connection flow + const connectionMethods = ['eth_accounts', 'eth_requestAccounts']; + + // Methods that should ONLY be routed to Flow Wallet when already connected + const connectedOnlyMethods = [ + // Read-only methods (only when connected to avoid breaking other wallet connections) 'eth_call', 'eth_getBalance', 'eth_getCode', @@ -515,12 +529,7 @@ const shouldRouteToFlowWallet = (method: string, connectedToFlowWallet: boolean) 'eth_getTransactionReceipt', 'eth_chainId', 'net_version', - ]; - - // Methods that should only be routed to Flow Wallet when connected - const conditionalFlowWalletMethods = [ - 'eth_accounts', - 'eth_requestAccounts', + // Transaction methods 'eth_sendTransaction', 'eth_estimateGas', 'eth_signTransaction', @@ -535,25 +544,28 @@ const shouldRouteToFlowWallet = (method: string, connectedToFlowWallet: boolean) 'wallet_watchAsset', ]; - // Always route certain methods to Flow Wallet - if (alwaysFlowWalletMethods.includes(method)) { - log('[routing]', `${method} -> Flow Wallet (always)`); - return true; + // Only route connection methods if already connected to Flow Wallet + // This prevents intercepting MetaMask connection attempts + if (connectionMethods.includes(method)) { + if (connectedToFlowWallet) { + log('[routing]', `${method} -> Flow Wallet (already connected)`); + return true; + } else { + log('[routing]', `${method} -> Default provider (not connected, let other wallets handle)`); + return false; + } } - // Route conditional methods only if connected to Flow Wallet - if (connectedToFlowWallet && conditionalFlowWalletMethods.includes(method)) { - log('[routing]', `${method} -> Flow Wallet (connected: ${connectedToFlowWallet})`); + // Route all other methods only if connected to Flow Wallet + if (connectedToFlowWallet && connectedOnlyMethods.includes(method)) { + log('[routing]', `${method} -> Flow Wallet (connected)`); return true; } - // For any other safe RPC methods, route to Flow Wallet if connected, otherwise to default provider + // For any other safe RPC methods, only route if connected if (SAFE_RPC_METHODS.includes(method)) { if (connectedToFlowWallet) { - log( - '[routing]', - `${method} -> Flow Wallet (safe method, connected: ${connectedToFlowWallet})` - ); + log('[routing]', `${method} -> Flow Wallet (safe method, connected)`); return true; } else { log('[routing]', `${method} -> Default provider (safe method, not connected)`); @@ -561,7 +573,7 @@ const shouldRouteToFlowWallet = (method: string, connectedToFlowWallet: boolean) } } - log('[routing]', `${method} -> Default provider (not in safe methods)`); + log('[routing]', `${method} -> Default provider (default case)`); return false; }; From 57f50a325ac02cc0eaa56674576e4c0dd743891f Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 11 Jun 2025 17:16:23 +1000 Subject: [PATCH 4/5] refactor: enhance message handling and security in PostMessage class - Updated the request ID pool size for better performance. - Improved comments for clarity on the proxy's role in multi-wallet interoperability. - Enhanced security checks in the PostMessage class to ensure safe communication with cross-origin iframes. - Adjusted the target origin handling for postMessage to default to the current window's origin. Closes #1040 --- src/content-script/pageProvider/eth/index.ts | 17 +++---- .../pageProvider/eth/utils/message/index.ts | 4 +- .../eth/utils/message/post-message.ts | 48 +++++++++++++++++-- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/content-script/pageProvider/eth/index.ts b/src/content-script/pageProvider/eth/index.ts index daaedd35b..b391d281c 100644 --- a/src/content-script/pageProvider/eth/index.ts +++ b/src/content-script/pageProvider/eth/index.ts @@ -612,16 +612,14 @@ const initProvider = async () => { ethereum: { get() { log('ethereum get'); - // Proxy that routes specific methods + // This proxy is the key to multi-wallet interoperability. + // It intercepts requests and routes them to our wallet or the default + // wallet based on the connection state and method type. const currentProvider = window.flowWalletRouter.currentProvider; const flowProvider = window.flowWalletRouter.flowProvider; - // If current provider is Flow Wallet, return it directly - if (currentProvider === flowProvider) { - return currentProvider; - } - - // Proxy that intercepts specific methods + // The proxy is always returned, wrapping the current default provider. + // This ensures we can intercept requests even if our wallet is not the active one. return new Proxy(currentProvider, { get(target, prop) { // Handle request method specially @@ -749,10 +747,9 @@ const providerInitPromise = flowProviderPromise.then((provider) => { try { const provider = await flowProviderPromise; const frwAsDefault = await requestIsDefaultWallet(); + // Set our wallet as the default if configured, but do not overwrite + // the window.ethereum proxy, which is essential for routing. window.flowWalletRouter?.setDefaultProvider(frwAsDefault); - if (frwAsDefault) { - window.ethereum = provider; - } } catch (e) { consoleError('An error occurred while checking if the Flow wallet is the default wallet', e); } diff --git a/src/content-script/pageProvider/eth/utils/message/index.ts b/src/content-script/pageProvider/eth/utils/message/index.ts index ffae8a4d8..a3a6c7f0a 100644 --- a/src/content-script/pageProvider/eth/utils/message/index.ts +++ b/src/content-script/pageProvider/eth/utils/message/index.ts @@ -9,11 +9,11 @@ import { ethErrors } from 'eth-rpc-errors'; abstract class Message extends EventEmitter { // avaiable id list // max concurrent request limit - private _requestIdPool = [...Array(1000).keys()]; + protected _requestIdPool = [...Array(100).keys()]; protected _EVENT_PRE = 'ETH_WALLET_'; protected listenCallback: any; - private _waitingMap = new Map< + protected _waitingMap = new Map< number, { data: any; diff --git a/src/content-script/pageProvider/eth/utils/message/post-message.ts b/src/content-script/pageProvider/eth/utils/message/post-message.ts index ea4290dd6..394013f24 100644 --- a/src/content-script/pageProvider/eth/utils/message/post-message.ts +++ b/src/content-script/pageProvider/eth/utils/message/post-message.ts @@ -1,16 +1,28 @@ import Message from './index'; +/** + * PostMessage class for communication between the page provider and content script. + * This class handles sending and receiving messages via `window.postMessage`, + * and includes security measures to support cross-origin iframes safely. + */ export default class PostMessage extends Message { + // The target origin for postMessage calls. Defaults to the window's current origin + // for security, but can be dynamically changed for responses. private _targetOrigin: string; private _uuid: string; + // Stores the origin of the last received message. This is crucial for sending + // responses back to the correct cross-origin iframe. + private _lastOrigin?: string; + constructor(uuid?: string) { super(); if (!uuid) { throw new Error('UUID is required for PostMessage communication'); } this._uuid = uuid; - this._targetOrigin = '*'; // We'll restrict this in production + // Default to the current window's origin for security. + this._targetOrigin = window.location.origin; } connect = () => { @@ -24,14 +36,31 @@ export default class PostMessage extends Message { return this; }; + /** + * Handles incoming messages from the window. + * It performs security checks to ensure the message is from a valid source + * (the window itself or one of its frames) and has the correct structure. + */ private _handleMessage = (event: MessageEvent) => { - // Basic security check - in production we'd want to be more restrictive + // Security check: only accept messages from the current window or its frames. + // This allows for communication with dApps running in iframes. + if (event.source !== window && event.source !== window.self) { + if (!Array.from(window.frames).includes(event.source as Window)) { + return; + } + } + + // Ensure the message has the required properties for our protocol. if (!event.data || !event.data.type || event.data.uuid !== this._uuid) { return; } const { type, data } = event.data; + // Store the origin of the sender. This is used to send the response + // back to the correct origin, which is vital for iframe support. + this._lastOrigin = event.origin; + if (type === 'frw:message') { this.emit('message', data); } else if (type === 'frw:response') { @@ -41,7 +70,20 @@ export default class PostMessage extends Message { } }; + /** + * Sends a message via window.postMessage. + * It dynamically sets the targetOrigin for responses to ensure they are sent + * securely to the source of the request, even across different origins. + */ send = (type: string, data: any) => { + let targetOrigin = this._targetOrigin; + + // For responses, we must use the origin of the original requestor. + // This is critical for securely communicating with cross-origin iframes. + if (type === 'response') { + targetOrigin = this._lastOrigin || this._targetOrigin; + } + window.postMessage( { type: `frw:${type}`, @@ -49,7 +91,7 @@ export default class PostMessage extends Message { uuid: this._uuid, source: 'frw-provider', }, - this._targetOrigin + targetOrigin ); }; From 399138c3f38cd3f5bacc37d10d6d8295f71ef602 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Thu, 12 Jun 2025 09:17:37 +1000 Subject: [PATCH 5/5] Add template to serve for EIP-6963 testing Closes #1040 --- e2e/d-app/eip6963-d-app.test.ts | 45 +++++ e2e/d-app/test-eip6963.html | 289 ++++++++++++++++++++++++++++++++ e2e/utils/loader.ts | 2 +- playwright.config.ts | 6 + 4 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 e2e/d-app/eip6963-d-app.test.ts create mode 100644 e2e/d-app/test-eip6963.html diff --git a/e2e/d-app/eip6963-d-app.test.ts b/e2e/d-app/eip6963-d-app.test.ts new file mode 100644 index 000000000..2bab36cd4 --- /dev/null +++ b/e2e/d-app/eip6963-d-app.test.ts @@ -0,0 +1,45 @@ +import { spawn } from 'child_process'; +import path from 'path'; + +import { importAccountBySeedPhrase, importSenderAccount } from '../utils/helper'; +import { test, expect } from '../utils/loader'; + +let serverProcess: ReturnType; + +test.beforeAll(async ({ page, extensionId }) => { + // Load the extension first + await page.goto(`chrome-extension://${extensionId}/index.html#/dashboard`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForURL(/.*unlock|.*welcome/); + + await importSenderAccount({ page, extensionId }); + + // Start the static server + serverProcess = spawn('npx', ['serve', '.', '-l', '3000'], { + cwd: import.meta.dirname, + stdio: 'inherit', + shell: true, + }); + + // Wait a bit for the server to start (or poll the port) + await new Promise((resolve) => setTimeout(resolve, 3000)); + + await page.goto('http://localhost:3000/test-eip6963.html'); +}); + +test.afterAll(async () => { + if (serverProcess) { + serverProcess.kill(); + } +}); + +/// Work in progress +test.describe.skip('Wallet Discovery', () => { + test('should discover wallets', async ({ page, extensionId }) => { + await page.waitForLoadState('domcontentloaded'); + + await page.goto('http://localhost:3000/test-eip6963.html'); + await page.pause(); + await expect(page.getByText('com.flowfoundation.wallet')).toBeVisible(); + }); +}); diff --git a/e2e/d-app/test-eip6963.html b/e2e/d-app/test-eip6963.html new file mode 100644 index 000000000..48f7627cb --- /dev/null +++ b/e2e/d-app/test-eip6963.html @@ -0,0 +1,289 @@ + + + + EIP-6963 Test - Flow Wallet + + + +

EIP-6963 Wallet Discovery Test

+ +
Waiting for wallets...
+ +
+

Debug Info:

+
Checking UUIDs...
+
Checking providers...
+
+ + + + + + +

Discovered Wallets:

+
+ +

Console Logs:

+
+ + + + diff --git a/e2e/utils/loader.ts b/e2e/utils/loader.ts index 5d39a7a38..6969c9198 100644 --- a/e2e/utils/loader.ts +++ b/e2e/utils/loader.ts @@ -27,7 +27,7 @@ export const test = base.extend<{ const baseFolderName = `/tmp/test-user-data-dir-${isTransaction ? 'transaction' : isRegistration ? 'registration' : 'other'}`; let dataDir = baseFolderName; - if (!isSetup) { + if (!isSetup && fs.existsSync(baseFolderName)) { // Copy the base folder to a new folder with the parallel index dataDir = `${baseFolderName}-${process.env.TEST_WORKER_INDEX}`; diff --git a/playwright.config.ts b/playwright.config.ts index c37f64022..072e0780e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -59,6 +59,12 @@ export default defineConfig({ // timeout: 3_600_000, /* Configure projects for major browsers */ projects: [ + // dApp + { + name: 'd-app-test', + testMatch: /.*d-app\.test\.ts/i, + fullyParallel: false, + }, // registration { name: 'registration-setup',