diff --git a/apps/remix-ide-e2e/src/tests/editor.test.ts b/apps/remix-ide-e2e/src/tests/editor.test.ts index 53d2a22ab5..993f43039f 100644 --- a/apps/remix-ide-e2e/src/tests/editor.test.ts +++ b/apps/remix-ide-e2e/src/tests/editor.test.ts @@ -92,13 +92,13 @@ module.exports = { .executeScript('remix.exeCurrent()') .scrollToLine(32) .waitForElementPresent('.highlightLine33', 60000) - .checkElementStyle('.highlightLine33', 'background-color', 'rgb(52, 152, 219)') + .checkElementStyle('.highlightLine33', 'background-color', 'rgb(44, 62, 80)') .scrollToLine(40) .waitForElementPresent('.highlightLine41', 60000) - .checkElementStyle('.highlightLine41', 'background-color', 'rgb(52, 152, 219)') + .checkElementStyle('.highlightLine41', 'background-color', 'rgb(44, 62, 80)') .scrollToLine(50) .waitForElementPresent('.highlightLine51', 60000) - .checkElementStyle('.highlightLine51', 'background-color', 'rgb(52, 152, 219)') + .checkElementStyle('.highlightLine51', 'background-color', 'rgb(44, 62, 80)') }, 'Should remove 1 highlight from source code #group1': '' + function (browser: NightwatchBrowser) { @@ -111,8 +111,8 @@ module.exports = { .waitForElementVisible('li[data-id="treeViewLitreeViewItemcontracts/3_Ballot.sol"]') .click('li[data-id="treeViewLitreeViewItemcontracts/3_Ballot.sol"]') .waitForElementNotPresent('.highlightLine33', 60000) - .checkElementStyle('.highlightLine41', 'background-color', 'rgb(52, 152, 219)') - .checkElementStyle('.highlightLine51', 'background-color', 'rgb(52, 152, 219)') + .checkElementStyle('.highlightLine41', 'background-color', 'rgb(44, 62, 80)') + .checkElementStyle('.highlightLine51', 'background-color', 'rgb(44, 62, 80)') }, 'Should remove all highlights from source code #group1': function (browser: NightwatchBrowser) { diff --git a/apps/remix-ide/src/app/editor/editor.js b/apps/remix-ide/src/app/editor/editor.js index a50b5fd8b6..875835d8ff 100644 --- a/apps/remix-ide/src/app/editor/editor.js +++ b/apps/remix-ide/src/app/editor/editor.js @@ -139,7 +139,11 @@ class Editor extends Plugin { this.on('sidePanel', 'pluginDisabled', (name) => { this.clearAllDecorationsFor(name) }) - + this.on('fileManager', 'fileClosed', (name) => { + if (name === this.currentFile) { + this.currentFile = null + } + }) this.on('theme', 'themeLoaded', (theme) => { this.currentThemeType = theme.quality this.renderComponent() diff --git a/apps/remix-ide/src/app/plugins/config.ts b/apps/remix-ide/src/app/plugins/config.ts index e44d403a12..3102d555df 100644 --- a/apps/remix-ide/src/app/plugins/config.ts +++ b/apps/remix-ide/src/app/plugins/config.ts @@ -18,7 +18,7 @@ export class ConfigPlugin extends Plugin { const queryParams = new QueryParams() const params = queryParams.get() const config = Registry.getInstance().get('config').api - const param = params[name] ? params[name] : config.get(name) + let param = params[name] || config.get(name) || config.get('settings/' + name) if (param === 'true') return true if (param === 'false') return false return param diff --git a/libs/remix-core-plugin/src/lib/compiler-fetch-and-compile.ts b/libs/remix-core-plugin/src/lib/compiler-fetch-and-compile.ts index c6a8a6156b..bf8b87bf55 100644 --- a/libs/remix-core-plugin/src/lib/compiler-fetch-and-compile.ts +++ b/libs/remix-core-plugin/src/lib/compiler-fetch-and-compile.ts @@ -3,6 +3,8 @@ import { Plugin } from '@remixproject/engine' import { compile } from '@remix-project/remix-solidity' import { util } from '@remix-project/remix-lib' import { toChecksumAddress } from 'ethereumjs-util' +import { fetchContractFromEtherscan } from './helpers/fetch-etherscan' +import { fetchContractFromSourcify } from './helpers/fetch-sourcify' const profile = { name: 'fetchAndCompile', @@ -68,48 +70,31 @@ export class FetchAndCompile extends Plugin { let data try { - data = await this.call('sourcify', 'fetchByNetwork', contractAddress, network.id) + data = await fetchContractFromSourcify(this, network, contractAddress, targetPath) } catch (e) { - setTimeout(_ => this.emit('notFound', contractAddress), 0) // plugin framework returns a time out error although it actually didn't find the source... - this.unresolvedAddresses.push(contractAddress) - return localCompilation() - } - if (!data || !data.metadata) { - setTimeout(_ => this.emit('notFound', contractAddress), 0) - this.unresolvedAddresses.push(contractAddress) - return localCompilation() + this.call('notification', 'toast', e.message) + console.log(e) // and fallback to getting the compilation result from etherscan } - // set the solidity contract code using metadata - await this.call('fileManager', 'setFile', `${targetPath}/${network.id}/${contractAddress}/metadata.json`, JSON.stringify(data.metadata, null, '\t')) - const compilationTargets = {} - for (let file in data.metadata.sources) { - const urls = data.metadata.sources[file].urls - for (const url of urls) { - if (url.includes('ipfs')) { - const stdUrl = `ipfs://${url.split('/')[2]}` - const source = await this.call('contentImport', 'resolve', stdUrl) - if (await this.call('contentImport', 'isExternalUrl', file)) { - // nothing to do, the compiler callback will handle those - } else { - file = file.replace('browser/', '') // should be fixed in the remix IDE end. - const path = `${targetPath}/${network.id}/${contractAddress}/${file}` - await this.call('fileManager', 'setFile', path, source.content) - compilationTargets[path] = { content: source.content } - } - break - } + if (!data) { + this.call('notification', 'toast', `contract ${contractAddress} not found in Sourcify, checking in Etherscan..`) + try { + data = await fetchContractFromEtherscan(this, network, contractAddress, targetPath) + } catch (e) { + this.call('notification', 'toast', e.message) + setTimeout(_ => this.emit('notFound', contractAddress), 0) // plugin framework returns a time out error although it actually didn't find the source... + this.unresolvedAddresses.push(contractAddress) + return localCompilation() } } - // compile - const settings = { - version: data.metadata.compiler.version, - language: data.metadata.language, - evmVersion: data.metadata.settings.evmVersion, - optimize: data.metadata.settings.optimizer.enabled, - runs: data.metadata.settings.optimizer.runs + if (!data) { + setTimeout(_ => this.emit('notFound', contractAddress), 0) + this.unresolvedAddresses.push(contractAddress) + return localCompilation() } + const { settings, compilationTargets } = data + try { setTimeout(_ => this.emit('compiling', settings), 0) const compData = await compile( diff --git a/libs/remix-core-plugin/src/lib/helpers/fetch-etherscan.ts b/libs/remix-core-plugin/src/lib/helpers/fetch-etherscan.ts new file mode 100644 index 0000000000..647547ba47 --- /dev/null +++ b/libs/remix-core-plugin/src/lib/helpers/fetch-etherscan.ts @@ -0,0 +1,59 @@ +export const fetchContractFromEtherscan = async (plugin, network, contractAddress, targetPath) => { + let data + const compilationTargets = {} + + const etherscanKey = await plugin.call('config', 'getAppParameter', 'etherscan-access-token') + if (etherscanKey) { + const endpoint = network.id == 1 ? 'api.etherscan.io' : 'api-' + network.name + '.etherscan.io' + data = await fetch('https://' + endpoint + '/api?module=contract&action=getsourcecode&address=' + contractAddress + '&apikey=' + etherscanKey) + data = await data.json() + // etherscan api doc https://docs.etherscan.io/api-endpoints/contracts + if (data.message === 'OK' && data.status === "1") { + if (data.result.length) { + if (data.result[0].SourceCode === '') throw new Error('contract not verified') + if (data.result[0].SourceCode.startsWith('{')) { + data.result[0].SourceCode = JSON.parse(data.result[0].SourceCode.replace(/(?:\r\n|\r|\n)/g, '').replace(/^{{/,'{').replace(/}}$/,'}')) + } + } + } else throw new Error('unable to retrieve contract data ' + data.message) + } else throw new Error('unable to try fetching the source code from etherscan: etherscan access token not found. please go to the Remix settings page and provide an access token.') + + if (!data || !data.result) { + return null + } + + if (typeof data.result[0].SourceCode === 'string') { + const fileName = `${targetPath}/${network.id}/${contractAddress}/${data.result[0].ContractName}.sol` + await plugin.call('fileManager', 'setFile', fileName , data.result[0].SourceCode) + compilationTargets[fileName] = { content: data.result[0].SourceCode } + } else if (data.result[0].SourceCode && typeof data.result[0].SourceCode == 'object') { + const sources = data.result[0].SourceCode.sources + for (let [file, source] of Object.entries(sources)) { // eslint-disable-line + file = file.replace('browser/', '') // should be fixed in the remix IDE end. + file = file.replace(/^\//g, '') // remove first slash. + if (await plugin.call('contentImport', 'isExternalUrl', file)) { + // nothing to do, the compiler callback will handle those + } else { + const path = `${targetPath}/${network.id}/${contractAddress}/${file}` + const content = (source as any).content + await plugin.call('fileManager', 'setFile', path, content) + compilationTargets[path] = { content } + } + } + } + let runs = 0 + try { + runs = parseInt(data.result[0].Runs) + } catch (e) {} + const settings = { + version: data.result[0].CompilerVersion.replace(/^v/, ''), + language: 'Solidity', + evmVersion: data.result[0].EVMVersion.toLowerCase(), + optimize: data.result[0].OptimizationUsed === '1', + runs + } + return { + settings, + compilationTargets + } +} \ No newline at end of file diff --git a/libs/remix-core-plugin/src/lib/helpers/fetch-sourcify.ts b/libs/remix-core-plugin/src/lib/helpers/fetch-sourcify.ts new file mode 100644 index 0000000000..698c062b44 --- /dev/null +++ b/libs/remix-core-plugin/src/lib/helpers/fetch-sourcify.ts @@ -0,0 +1,46 @@ +export const fetchContractFromSourcify = async (plugin, network, contractAddress, targetPath) => { + let data + const compilationTargets = {} + + try { + data = await plugin.call('sourcify', 'fetchByNetwork', contractAddress, network.id) + } catch (e) { + console.log(e) + } + + if (!data || !data.metadata) { + return null + } + + // set the solidity contract code using metadata + await plugin.call('fileManager', 'setFile', `${targetPath}/${network.id}/${contractAddress}/metadata.json`, JSON.stringify(data.metadata, null, '\t')) + for (let file in data.metadata.sources) { + const urls = data.metadata.sources[file].urls + for (const url of urls) { + if (url.includes('ipfs')) { + const stdUrl = `ipfs://${url.split('/')[2]}` + const source = await plugin.call('contentImport', 'resolve', stdUrl) + file = file.replace('browser/', '') // should be fixed in the remix IDE end. + if (await plugin.call('contentImport', 'isExternalUrl', file)) { + // nothing to do, the compiler callback will handle those + } else { + const path = `${targetPath}/${network.id}/${contractAddress}/${file}` + await plugin.call('fileManager', 'setFile', path, source.content) + compilationTargets[path] = { content: source.content } + } + break + } + } + } + const settings = { + version: data.metadata.compiler.version, + language: data.metadata.language, + evmVersion: data.metadata.settings.evmVersion, + optimize: data.metadata.settings.optimizer.enabled, + runs: data.metadata.settings.optimizer.runs + } + return { + settings, + compilationTargets + } +} diff --git a/libs/remix-solidity/src/compiler/compiler-input.ts b/libs/remix-solidity/src/compiler/compiler-input.ts index 8b00c647ee..6e426ddaab 100644 --- a/libs/remix-solidity/src/compiler/compiler-input.ts +++ b/libs/remix-solidity/src/compiler/compiler-input.ts @@ -19,9 +19,13 @@ export default (sources: Source, opts: CompilerInputOptions): string => { } } } - } + } if (opts.evmVersion) { - o.settings.evmVersion = opts.evmVersion + if (opts.evmVersion.toLowerCase() == 'default') { + opts.evmVersion = null + } else { + o.settings.evmVersion = opts.evmVersion + } } if (opts.language) { o.language = opts.language diff --git a/libs/remix-ui/debugger-ui/src/lib/debugger-ui.tsx b/libs/remix-ui/debugger-ui/src/lib/debugger-ui.tsx index de26b84af9..0524f22170 100644 --- a/libs/remix-ui/debugger-ui/src/lib/debugger-ui.tsx +++ b/libs/remix-ui/debugger-ui/src/lib/debugger-ui.tsx @@ -91,7 +91,7 @@ export const DebuggerUI = (props: DebuggerUIProps) => { if (!lineColumnPos) { await debuggerModule.discardHighlight() setState(prevState => { - return { ...prevState, sourceLocationStatus: 'Source location not available.' } + return { ...prevState, sourceLocationStatus: 'Source location not available, neither in Sourcify nor in Etherscan. Please make sure the Etherscan api key is provided in the settings.' } }) return } @@ -334,6 +334,15 @@ export const DebuggerUI = (props: DebuggerUIProps) => { { state.debugging && state.sourceLocationStatus &&
{state.sourceLocationStatus}
} + { !state.debugging && +
+ + + When Debugging with a transaction hash, + if the contract is verified, Remix will try to fetch the source code from Sourcify or Etherscan. Put in your Etherscan API key in the Remix settings. + For supported networks, please see: https://sourcify.dev & https://etherscan.io/contractsVerified + +
} { state.debugging && } { state.debugging && } diff --git a/libs/remix-ui/editor/src/lib/remix-ui-editor.css b/libs/remix-ui/editor/src/lib/remix-ui-editor.css index ad1c9a7d5b..d3a7dde20d 100644 --- a/libs/remix-ui/editor/src/lib/remix-ui-editor.css +++ b/libs/remix-ui/editor/src/lib/remix-ui-editor.css @@ -18,4 +18,9 @@ .contextview { opacity: 1; position: absolute; +} + +.inline-class { + background: var(--primary) !important; + color: var(--text) !important; } \ No newline at end of file diff --git a/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx b/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx index e0e1d75fce..769abf9528 100644 --- a/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx +++ b/libs/remix-ui/editor/src/lib/remix-ui-editor.tsx @@ -263,7 +263,7 @@ export const EditorUI = (props: EditorUIProps) => { range: new monacoRef.current.Range(decoration.position.start.line + 1, decoration.position.start.column + 1, decoration.position.end.line + 1, decoration.position.end.column + 1), options: { isWholeLine, - inlineClassName: `alert-info border-0 highlightLine${decoration.position.start.line + 1}` + inlineClassName: `inline-class border-0 selectionHighlight highlightLine${decoration.position.start.line + 1}` } } } diff --git a/libs/remix-ui/settings/src/lib/constants.ts b/libs/remix-ui/settings/src/lib/constants.ts index 0da1f23863..9219ffff63 100644 --- a/libs/remix-ui/settings/src/lib/constants.ts +++ b/libs/remix-ui/settings/src/lib/constants.ts @@ -2,13 +2,34 @@ export const generateContractMetadataText = 'Generate contract metadata. Generat export const textSecondary = 'text-secondary' export const textDark = 'text-dark' export const warnText = 'Be sure the endpoint is opened before enabling it. \nThis mode allows a user to provide a passphrase in the Remix interface without having to unlock the account. Although this is very convenient, you should completely trust the backend you are connected to (Geth, Parity, ...). Remix never persists any passphrase'.split('\n').map(s => s.trim()).join(' ') + export const gitAccessTokenTitle = 'Github Access Token' export const gitAccessTokenText = 'Manage the access token used to publish to Gist and retrieve Github contents.' export const gitAccessTokenText2 = 'Go to github token page (link below) to create a new token and save it in Remix. Make sure this token has only \'create gist\' permission.' export const gitAccessTokenLink = 'https://github.com/settings/tokens' +export const etherscanTokenTitle = 'EtherScan Access Token' +export const etherscanTokenLink = 'https://etherscan.io/myapikey' +export const etherscanAccessTokenText = 'Manage the api key used to interact with Etherscan.' +export const etherscanAccessTokenText2 = 'Go to Etherscan api key page (link below) to create a new api key and save it in Remix.' export const ethereunVMText = 'Always use Javascript VM at load' export const wordWrapText = 'Word wrap in editor' export const enablePersonalModeText = ' Enable Personal Mode for web3 provider. Transaction sent over Web3 will use the web3.personal API.\n' export const matomoAnalytics = 'Enable Matomo Analytics. We do not collect personally identifiable information (PII). The info is used to improve the site’s UX & UI. See more about ' export const swarmSettingsTitle = 'Swarm Settings' export const swarmSettingsText = 'Swarm Settings' +export const labels = { + 'gist': { + 'link': gitAccessTokenLink, + 'title': gitAccessTokenTitle, + 'message1': gitAccessTokenText, + 'message2': gitAccessTokenText2, + 'key': 'gist-access-token' + }, + 'etherscan': { + 'link': etherscanTokenLink, + 'title': etherscanTokenTitle, + 'message1':etherscanAccessTokenText, + 'message2':etherscanAccessTokenText2, + 'key': 'etherscan-access-token' + } +} diff --git a/libs/remix-ui/settings/src/lib/remix-ui-settings.tsx b/libs/remix-ui/settings/src/lib/remix-ui-settings.tsx index 714c18bdfa..d52e1ad86a 100644 --- a/libs/remix-ui/settings/src/lib/remix-ui-settings.tsx +++ b/libs/remix-ui/settings/src/lib/remix-ui-settings.tsx @@ -1,7 +1,7 @@ import React, { useState, useReducer, useEffect, useCallback } from 'react' // eslint-disable-line import { CopyToClipboard } from '@remix-ui/clipboard' // eslint-disable-line -import { enablePersonalModeText, ethereunVMText, generateContractMetadataText, gitAccessTokenLink, gitAccessTokenText, gitAccessTokenText2, gitAccessTokenTitle, matomoAnalytics, swarmSettingsTitle, textDark, textSecondary, warnText, wordWrapText } from './constants' +import { enablePersonalModeText, ethereunVMText, labels, generateContractMetadataText, matomoAnalytics, textDark, textSecondary, warnText, wordWrapText, swarmSettingsTitle } from './constants' import './remix-ui-settings.css' import { ethereumVM, generateContractMetadat, personal, textWrapEventAction, useMatomoAnalytics, saveTokenToast, removeTokenToast, saveSwarmSettingsToast } from './settingsAction' @@ -21,19 +21,28 @@ export interface RemixUiSettingsProps { export const RemixUiSettings = (props: RemixUiSettingsProps) => { const [, dispatch] = useReducer(settingReducer, initialState) const [state, dispatchToast] = useReducer(toastReducer, toastInitialState) - const [tokenValue, setTokenValue] = useState('') + const [tokenValue, setTokenValue] = useState({}) const [themeName, ] = useState('') const [privateBeeAddress, setPrivateBeeAddress] = useState('') const [postageStampId, setPostageStampId] = useState('') useEffect(() => { - const token = props.config.get('settings/gist-access-token') + const token = props.config.get('settings/' + labels['gist'].key) if (token === undefined) { props.config.set('settings/generate-contract-metadata', true) dispatch({ type: 'contractMetadata', payload: { name: 'contractMetadata', isChecked: true, textClass: textDark } }) } if (token) { - setTokenValue(token) + setTokenValue(prevState => { + return { ...prevState, gist: token } + }) + } + + const etherscantoken = props.config.get('settings/' + labels['etherscan'].key) + if (etherscantoken) { + setTokenValue(prevState => { + return { ...prevState, etherscan: etherscantoken } + }) } const configPrivateBeeAddress = props.config.get('settings/swarm-private-bee-address') if (configPrivateBeeAddress) { @@ -125,42 +134,48 @@ export const RemixUiSettings = (props: RemixUiSettingsProps) => { ) } - const saveToken = () => { - saveTokenToast(props.config, dispatchToast, tokenValue) + // api key settings + const saveToken = (type: string) => { + saveTokenToast(props.config, dispatchToast, tokenValue[type], labels[type].key) } - const removeToken = () => { - setTokenValue('') - removeTokenToast(props.config, dispatchToast) + const removeToken = (type: string) => { + setTokenValue(prevState => { + return { ...prevState, [type]: ''} + }) + removeTokenToast(props.config, dispatchToast, labels[type].key) } const handleSaveTokenState = useCallback( - (event) => { - setTokenValue(event.target.value) + (event, type) => { + setTokenValue(prevState => { + return { ...prevState, [type]: event.target.value} + }) }, [tokenValue] ) - const gistToken = () => ( + const token = (type: string) => (
-
{ gitAccessTokenTitle }
-

{ gitAccessTokenText }

-

{ gitAccessTokenText2 }

-

{ gitAccessTokenLink }

+
{ labels[type].title }
+

{ labels[type].message1 }

+

{ labels[type].message2 }

+

{ labels[type].link }

- + handleSaveTokenState(e, type)} value={ tokenValue[type] } />
- - saveToken()} value="Save" type="button" disabled={tokenValue === ''}> - + + saveToken(type)} value="Save" type="button" disabled={tokenValue === ''}> +
) + // swarm settings const handleSavePrivateBeeAddress = useCallback( (event) => { setPrivateBeeAddress(event.target.value) @@ -206,8 +221,9 @@ export const RemixUiSettings = (props: RemixUiSettingsProps) => { return (
{state.message ? : null} - {generalConfig()} - {gistToken()} + {generalConfig()} + {token('gist')} + {token('etherscan')} {swarmSettings()}
diff --git a/libs/remix-ui/settings/src/lib/settingsAction.ts b/libs/remix-ui/settings/src/lib/settingsAction.ts index 48f9572040..8519efb6f5 100644 --- a/libs/remix-ui/settings/src/lib/settingsAction.ts +++ b/libs/remix-ui/settings/src/lib/settingsAction.ts @@ -41,13 +41,13 @@ export const useMatomoAnalytics = (config, checked, dispatch) => { } } -export const saveTokenToast = (config, dispatch, tokenValue) => { - config.set('settings/gist-access-token', tokenValue) +export const saveTokenToast = (config, dispatch, tokenValue, key) => { + config.set('settings/' + key, tokenValue) dispatch({ type: 'save', payload: { message: 'Access token has been saved' } }) } -export const removeTokenToast = (config, dispatch) => { - config.set('settings/gist-access-token', '') +export const removeTokenToast = (config, dispatch, key) => { + config.set('settings/' + key, '') dispatch({ type: 'removed', payload: { message: 'Access token removed' } }) } diff --git a/libs/remix-ui/solidity-unit-testing/src/lib/css/style.css b/libs/remix-ui/solidity-unit-testing/src/lib/css/style.css index f6d05f2150..fa67ecb389 100644 --- a/libs/remix-ui/solidity-unit-testing/src/lib/css/style.css +++ b/libs/remix-ui/solidity-unit-testing/src/lib/css/style.css @@ -9,10 +9,8 @@ margin: 5%; max-height: 300px; overflow-y: auto; - } .container { - margin: 2%; padding-bottom: 5%; max-height: 300px; overflow-y: auto;