From bec235831cd901be1b0066b28e7f532d0476960a Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 14 Dec 2022 16:18:41 -0700 Subject: [PATCH] Add interactions involving filters & subscriptions We have a PR in the extension repo which touches code around RPC middleware for filter- and subscription-based RPC methods, and we want to be able to test these methods manually to ensure they are still working as designed. In order to accomplish this, I've added new cards which allow the user to: * Create and remove a filter. * Currently there is support for creating a generic log filter via `eth_newFilter` as well as a block filter via `eth_newBlockFilter`. Once a filter is created, `eth_getFilterChanges` is then polled every 2 seconds. Filters are removed via `eth_uninstallFilter`. * I tried to add a button which created a pending transaction filter via `eth_newPendingTransactionFilter`, but it appears that `eth-json-rpc-filters` has a [bug](https://github.com/MetaMask/eth-json-rpc-filters/issues/81) which prohibits this RPC method from working fully. * Start and stop a subscription. * As with filters, currently there is support for subscribing to new blocks via the `newHeads` parameter to `eth_subscribe` as well as new logs via the `logs` parameter. Subscriptions are stopped via `eth_unsubscribe`. * I also tried to add a button for subscribing to pending transactions, but [this doesn't seem to be supported outright by `eth-json-rpc-filters`](https://github.com/MetaMask/eth-json-rpc-filters/blob/5cbea3037b0655aa2c188d85b8ffe559a263dc0d/subscriptionManager.js#L50). --- src/index.css | 13 +++ src/index.html | 163 ++++++++++++++++++++++++++-- src/index.js | 280 ++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 436 insertions(+), 20 deletions(-) diff --git a/src/index.css b/src/index.css index 9ab163bb..e783a0ca 100644 --- a/src/index.css +++ b/src/index.css @@ -14,6 +14,19 @@ section { margin-bottom: 20px; } +.multiline-results { + height: 300px; + overflow-x: auto; + border: 1px solid #ced4da; + border-radius: 0.25rem; + padding: 0.375rem 0.75rem; +} + +.multiline-results__placeholder { + font-style: italic; + color: gray; +} + /* Logo & Header */ header { diff --git a/src/index.html b/src/index.html index b68d61c9..b3e50a4c 100644 --- a/src/index.html +++ b/src/index.html @@ -337,7 +337,7 @@

Approve - +
-
-
+
+

@@ -709,11 +711,9 @@

-
-
-
-
-
+

@@ -793,6 +793,153 @@

+
+
+
+
+
+

+ Log filters +

+ +
+ + +
+(Changes for this filter will appear here as new blocks
+are created, or as you interact with the chain.)
+                  
+
+
+
+
+
+
+
+

+ Block filters +

+ +
+ + +
+(Changes for this filter will appear here as new blocks
+are created.)
+                  
+
+
+
+
+
+
+
+
+
+
+
+

+ Subscriptions (new heads) +

+ +
+ + +
+(Information on new blocks will appear here as they are created.)
+                  
+
+
+
+
+
+
+
+

+ Subscriptions (logs) +

+ +
+ + +
+(Information on new blocks will appear here
+as they are created.)
+                  
+
+
+
+
+
+
diff --git a/src/index.js b/src/index.js index 9bb32b19..29573c5b 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,200 @@ import { failingContractBytecode, } from './constants.json'; +function findElementById(id) { + const element = document.getElementById(id); + + if (element === null) { + throw new Error(`Couldn't find element by id '${id}'`); + } else { + return element; + } +} + +function setupFilters(filterType, filterChangesBoxPlaceholder) { + if (!['log', 'block', 'pendingTransaction'].includes(filterType)) { + throw new Error( + "filterType must be either 'log', 'block', or 'pendingTransaction'", + ); + } + + const capitalizedFilterType = + filterType[0].toUpperCase() + filterType.slice(1); + let filterId; + let pollingTimer; + const createFilterButton = findElementById( + `create${capitalizedFilterType}FilterButton`, + ); + const removeFilterButton = findElementById( + `remove${capitalizedFilterType}FilterButton`, + ); + const filterChangesBox = findElementById(`${filterType}FilterChangesBox`); + + return { + createFilterButton, + removeFilterButton, + activateFilterButtons, + reset, + }; + + function activateFilterButtons(options) { + createFilterButton.addEventListener('click', () => { + createFilter(options).catch(console.error); + }); + + removeFilterButton.addEventListener('click', () => { + removeFilter().catch(console.error); + }); + } + + async function createFilter(options) { + let request; + + if (filterType === 'log') { + const accounts = options.getAccounts(); + request = { method: 'eth_newFilter', params: { address: accounts[0] } }; + } else if (filterType === 'block') { + request = { method: 'eth_newBlockFilter', params: [] }; + } else if (filterType === 'pendingTransaction') { + request = { method: 'eth_newPendingTransactionFilter', params: [] }; + } + + filterId = await ethereum.request(request); + + pollingTimer = setInterval(async () => { + const filterChanges = await ethereum.request({ + method: 'eth_getFilterChanges', + params: [filterId], + }); + + console.log(`${filterType} filter changes`, filterChanges); + + if (filterChanges.length > 0) { + const logMessage = `[${new Date().toISOString()}] ${JSON.stringify( + filterChanges, + null, + ' ', + )}`; + if (filterChangesBox.innerHTML === filterChangesBoxPlaceholder) { + filterChangesBox.innerHTML = logMessage; + } else { + filterChangesBox.innerHTML = `${logMessage}\n\n${filterChangesBox.innerHTML}`; + } + } + }, 2000); + + createFilterButton.disabled = true; + createFilterButton.innerHTML = 'Filter created, waiting for blocks...'; + removeFilterButton.disabled = false; + } + + async function removeFilter() { + await ethereum.request({ + method: 'eth_uninstallFilter', + params: [filterId], + }); + clearInterval(pollingTimer); + reset({ includingFilterChangesBox: false }); + } + + function reset({ includingFilterChangesBox = true } = {}) { + removeFilterButton.disabled = true; + createFilterButton.disabled = false; + createFilterButton.innerHTML = `Create ${filterType} filter`; + + if (includingFilterChangesBox) { + filterChangesBox.innerHTML = filterChangesBoxPlaceholder; + } + } +} + +function setupSubscriptions(subscriptionType) { + const capitalizedSubscriptionType = + subscriptionType[0].toUpperCase() + subscriptionType.slice(1); + let newSubscriptionId; + const startSubscriptionButton = findElementById( + `start${capitalizedSubscriptionType}SubscriptionButton`, + ); + const stopSubscriptionButton = findElementById( + `stop${capitalizedSubscriptionType}SubscriptionButton`, + ); + const resultsBox = findElementById( + `${subscriptionType}SubscriptionResultsBox`, + ); + const RESULTS_BOX_PLACEHOLDER = + '(Information on new blocks will appear here\nas they are created.)'; + + return { + startSubscriptionButton, + stopSubscriptionButton, + activateSubscriptionButtons, + reset, + }; + + function activateSubscriptionButtons() { + startSubscriptionButton.addEventListener('click', () => { + startSubscription().catch(console.error); + }); + + stopSubscriptionButton.addEventListener('click', () => { + stopSubscription().catch(console.error); + }); + } + + async function startSubscription() { + window.ethereum.addListener('message', onMessageReceived); + + newSubscriptionId = await ethereum.request({ + method: 'eth_subscribe', + params: [subscriptionType], + }); + + startSubscriptionButton.disabled = true; + startSubscriptionButton.innerHTML = + 'Subscription started, waiting for blocks...'; + stopSubscriptionButton.disabled = false; + } + + async function stopSubscription() { + await ethereum.request({ + method: 'eth_unsubscribe', + params: [newSubscriptionId], + }); + + window.ethereum.removeListener('message', onMessageReceived); + + reset({ includingResultsBox: false }); + } + + function reset({ includingResultsBox = true } = {}) { + startSubscriptionButton.disabled = false; + startSubscriptionButton.innerHTML = 'Start subscription'; + stopSubscriptionButton.disabled = true; + + if (includingResultsBox) { + resultsBox.innerHTML = RESULTS_BOX_PLACEHOLDER; + } + } + + function onMessageReceived(message) { + if ( + message.type === 'eth_subscription' && + message.data.subscription === newSubscriptionId + ) { + const logMessage = `[${new Date().toISOString()}] ${JSON.stringify( + message.data.result, + null, + ' ', + )}`; + if (resultsBox.innerHTML === RESULTS_BOX_PLACEHOLDER) { + resultsBox.innerHTML = logMessage; + } else { + resultsBox.innerHTML = `${logMessage}\n\n${resultsBox.innerHTML}`; + } + } + } +} + let ethersProvider; let hstFactory; let piggybankFactory; @@ -164,6 +358,40 @@ const submitFormButton = document.getElementById('submitForm'); const addEthereumChain = document.getElementById('addEthereumChain'); const switchEthereumChain = document.getElementById('switchEthereumChain'); +// Filters section +const { + createFilterButton: createLogFilterButton, + removeFilterButton: removeLogFilterButton, + activateFilterButtons: activateLogFilterButtons, + reset: resetLogFilters, +} = setupFilters( + 'log', + '(Changes for this filter will appear here as new blocks\nare created, or as you interact with the chain.)', +); +const { + createFilterButton: createBlockFilterButton, + removeFilterButton: removeBlockFilterButton, + activateFilterButtons: activateBlockFilterButtons, + reset: resetBlockFilters, +} = setupFilters( + 'block', + '(Changes for this filter will appear here as new blocks\nare created.)', +); + +// Subscriptions section +const { + startSubscriptionButton: startNewHeadsSubscriptionButton, + stopSubscriptionButton: stopNewHeadsSubscriptionButton, + activateSubscriptionButtons: activateNewHeadsSubscriptionButtons, + reset: resetNewHeadsSubscriptions, +} = setupSubscriptions('newHeads'); +const { + startSubscriptionButton: startLogsSubscriptionButton, + stopSubscriptionButton: stopLogsSubscriptionButton, + activateSubscriptionButtons: activateLogsSubscriptionButtons, + reset: resetLogsSubscriptions, +} = setupSubscriptions('logs'); + const initialize = async () => { try { // We must specify the network as 'any' for ethers to allow network changes @@ -264,6 +492,14 @@ const initialize = async () => { siweBadDomain, siweBadAccount, siweMalformed, + createLogFilterButton, + removeLogFilterButton, + createBlockFilterButton, + removeBlockFilterButton, + startNewHeadsSubscriptionButton, + stopNewHeadsSubscriptionButton, + startLogsSubscriptionButton, + stopLogsSubscriptionButton, ]; const isMetaMaskConnected = () => accounts && accounts.length > 0; @@ -319,6 +555,10 @@ const initialize = async () => { siweBadDomain.disabled = false; siweBadAccount.disabled = false; siweMalformed.disabled = false; + createLogFilterButton.disabled = false; + createBlockFilterButton.disabled = false; + startNewHeadsSubscriptionButton.disabled = false; + startLogsSubscriptionButton.disabled = false; } if (isMetaMaskInstalled()) { @@ -1378,6 +1618,20 @@ const initialize = async () => { } }; + /** + * Filters + */ + activateLogFilterButtons({ + getAccounts: () => accounts, + }); + activateBlockFilterButtons(); + + /** + * Subscriptions + */ + activateNewHeadsSubscriptionButtons(); + activateLogsSubscriptionButtons(); + function handleNewAccounts(newAccounts) { accounts = newAccounts; accountsDiv.innerHTML = accounts; @@ -1391,7 +1645,7 @@ const initialize = async () => { updateButtons(); } - function handleNewChain(chainId) { + async function handleNewChain(chainId) { chainIdDiv.innerHTML = chainId; if (chainId === '0x1') { @@ -1399,6 +1653,11 @@ const initialize = async () => { } else { warningDiv.classList.add('warning-invisible'); } + + await resetLogFilters(); + await resetBlockFilters(); + await resetNewHeadsSubscriptions(); + await resetLogsSubscriptions(); } function handleEIP1559Support(supported) { @@ -1422,7 +1681,7 @@ const initialize = async () => { const chainId = await ethereum.request({ method: 'eth_chainId', }); - handleNewChain(chainId); + await handleNewChain(chainId); const networkId = await ethereum.request({ method: 'net_version', @@ -1449,16 +1708,13 @@ const initialize = async () => { ethereum.autoRefreshOnNetworkChange = false; getNetworkAndChainId(); - ethereum.on('chainChanged', (chain) => { - handleNewChain(chain); - ethereum - .request({ - method: 'eth_getBlockByNumber', - params: ['latest', false], - }) - .then((block) => { - handleEIP1559Support(block.baseFeePerGas !== undefined); - }); + ethereum.on('chainChanged', async (chain) => { + await handleNewChain(chain); + const block = await ethereum.request({ + method: 'eth_getBlockByNumber', + params: ['latest', false], + }); + handleEIP1559Support(block.baseFeePerGas !== undefined); }); ethereum.on('chainChanged', handleNewNetwork); ethereum.on('accountsChanged', (newAccounts) => {