diff --git a/build.js b/build.js index 48a63eb..fdc9a24 100644 --- a/build.js +++ b/build.js @@ -62,6 +62,17 @@ async function build() { minify: true, }); + // bundle inpage script (injected into page context, separate file) + await esbuild.build({ + entryPoints: [path.join(SRC, "content", "inpage.js")], + bundle: true, + format: "iife", + outfile: path.join(distDir, "src", "content", "inpage.js"), + platform: "browser", + target: ["chrome110", "firefox110"], + minify: true, + }); + // copy popup HTML fs.copyFileSync( path.join(SRC, "popup", "index.html"), diff --git a/manifest/chrome.json b/manifest/chrome.json index c295573..9921717 100644 --- a/manifest/chrome.json +++ b/manifest/chrome.json @@ -16,5 +16,11 @@ "js": ["src/content/index.js"], "run_at": "document_start" } + ], + "web_accessible_resources": [ + { + "resources": ["src/content/inpage.js"], + "matches": [""] + } ] } diff --git a/manifest/firefox.json b/manifest/firefox.json index 453dbf6..758ae5d 100644 --- a/manifest/firefox.json +++ b/manifest/firefox.json @@ -17,6 +17,7 @@ "run_at": "document_start" } ], + "web_accessible_resources": ["src/content/inpage.js"], "browser_specific_settings": { "gecko": { "id": "autistmask@sneak.berlin" diff --git a/src/background/index.js b/src/background/index.js index a8a1020..fbf9591 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -1,2 +1,218 @@ // AutistMask background service worker -// TODO: wallet management, message routing, provider implementation +// Handles EIP-1193 RPC requests from content scripts and proxies +// non-sensitive calls to the configured Ethereum JSON-RPC endpoint. + +const CHAIN_ID = "0x1"; +const DEFAULT_RPC = "https://eth.llamarpc.com"; + +const storageApi = + typeof browser !== "undefined" + ? browser.storage.local + : chrome.storage.local; +const runtime = + typeof browser !== "undefined" ? browser.runtime : chrome.runtime; + +// Connected sites: { origin: [address, ...] } +const connectedSites = {}; + +async function getState() { + const result = await storageApi.get("autistmask"); + return result.autistmask || { wallets: [], rpcUrl: DEFAULT_RPC }; +} + +async function getAccounts() { + const state = await getState(); + const accounts = []; + for (const wallet of state.wallets) { + for (const addr of wallet.addresses) { + accounts.push(addr.address); + } + } + return accounts; +} + +async function getRpcUrl() { + const state = await getState(); + return state.rpcUrl || DEFAULT_RPC; +} + +// Proxy an RPC call to the Ethereum node +async function proxyRpc(method, params) { + const rpcUrl = await getRpcUrl(); + const resp = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method, + params, + }), + }); + const json = await resp.json(); + if (json.error) { + throw new Error(json.error.message || "RPC error"); + } + return json.result; +} + +// Methods that are safe to proxy directly to the RPC node +const PROXY_METHODS = [ + "eth_blockNumber", + "eth_call", + "eth_chainId", + "eth_estimateGas", + "eth_gasPrice", + "eth_getBalance", + "eth_getBlockByHash", + "eth_getBlockByNumber", + "eth_getCode", + "eth_getLogs", + "eth_getStorageAt", + "eth_getTransactionByHash", + "eth_getTransactionCount", + "eth_getTransactionReceipt", + "eth_maxPriorityFeePerGas", + "eth_sendRawTransaction", + "net_version", + "web3_clientVersion", + "eth_feeHistory", + "eth_getBlockTransactionCountByHash", + "eth_getBlockTransactionCountByNumber", +]; + +async function handleRpc(method, params, origin) { + // Methods that need wallet involvement + if (method === "eth_requestAccounts" || method === "eth_accounts") { + const accounts = await getAccounts(); + if (accounts.length === 0) { + return { error: { message: "No accounts available" } }; + } + // Auto-connect for now (approval flow is a future TODO) + connectedSites[origin] = accounts; + return { result: accounts }; + } + + if (method === "eth_chainId") { + return { result: CHAIN_ID }; + } + + if (method === "net_version") { + return { result: "1" }; + } + + if (method === "wallet_switchEthereumChain") { + const chainId = params?.[0]?.chainId; + if (chainId === CHAIN_ID) { + return { result: null }; + } + return { + error: { + code: 4902, + message: "AutistMask only supports Ethereum mainnet.", + }, + }; + } + + if (method === "wallet_addEthereumChain") { + return { + error: { + code: 4902, + message: "AutistMask only supports Ethereum mainnet.", + }, + }; + } + + if (method === "wallet_requestPermissions") { + const accounts = await getAccounts(); + if (accounts.length === 0) { + return { error: { message: "No accounts available" } }; + } + connectedSites[origin] = accounts; + return { + result: [ + { + parentCapability: "eth_accounts", + caveats: [ + { + type: "restrictReturnedAccounts", + value: accounts, + }, + ], + }, + ], + }; + } + + if (method === "wallet_getPermissions") { + const accounts = connectedSites[origin] || []; + if (accounts.length === 0) { + return { result: [] }; + } + return { + result: [ + { + parentCapability: "eth_accounts", + caveats: [ + { + type: "restrictReturnedAccounts", + value: accounts, + }, + ], + }, + ], + }; + } + + if (method === "personal_sign" || method === "eth_sign") { + // TODO: implement signature approval flow + return { + error: { message: "Signing not yet implemented in AutistMask." }, + }; + } + + if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") { + // TODO: implement typed data signing + return { + error: { + message: + "Typed data signing not yet implemented in AutistMask.", + }, + }; + } + + if (method === "eth_sendTransaction") { + // TODO: implement transaction signing approval flow + // For now, return an error directing the user to use the popup + return { + error: { + message: + "Transaction signing via dApps not yet implemented. Use the AutistMask popup to send transactions.", + }, + }; + } + + // Proxy safe read-only methods to the RPC node + if (PROXY_METHODS.includes(method)) { + try { + const result = await proxyRpc(method, params); + return { result }; + } catch (e) { + return { error: { message: e.message } }; + } + } + + return { error: { message: "Unsupported method: " + method } }; +} + +// Listen for messages from content scripts +runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg.type !== "AUTISTMASK_RPC") return; + + handleRpc(msg.method, msg.params, msg.origin).then((response) => { + sendResponse(response); + }); + + // Return true to indicate async response + return true; +}); diff --git a/src/content/index.js b/src/content/index.js index f55e04c..cc708eb 100644 --- a/src/content/index.js +++ b/src/content/index.js @@ -1,2 +1,51 @@ -// AutistMask content script -// TODO: inject window.ethereum provider into page context +// AutistMask content script — bridges between inpage (window.ethereum) +// and the background service worker via extension messaging. + +// Inject the inpage script into the page's JS context +const script = document.createElement("script"); +script.src = (typeof browser !== "undefined" ? browser : chrome).runtime.getURL( + "src/content/inpage.js", +); +script.onload = function () { + this.remove(); +}; +(document.head || document.documentElement).appendChild(script); + +// Relay requests from the page to the background script +window.addEventListener("message", (event) => { + if (event.source !== window) return; + if (event.data?.type !== "AUTISTMASK_REQUEST") return; + const { id, method, params } = event.data; + + const runtime = + typeof browser !== "undefined" ? browser.runtime : chrome.runtime; + + runtime.sendMessage( + { type: "AUTISTMASK_RPC", id, method, params, origin: location.origin }, + (response) => { + if (response) { + window.postMessage( + { type: "AUTISTMASK_RESPONSE", id, ...response }, + "*", + ); + } + }, + ); +}); + +// Listen for events pushed from the background (e.g. accountsChanged) +const runtime = + typeof browser !== "undefined" ? browser.runtime : chrome.runtime; + +runtime.onMessage.addListener((msg) => { + if (msg.type === "AUTISTMASK_EVENT") { + window.postMessage( + { + type: "AUTISTMASK_EVENT", + eventName: msg.eventName, + data: msg.data, + }, + "*", + ); + } +}); diff --git a/src/content/inpage.js b/src/content/inpage.js new file mode 100644 index 0000000..4182234 --- /dev/null +++ b/src/content/inpage.js @@ -0,0 +1,128 @@ +// AutistMask inpage script — injected into the page's JS context. +// Creates window.ethereum (EIP-1193 provider). + +(function () { + if (typeof window.ethereum !== "undefined") return; + + const CHAIN_ID = "0x1"; // Ethereum mainnet + + const listeners = {}; + let nextId = 1; + const pending = {}; + + // Listen for responses from the content script + window.addEventListener("message", (event) => { + if (event.source !== window) return; + if (event.data?.type !== "AUTISTMASK_RESPONSE") return; + const { id, result, error } = event.data; + const p = pending[id]; + if (!p) return; + delete pending[id]; + if (error) { + p.reject(new Error(error.message || "Request failed")); + } else { + p.resolve(result); + } + }); + + // Listen for events pushed from the extension + window.addEventListener("message", (event) => { + if (event.source !== window) return; + if (event.data?.type !== "AUTISTMASK_EVENT") return; + const { eventName, data } = event.data; + emit(eventName, data); + }); + + function emit(eventName, data) { + const cbs = listeners[eventName]; + if (!cbs) return; + for (const cb of cbs) { + try { + cb(data); + } catch (e) { + // ignore listener errors + } + } + } + + function request(args) { + return new Promise((resolve, reject) => { + const id = nextId++; + pending[id] = { resolve, reject }; + window.postMessage( + { type: "AUTISTMASK_REQUEST", id, ...args }, + "*", + ); + }); + } + + const provider = { + isAutistMask: true, + isMetaMask: true, // compatibility — many dApps check this + chainId: CHAIN_ID, + networkVersion: "1", + selectedAddress: null, + + request(args) { + return request({ method: args.method, params: args.params || [] }); + }, + + // Legacy methods (still used by some dApps) + enable() { + return this.request({ method: "eth_requestAccounts" }); + }, + + send(methodOrPayload, paramsOrCallback) { + // Handle both send(method, params) and send({method, params}) + if (typeof methodOrPayload === "string") { + return this.request({ + method: methodOrPayload, + params: paramsOrCallback || [], + }); + } + return this.request({ + method: methodOrPayload.method, + params: methodOrPayload.params || [], + }); + }, + + sendAsync(payload, callback) { + this.request({ + method: payload.method, + params: payload.params || [], + }) + .then((result) => + callback(null, { id: payload.id, jsonrpc: "2.0", result }), + ) + .catch((err) => callback(err)); + }, + + on(event, cb) { + if (!listeners[event]) listeners[event] = []; + listeners[event].push(cb); + return this; + }, + + removeListener(event, cb) { + if (!listeners[event]) return this; + listeners[event] = listeners[event].filter((c) => c !== cb); + return this; + }, + + removeAllListeners(event) { + if (event) { + delete listeners[event]; + } else { + for (const key of Object.keys(listeners)) { + delete listeners[key]; + } + } + return this; + }, + }; + + window.ethereum = provider; + + // Announce via EIP-6963 (multi-wallet discovery) + window.dispatchEvent(new Event("ethereum#initialized")); +})();