diff --git a/apps/contract-interaction/.babelrc b/apps/contract-interaction/.babelrc new file mode 100644 index 00000000000..6df3e5be524 --- /dev/null +++ b/apps/contract-interaction/.babelrc @@ -0,0 +1,5 @@ +{ + "presets": ["@babel/preset-env", ["@babel/preset-react", { "runtime": "automatic" }]], + "plugins": ["@babel/plugin-proposal-class-properties", "@babel/plugin-transform-runtime", "@babel/plugin-proposal-nullish-coalescing-operator"], + "ignore": ["**/node_modules/**"] +} diff --git a/apps/contract-interaction/.eslintrc b/apps/contract-interaction/.eslintrc new file mode 100644 index 00000000000..be97c53fbbb --- /dev/null +++ b/apps/contract-interaction/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "../../.eslintrc.json" +} diff --git a/apps/contract-interaction/README.md b/apps/contract-interaction/README.md new file mode 100644 index 00000000000..6c5c689c725 --- /dev/null +++ b/apps/contract-interaction/README.md @@ -0,0 +1 @@ +# Contract Interaction Plugin diff --git a/apps/contract-interaction/package.json b/apps/contract-interaction/package.json new file mode 100644 index 00000000000..ffaccec60f5 --- /dev/null +++ b/apps/contract-interaction/package.json @@ -0,0 +1,6 @@ +{ + "name": "contract-interaction", + "version": "1.0.0", + "main": "index.js", + "license": "MIT" +} diff --git a/apps/contract-interaction/project.json b/apps/contract-interaction/project.json new file mode 100644 index 00000000000..b19dde9456a --- /dev/null +++ b/apps/contract-interaction/project.json @@ -0,0 +1,88 @@ +{ + "name": "contract-interaction", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/contract-interaction/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nrwl/webpack:webpack", + "outputs": [ + "{options.outputPath}" + ], + "defaultConfiguration": "development", + "dependsOn": [ + "install" + ], + "options": { + "compiler": "babel", + "outputPath": "dist/apps/contract-interaction", + "index": "apps/contract-interaction/src/index.html", + "baseHref": "./", + "main": "apps/contract-interaction/src/main.tsx", + "polyfills": "apps/contract-interaction/src/polyfills.ts", + "tsConfig": "apps/contract-interaction/tsconfig.app.json", + "assets": [ + "apps/contract-interaction/src/favicon.ico", + "apps/contract-interaction/src/assets", + "apps/contract-interaction/src/profile.json" + ], + "styles": [ + "apps/contract-interaction/src/styles.css" + ], + "scripts": [], + "webpackConfig": "apps/contract-interaction/webpack.config.js" + }, + "configurations": { + "development": {}, + "production": { + "fileReplacements": [ + { + "replace": "apps/contract-interaction/src/environments/environment.ts", + "with": "apps/contract-interaction/src/environments/environment.prod.ts" + } + ] + } + } + }, + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "lintFilePatterns": [ + "apps/contract-interaction/**/*.ts" + ], + "eslintConfig": "apps/contract-interaction/.eslintrc" + } + }, + "install": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "cd apps/contract-interaction && yarn" + ], + "parallel": false + } + }, + "serve": { + "executor": "@nrwl/webpack:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "contract-interaction:build", + "hmr": true, + "baseHref": "/" + }, + "configurations": { + "development": { + "buildTarget": "contract-interaction:build:development", + "port": 5003 + }, + "production": { + "buildTarget": "contract-interaction:build:production" + } + } + } + }, + "tags": [] +} diff --git a/apps/contract-interaction/src/app/App.css b/apps/contract-interaction/src/app/App.css new file mode 100644 index 00000000000..fba8495b8fc --- /dev/null +++ b/apps/contract-interaction/src/app/App.css @@ -0,0 +1,668 @@ +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; +} + +.fa-arrow-up-right-from-square::before { + content: "\f08e"; +} + +.fa-xmark::before { + content: "\f00d"; +} + +.udapp_cardContainer { + padding: 0 24px 16px; + margin: 0; + background: none; +} + +.udapp_runTabView { + display: flex; + flex-direction: column; +} + +.udapp_runTabView::-webkit-scrollbar { + display: none; +} + +.udapp_settings { + padding: 0 24px 16px; +} + +.udapp_crow { + display: block; + margin-top: 8px; +} + +.udapp_col1 { + width: 30%; + float: left; + align-self: center; +} + +.udapp_settingsLabel { + font-size: 11px; + margin-bottom: 4px; + text-transform: uppercase; +} + +.udapp_settingsCompiledBy { + margin-bottom: 4px; +} + +.udapp_syncFramework { + margin-bottom: 4px; +} + +.udapp_environment { + display: flex; + align-items: center; + position: relative; + width: 100%; +} + +.udapp_environment a { + margin-left: 7px; +} + +.udapp_account { + display: flex; + align-items: center; +} + +.udapp_account i { + margin-left: 12px; +} + +.udapp_col2 { + border-radius: 3px; +} + +.udapp_col2_1 { + width: 164px; + min-width: 164px; +} + +.udapp_select { + font-weight: normal; + width: 100%; + overflow: hidden; +} + +.udapp_instanceContainer { + display: flex; + flex-direction: column; + margin-bottom: 2%; + border: none; + text-align: center; + padding: 0 14px 16px; +} + +.udapp_deployedContracts { + font-size: 1rem; +} + +.udapp_pendingTxsContainer { + display: flex; + flex-direction: column; + margin-top: 2%; + border: none; + text-align: center; +} + +.udapp_container { + padding: 0 24px 16px; +} + +.udapp_contractNames { + width: 100%; + border: 1px solid +} + +.udapp_evmVersion { + cursor: default; +} + +.udapp_subcontainer { + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 2px; +} + +.udapp_subcontainer i { + width: 16px; + display: flex; + justify-content: center; + margin-left: 1px; +} + +.udapp_button button { + flex: none; +} + +.udapp_button { + display: flex; + align-items: center; + margin-top: 13px; +} + +.udapp_atAddress { + margin: 0; + min-width: 100px; + width: 100px; + height: 100%; + word-break: inherit; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: 0; +} + +.udapp_atAddress:focus { + outline: none; + box-shadow: none; +} + +.udapp_atAddressSect { + margin-top: 8px; + height: 32px; +} + +.udapp_atAddressSect input { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; +} + +.udapp_ataddressinput { + padding: .25rem; +} + +.udapp_recorderSection:hover { + cursor: pointer; +} + +.udapp_recorderSectionLabel:hover { + cursor: pointer; +} + +.udapp_recorderSectionLabel { + cursor: pointer; + font-size: 1rem; +} + +.udapp_input { + font-size: 10px !important; +} + +.udapp_pendingTxsText { + font-style: italic; + display: flex; + justify-content: space-evenly; + align-items: center; + flex-wrap: wrap; +} + +.udapp_item { + margin-right: 1em; + display: flex; + align-items: center; +} + +.udapp_pendingContainer { + display: flex; + align-items: baseline; +} + +.udapp_pending { + height: 25px; + text-align: center; + padding-left: 10px; + border-radius: 3px; + margin-left: 5px; +} + +.udapp_disableMouseEvents { + pointer-events: none; +} + +.udapp_icon { + cursor: pointer; + font-size: 12px; + cursor: pointer; + margin-left: 5px; +} + +.udapp_icon:hover { + font-size: 12px; +} + +.udapp_errorIcon { + color: var(--warning); + margin-left: 15px; +} + +.udapp_failDesc { + color: var(--warning); + padding-left: 10px; + display: inline; +} + +.udapp_network { + pointer-events: none; +} + +.udapp_networkItem { + margin-right: 5px; +} + +.udapp_transactionActions { + display: flex; + justify-content: space-evenly; + width: 145px; +} + +.udapp_orLabel { + text-align: center; + text-transform: uppercase; +} + +.udapp_infoDeployAction { + margin-left: 1px; + font-size: 13px; + color: var(--info); +} + +.udapp_gasValueContainer { + flex-direction: row; + display: flex; +} + +.udapp_gasNval { + font-size: 0.8rem; +} + +.udapp_gasNvalUnit { + width: 41%; + margin-left: 10px; + font-size: 0.8rem; +} + +.udapp_deployDropdown { + text-align: center; + text-transform: uppercase; +} + +.udapp_checkboxAlign { + padding-top: 2px; +} + +.udapp_instanceTitleContainer { + display: flex; + align-items: center; +} + +.udapp_calldataInput { + height: 32px; +} + +.udapp_title { + display: flex; + justify-content: space-between; + font-size: 11px; + width: 100%; + overflow: hidden; + word-break: break-word; + line-height: initial; + overflow: visible; + padding: 0 0 8px; + margin: 0; + background: none; + border: none; +} + +.udapp_title button { + background: none; + border: none; +} + +.udapp_titleLine { + display: flex; + align-items: baseline; +} + +.udapp_titleText { + word-break: break-word; + width: 100%; + border: none; + overflow: hidden; +} + +.udapp_spanTitleText { + line-height: 12px; + padding: 0; + font-size: 11px; + width: 100%; + border: none; + background: none; + text-transform: uppercase; + overflow: hidden; +} + +.udapp_inputGroupText { + width: 100%; +} + +.udapp_title .udapp_copy { + color: var(--primary); +} + +.udapp_titleExpander { + align-self: center; +} + +.udapp_nameNbuts { + display: contents; + flex-wrap: nowrap; + width: 100%; +} + +.udapp_instance { + display: block; + flex-direction: column; + background: none; + border-radius: 2px; +} + +.udapp_instance.udapp_hidesub { + border-bottom: 1px solid; +} + +.udapp_instance.udapp_hidesub .udapp_title { + display: flex; +} + +.udapp_instance.udapp_hidesub .udapp_udappClose { + display: flex; +} + +.udapp_instance.udapp_hidesub>* { + display: none; +} + +.udapp_methCaret { + min-width: 12px; + width: 12px; + margin-left: 4px; + cursor: pointer; + font-size: 16px; + line-height: 0.6; + vertical-align: middle; + padding: 0; +} + +.udapp_cActionsWrapper { + border-top-left-radius: 0; + border-bottom-left-radius: 0.25rem; + border-top-right-radius: 0; + border-bottom-right-radius: 0.25rem; + padding: 8px 10px 7px; +} + +.udapp_group:after { + content: ""; + display: table; + clear: both; +} + +.udapp_buttonsContainer { + margin-top: 2%; + display: flex; + overflow: hidden; +} + +.udapp_instanceButton { + height: 32px; + border-radius: 3px; + white-space: nowrap; + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; +} + +.udapp_closeIcon { + font-size: 12px; + cursor: pointer; + margin-left: 5px; +} + +.udapp_udappClose { + display: flex; + justify-content: flex-end; +} + +.udapp_contractProperty { + width: 100%; +} + +.udapp_contractProperty.udapp_hasArgs input { + padding: .36em; + border-radius: 5px; +} + +.udapp_contractProperty .udapp_contractActionsContainerSingle input { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.udapp_contractProperty button { + min-width: 80px; + width: 100px; + margin: 0; + word-break: inherit; +} + +.udapp_contractProperty.udapp_constant button { + min-width: 100px; + width: 100px; + margin: 0; + word-break: inherit; + outline: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.udapp_contractProperty>.udapp_value { + box-sizing: border-box; + float: left; + align-self: center; + margin-left: 4px; +} + +.udapp_contractActionsContainer { + width: 100%; + margin-bottom: 8px; +} + +.udapp_contractActionsContainerSingle { + display: flex; + width: 100%; +} + +.udapp_contractActionsContainerSingle i { + line-height: 2; +} + +.udapp_contractActionsContainerMulti { + display: none; + width: 100%; +} + +.udapp_contractActionsContainerMultiInner { + width: 100%; + border-radius: 3px; + margin-bottom: 8px; +} + +.udapp_multiHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + text-align: left; + font-size: 10px; + font-weight: bold; +} + +.udapp_contractActionsContainerMultiInner .udapp_multiTitle { + padding-left: 10px; +} + +.udapp_contractProperty .udapp_multiTitle { + padding: 0; + line-height: 16px; + display: inline-block; + font-size: 12px; + font-weight: bold; + cursor: default; +} + +.udapp_contractProperty .udapp_contractActionsContainerMultiInner .udapp_multiArg label { + text-align: right; +} + +.udapp_multiHeader .udapp_methCaret { + float: right; + margin-right: 0; +} + +.udapp_contractProperty.udapp_constant .udapp_multiTitle { + display: inline-block; + width: 90%; + /* font-size: 10px; */ + height: 25px; + padding-left: 20px; + font-weight: bold; + line-height: 25px; + cursor: default; +} + +.udapp_multiArg { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: 4px; +} + +.udapp_multiArg input { + padding: 5px; +} + +.udapp_multiArg label { + width: auto; + padding: 0; + margin: 0 4px 0 0; + font-size: 10px; + line-height: 12px; + text-align: right; + word-break: initial; +} + +.udapp_multiArg button { + max-width: 100px; + border-radius: 3px; + border-width: 1px; + width: inherit; +} + +.udapp_multiHeader button { + display: inline-block; + width: 94%; +} + +.udapp_hasArgs .udapp_multiArg input { + border-left: 1px solid #dddddd; + width: 67%; +} + +.udapp_hasArgs input { + display: block; + height: 32px; + border: 1px solid #dddddd; + padding: .36em; + border-left: none; + padding: 8px 8px 8px 10px; + font-size: 10px !important; +} + +.udapp_hasArgs button { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 11px; +} + +.udapp_hasArgs .udapp_contractActionsContainerMulti button { + border-radius: 3px; +} + +.udapp_contractActionsContainerMultiInner .udapp_multiArg i { + padding-right: 10px; +} + +.udapp_hideWarningsContainer { + display: flex; + align-items: center; + margin-left: 2% +} + +#confirmsetting { + z-index: 1; +} + +.udapp_wrapword { + white-space: pre-wrap; + /* Since CSS 2.1 */ + white-space: -moz-pre-wrap; + /* Mozilla, since 1999 */ + white-space: -pre-wrap; + /* Opera 4-6 */ + white-space: -o-pre-wrap; + /* Opera 7 */ + word-wrap: break-word; + /* Internet Explorer 5.5+ */ +} + +.deploy-items { + padding: 0.25rem 0.25rem; + border-radius: .25rem; +} + +.deploy-items a { + border-radius: .25rem; + text-transform: none; + text-decoration: none; + font-weight: normal; + font-size: .8rem; + padding: 0.25rem 0.25rem; + width: auto; +} + +.udapp_selectExEnvOptions { + width: 100%; +} + +.remixui_runtabBalancelabel { + font-size: 0.688rem; + line-height: 0.75rem; + color: var(--runtab); +} \ No newline at end of file diff --git a/apps/contract-interaction/src/app/AppContext.tsx b/apps/contract-interaction/src/app/AppContext.tsx new file mode 100644 index 00000000000..d796bee7e28 --- /dev/null +++ b/apps/contract-interaction/src/app/AppContext.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import type { ThemeType, Chain, ContractInteractionSettings } from './types' +import { ContractInteractionPluginClient } from './ContractInteractionPluginClient' +import { appInitialState, State } from './reducers/state' + +// Define the type for the context +type AppContextType = { + // themeType: ThemeType + // setThemeType: (themeType: ThemeType) => void + plugin: ContractInteractionPluginClient + appState: State, + settings: ContractInteractionSettings + setSettings: React.Dispatch> + chains: Chain[] +} + +// Provide a default value with the appropriate types +const defaultContextValue: AppContextType = { + // themeType: 'dark', + // setThemeType: (themeType: ThemeType) => { }, + plugin: {} as ContractInteractionPluginClient, + settings: { chains: {} }, + appState: appInitialState, + setSettings: () => { }, + chains: [], +} + +// Create the context with the type +export const AppContext = React.createContext(defaultContextValue) diff --git a/apps/contract-interaction/src/app/ContractInteractionPluginClient.ts b/apps/contract-interaction/src/app/ContractInteractionPluginClient.ts new file mode 100644 index 00000000000..afe6f39b68f --- /dev/null +++ b/apps/contract-interaction/src/app/ContractInteractionPluginClient.ts @@ -0,0 +1,24 @@ +import { PluginClient } from '@remixproject/plugin' +import { createClient } from '@remixproject/plugin-webview' +import EventManager from 'events' +import { loadPluginAction } from './actions' + +export class ContractInteractionPluginClient extends PluginClient { + public internalEvents: EventManager + + constructor() { + super() + this.internalEvents = new EventManager() + createClient(this) + } + + onActivation(): void { + this.internalEvents.emit('interaction_activated') + } + + loadPlugin = async () => { + await loadPluginAction(); + } +} + +export default new ContractInteractionPluginClient() diff --git a/apps/contract-interaction/src/app/InteractionFormContext.tsx b/apps/contract-interaction/src/app/InteractionFormContext.tsx new file mode 100644 index 00000000000..a540f2dcd91 --- /dev/null +++ b/apps/contract-interaction/src/app/InteractionFormContext.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import type { Chain } from './types' +import { ContractDropdownSelection } from './components/ContractDropdown' + +// Define the type for the context +type InteractionFormContextType = { + selectedChain: Chain | undefined + setSelectedChain: React.Dispatch> + contractAddress: string + setContractAddress: React.Dispatch> + contractAddressError: string + setContractAddressError: React.Dispatch> + selectedContract: ContractDropdownSelection | undefined + setSelectedContract: React.Dispatch> + proxyAddress: string + setProxyAddress: React.Dispatch> + proxyAddressError: string + setProxyAddressError: React.Dispatch> + abiEncodedConstructorArgs: string + setAbiEncodedConstructorArgs: React.Dispatch> + abiEncodingError: string + setAbiEncodingError: React.Dispatch> +} + +// Provide a default value with the appropriate types +const defaultContextValue: InteractionFormContextType = { + selectedChain: undefined, + setSelectedChain: (selectedChain: Chain) => { }, + contractAddress: '', + setContractAddress: (contractAddress: string) => { }, + contractAddressError: '', + setContractAddressError: (contractAddressError: string) => { }, + selectedContract: undefined, + setSelectedContract: (selectedContract: ContractDropdownSelection) => { }, + proxyAddress: '', + setProxyAddress: (proxyAddress: string) => { }, + proxyAddressError: '', + setProxyAddressError: (contractAddressError: string) => { }, + abiEncodedConstructorArgs: '', + setAbiEncodedConstructorArgs: (contractAddproxyAddressress: string) => { }, + abiEncodingError: '', + setAbiEncodingError: (contractAddressError: string) => { }, +} + +// Create the context with the type +export const InteractionFormContext = React.createContext(defaultContextValue) diff --git a/apps/contract-interaction/src/app/abiProviders/AbstractAbiProvider.ts b/apps/contract-interaction/src/app/abiProviders/AbstractAbiProvider.ts new file mode 100644 index 00000000000..38de75d3727 --- /dev/null +++ b/apps/contract-interaction/src/app/abiProviders/AbstractAbiProvider.ts @@ -0,0 +1,33 @@ +import type { ContractABI } from '../types' + +export abstract class AbstractAbiProvider { + constructor(public apiUrl: string, public explorerUrl: string) { } + + abstract lookupABI(contractAddress: string): Promise + abstract lookupBytecode(contractAddress: string): Promise + + /** + * Fetch data from provider. + * + * @param url - URL to fetch the data from. + * @returns An JSON response from the provider of type `T`. + */ + static async fetch(url: string): Promise { + try { + const response = await fetch(url, { + method: 'GET', + headers: new Headers({ 'Content-Type': 'application/json' }), + }); + + if (!response.ok) { + console.error(`ERROR fetching data from URL: ${url}`); + throw new Error(`ERROR fetching data from URL: ${url}`); + } + + return await response.json() + } catch (error) { + console.error('An error occurred while fetching data:', error); + throw new Error('An error occurred while fetching data:' + error); + } + } +} diff --git a/apps/contract-interaction/src/app/abiProviders/BlockscoutAbiProvider.ts b/apps/contract-interaction/src/app/abiProviders/BlockscoutAbiProvider.ts new file mode 100644 index 00000000000..fd347a8acb4 --- /dev/null +++ b/apps/contract-interaction/src/app/abiProviders/BlockscoutAbiProvider.ts @@ -0,0 +1,68 @@ +import { ABICategoryBlockScout } from '../types' +import { AbstractAbiProvider } from './AbstractAbiProvider'; +import { EtherscanAbiProvider } from './EtherscanAbiProvider' + +interface BlockscoutSmartContract { + deployed_bytecode: string +} + +export class BlockscoutAbiProvider extends EtherscanAbiProvider { + LOOKUP_STORE_DIR = 'blockscout-interacted' + + constructor(apiUrl: string) { + // apiUrl and explorerUrl are the same for Blockscout + super(apiUrl, apiUrl, undefined) + } + + /** + * Get the blockexplorer specific URL for fetching the smart contract ABI. + * + * @param contractAddress - The contract address. + * @param ABICategory - The sub type of the ABI (one of the values: 'read' | 'write' | 'readProxy' | 'writeProxy'). + * @returns The url to fetch the ABI data. + */ + getAbiURL(contractAddress: string, ABICategory: ABICategoryBlockScout): string { + const url = new URL(this.explorerUrl + `/api/v2/smart-contracts/${contractAddress}/${ABICategory}`) + return url.href + } + + /** + * Get the blockexplorer specific URL for fetching the raw bytecode of a smart contract. + * + * @param contractAddress - The contract address. + * @returns The url to fetch the raw bytecode data. + */ + getBytecodeURL(contractAddress: string): string { + const url = new URL(this.explorerUrl + `/api/v2/smart-contracts/${contractAddress}`) + return url.href + } + + async lookupBytecode(contractAddress: string): Promise { + + // TODO try-catch + let response = await AbstractAbiProvider.fetch(this.getBytecodeURL(contractAddress)) + return response.deployed_bytecode + } + + // getContractCodeUrl(address: string): string {i + // const url = new URL(this.explorerUrl + `/address/${address}`) + // url.searchParams.append('tab', 'contract') + // return url.href + // } + + // processReceivedFiles(source: unknown, contractAddress: string, chainId: string): { sourceFiles: SourceFile[]; targetFilePath?: string } { + // const blockscoutSource = source as BlockscoutSource + + // const result: SourceFile[] = [] + // const filePrefix = `/${this.LOOKUP_STORE_DIR}/${chainId}/${contractAddress}` + + // const targetFilePath = `${filePrefix}/${blockscoutSource.FileName}` + // result.push({ content: blockscoutSource.SourceCode, path: targetFilePath }) + + // for (const additional of blockscoutSource.AdditionalSources ?? []) { + // result.push({ content: additional.SourceCode, path: `${filePrefix}/${additional.Filename}` }) + // } + + // return { sourceFiles: result, targetFilePath } + // } +} diff --git a/apps/contract-interaction/src/app/abiProviders/EtherscanAbiProvider.ts b/apps/contract-interaction/src/app/abiProviders/EtherscanAbiProvider.ts new file mode 100644 index 00000000000..90b1c305e12 --- /dev/null +++ b/apps/contract-interaction/src/app/abiProviders/EtherscanAbiProvider.ts @@ -0,0 +1,338 @@ +import { AbstractAbiProvider } from './AbstractAbiProvider' +import { ContractABI, ABICategoryBlockScout } from '../types' +import { FuncABI } from '@remix-project/core-plugin' + +export class EtherscanAbiProvider extends AbstractAbiProvider { + LOOKUP_STORE_DIR = 'etherscan-verified' + + constructor(apiUrl: string, explorerUrl: string, protected apiKey?: string) { + super(apiUrl, explorerUrl) + } + + + /** + * Get the blockexplorer specific URL for fetching the smart contract ABI. + * + * @param contractAddress - The contract address. + * @param ABICategory - The sub type of the ABI (one of the values: 'read' | 'write' | 'readProxy' | 'writeProxy'). + * @returns The url to fetch the ABI data. + */ + getAbiURL(contractAddress: string, ABICategory: ABICategoryBlockScout): string { + const url = new URL(this.explorerUrl + `/api/v2/smart-contracts/${contractAddress}/${ABICategory}`) + return url.href + } + + async lookupABI(contractAddress: string): Promise { + + // TODO try-catch + const parsedReadABI = await AbstractAbiProvider.fetch(this.getAbiURL(contractAddress, ABICategoryBlockScout.Read)) + const parsedWriteABI = await AbstractAbiProvider.fetch(this.getAbiURL(contractAddress, ABICategoryBlockScout.Write)) + const parsedProxyReadABI = await AbstractAbiProvider.fetch(this.getAbiURL(contractAddress, ABICategoryBlockScout.ProxyRead)) + const parsedProxyWriteABI = await AbstractAbiProvider.fetch(this.getAbiURL(contractAddress, ABICategoryBlockScout.ProxyWrite)) + + return { + Read: parsedReadABI, + Write: parsedWriteABI, + ProxyRead: parsedProxyReadABI, + ProxyWrite: parsedProxyWriteABI + } + } + + /** + * Get the blockexplorer specific URL for fetching the raw bytecode of a smart contract. + * + * @param contractAddress - The contract address. + * @returns The url to fetch the raw bytecode data. + */ + getBytecodeURL(contractAddress: string): string { + // TODO: get correct URL + const url = new URL(this.explorerUrl + `/api?module=contract&action=getsourcecode&address=${contractAddress}`) + return url.href + } + + async lookupBytecode(contractAddress: string): Promise { + // TODO try-catch + return await AbstractAbiProvider.fetch(this.getBytecodeURL(contractAddress)) + } +} + +// interface EtherscanRpcResponse { +// status: '0' | '1' +// message: string +// result: string +// } + +// interface EtherscanCheckStatusResponse { +// status: '0' | '1' +// message: string +// result: 'Pending in queue' | 'Pass - Verified' | 'Fail - Unable to verify' | 'Already Verified' | 'Unknown UID' +// } + +// interface EtherscanSource { +// SourceCode: string +// ABI: string +// ContractName: string +// CompilerVersion: string +// OptimizationUsed: string +// Runs: string +// ConstructorArguments: string +// EVMVersion: string +// Library: string +// LicenseType: string +// Proxy: string +// Implementation: string +// SwarmSource: string +// } + +// interface EtherscanGetSourceCodeResponse { +// status: '0' | '1' +// message: string +// result: EtherscanSource[] +// } + + +// async verify(submittedContract: SubmittedContract, compilerAbstract: CompilerAbstract): Promise { +// // TODO: Handle version Vyper contracts. This relies on Solidity metadata. +// const metadata = JSON.parse(compilerAbstract.data.contracts[submittedContract.filePath][submittedContract.contractName].metadata) +// const formData = new FormData() +// formData.append('chainId', submittedContract.chainId) +// formData.append('codeformat', 'solidity-standard-json-input') +// formData.append('sourceCode', compilerAbstract.input.toString()) +// formData.append('contractaddress', submittedContract.address) +// formData.append('contractname', submittedContract.filePath + ':' + submittedContract.contractName) +// formData.append('compilerversion', `v${metadata.compiler.version}`) +// formData.append('constructorArguements', submittedContract.abiEncodedConstructorArgs?.replace('0x', '') ?? '') + +// const url = new URL(this.apiUrl + '/api') +// url.searchParams.append('module', 'contract') +// url.searchParams.append('action', 'verifysourcecode') +// if (this.apiKey) { +// url.searchParams.append('apikey', this.apiKey) +// } + +// const response = await fetch(url.href, { +// method: 'POST', +// body: formData, +// }) + +// if (!response.ok) { +// const responseText = await response.text() +// console.error('Error on Etherscan API verification at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + responseText) +// throw new Error(responseText) +// } + +// const verificationResponse: EtherscanRpcResponse = await response.json() + +// if (verificationResponse.result.includes('already verified')) { +// return { status: 'already verified', receiptId: null, lookupUrl: this.getContractCodeUrl(submittedContract.address) } +// } + +// if (verificationResponse.status !== '1' || verificationResponse.message !== 'OK') { +// console.error('Error on Etherscan API verification at ' + this.apiUrl + '\nStatus: ' + verificationResponse.status + '\nMessage: ' + verificationResponse.message + '\nResult: ' + verificationResponse.result) +// throw new Error(verificationResponse.result) +// } + +// const lookupUrl = this.getContractCodeUrl(submittedContract.address) +// return { status: 'pending', receiptId: verificationResponse.result, lookupUrl } +// } + +// async verifyProxy(submittedContract: SubmittedContract): Promise { +// if (!submittedContract.proxyAddress) { +// throw new Error('SubmittedContract does not have a proxyAddress') +// } + +// const formData = new FormData() +// formData.append('address', submittedContract.proxyAddress) +// formData.append('expectedimplementation', submittedContract.address) + +// const url = new URL(this.apiUrl + '/api') +// url.searchParams.append('module', 'contract') +// url.searchParams.append('action', 'verifyproxycontract') +// if (this.apiKey) { +// url.searchParams.append('apikey', this.apiKey) +// } + +// const response = await fetch(url.href, { +// method: 'POST', +// body: formData, +// }) + +// if (!response.ok) { +// const responseText = await response.text() +// console.error('Error on Etherscan API proxy verification at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + responseText) +// throw new Error(responseText) +// } + +// const verificationResponse: EtherscanRpcResponse = await response.json() + +// if (verificationResponse.status !== '1' || verificationResponse.message !== 'OK') { +// console.error('Error on Etherscan API proxy verification at ' + this.apiUrl + '\nStatus: ' + verificationResponse.status + '\nMessage: ' + verificationResponse.message + '\nResult: ' + verificationResponse.result) +// throw new Error(verificationResponse.result) +// } + +// return { status: 'pending', receiptId: verificationResponse.result } +// } + +// async checkVerificationStatus(receiptId: string): Promise { +// const url = new URL(this.apiUrl + '/api') +// url.searchParams.append('module', 'contract') +// url.searchParams.append('action', 'checkverifystatus') +// url.searchParams.append('guid', receiptId) +// if (this.apiKey) { +// url.searchParams.append('apikey', this.apiKey) +// } + +// const response = await fetch(url.href, { method: 'GET' }) + +// if (!response.ok) { +// const responseText = await response.text() +// console.error('Error on Etherscan API check verification status at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + responseText) +// throw new Error(responseText) +// } + +// const checkStatusResponse: EtherscanCheckStatusResponse = await response.json() + +// if (checkStatusResponse.result.startsWith('Fail - Unable to verify')) { +// return { status: 'failed', receiptId, message: checkStatusResponse.result } +// } +// if (checkStatusResponse.result === 'Pending in queue') { +// return { status: 'pending', receiptId } +// } +// if (checkStatusResponse.result === 'Pass - Verified') { +// return { status: 'verified', receiptId } +// } +// if (checkStatusResponse.result === 'Already Verified') { +// return { status: 'already verified', receiptId } +// } +// if (checkStatusResponse.result === 'Unknown UID') { +// console.error('Error on Etherscan API check verification status at ' + this.apiUrl + '\nStatus: ' + checkStatusResponse.status + '\nMessage: ' + checkStatusResponse.message + '\nResult: ' + checkStatusResponse.result) +// return { status: 'failed', receiptId, message: checkStatusResponse.result } +// } + +// if (checkStatusResponse.status !== '1' || !checkStatusResponse.message.startsWith('OK')) { +// console.error('Error on Etherscan API check verification status at ' + this.apiUrl + '\nStatus: ' + checkStatusResponse.status + '\nMessage: ' + checkStatusResponse.message + '\nResult: ' + checkStatusResponse.result) +// throw new Error(checkStatusResponse.result) +// } + +// return { status: 'unknown', receiptId } +// } + +// async checkProxyVerificationStatus(receiptId: string): Promise { +// const url = new URL(this.apiUrl + '/api') +// url.searchParams.append('module', 'contract') +// url.searchParams.append('action', 'checkproxyverification') +// url.searchParams.append('guid', receiptId) +// if (this.apiKey) { +// url.searchParams.append('apikey', this.apiKey) +// } + +// const response = await fetch(url.href, { method: 'GET' }) + +// if (!response.ok) { +// const responseText = await response.text() +// console.error('Error on Etherscan API check verification status at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + responseText) +// throw new Error(responseText) +// } + +// const checkStatusResponse: EtherscanRpcResponse = await response.json() + +// if (checkStatusResponse.result === 'A corresponding implementation contract was unfortunately not detected for the proxy address.' || checkStatusResponse.result === 'The provided expected results are different than the retrieved implementation address!' || checkStatusResponse.result === 'This contract does not look like it contains any delegatecall opcode sequence.') { +// return { status: 'failed', receiptId, message: checkStatusResponse.result } +// } +// if (checkStatusResponse.result === 'Verification in progress') { +// return { status: 'pending', receiptId } +// } +// if (checkStatusResponse.result.startsWith("The proxy's") && checkStatusResponse.result.endsWith('and is successfully updated.')) { +// return { status: 'verified', receiptId } +// } +// if (checkStatusResponse.result === 'Unknown UID') { +// console.error('Error on Etherscan API check proxy verification status at ' + this.apiUrl + '\nStatus: ' + checkStatusResponse.status + '\nMessage: ' + checkStatusResponse.message + '\nResult: ' + checkStatusResponse.result) +// return { status: 'failed', receiptId, message: checkStatusResponse.result } +// } + +// if (checkStatusResponse.status !== '1' || !checkStatusResponse.message.startsWith('OK')) { +// console.error('Error on Etherscan API check proxy verification status at ' + this.apiUrl + '\nStatus: ' + checkStatusResponse.status + '\nMessage: ' + checkStatusResponse.message + '\nResult: ' + checkStatusResponse.result) +// throw new Error(checkStatusResponse.result) +// } + +// return { status: 'unknown', receiptId } +// } + + +// async lookup(contractAddress: string, chainId: string): Promise { +// const url = new URL(this.apiUrl + '/api') +// url.searchParams.append('module', 'contract') +// url.searchParams.append('action', 'getsourcecode') +// url.searchParams.append('address', contractAddress) +// if (this.apiKey) { +// url.searchParams.append('apikey', this.apiKey) +// } + +// const response = await fetch(url.href, { method: 'GET' }) + +// if (!response.ok) { +// const responseText = await response.text() +// console.error('Error on Etherscan API lookup at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + responseText) +// throw new Error(responseText) +// } + +// const lookupResponse: EtherscanGetSourceCodeResponse = await response.json() + +// if (lookupResponse.status !== '1' || !lookupResponse.message.startsWith('OK')) { +// const errorResponse = lookupResponse as unknown as EtherscanRpcResponse +// console.error('Error on Etherscan API lookup at ' + this.apiUrl + '\nStatus: ' + errorResponse.status + '\nMessage: ' + errorResponse.message + '\nResult: ' + errorResponse.result) +// throw new Error(errorResponse.result) +// } + +// if (lookupResponse.result[0].ABI === 'Contract source code not verified' || !lookupResponse.result[0].SourceCode) { +// return { status: 'not verified' } +// } + +// const lookupUrl = this.getContractCodeUrl(contractAddress) +// const { sourceFiles, targetFilePath } = this.processReceivedFiles(lookupResponse.result[0], contractAddress, chainId) + +// return { status: 'verified', lookupUrl, sourceFiles, targetFilePath } +// } + +// getContractCodeUrl(address: string): string { +// const url = new URL(this.explorerUrl + `/address/${address}#code`) +// return url.href +// } + +// processReceivedFiles(source: EtherscanSource, contractAddress: string, chainId: string): { sourceFiles: SourceFile[]; targetFilePath?: string } { +// const filePrefix = `/${this.LOOKUP_STORE_DIR}/${chainId}/${contractAddress}` + +// // Covers the cases: +// // SourceFile: {[FileName]: [content]} +// // SourceFile: {{sources: {[FileName]: [content]}}} +// let parsedFiles: any +// try { +// parsedFiles = JSON.parse(source.SourceCode) +// } catch (e) { +// try { +// // Etherscan wraps the Object in one additional bracket +// parsedFiles = JSON.parse(source.SourceCode.substring(1, source.SourceCode.length - 1)).sources +// } catch (e) { } +// } + +// if (parsedFiles) { +// const result: SourceFile[] = [] +// let targetFilePath = '' +// for (const [fileName, fileObj] of Object.entries(parsedFiles)) { +// const path = `${filePrefix}/${fileName}` + +// result.push({ path, content: fileObj.content }) + +// if (path.endsWith(`/${source.ContractName}.sol`)) { +// targetFilePath = path +// } +// } +// return { sourceFiles: result, targetFilePath } +// } + +// // Parsing to JSON failed, SourceCode is the code itself +// const targetFilePath = `${filePrefix}/${source.ContractName}.sol` +// const sourceFiles: SourceFile[] = [{ content: source.SourceCode, path: targetFilePath }] +// return { sourceFiles, targetFilePath } +// } +// \ No newline at end of file diff --git a/apps/contract-interaction/src/app/abiProviders/SourcifyAbiProvider.ts b/apps/contract-interaction/src/app/abiProviders/SourcifyAbiProvider.ts new file mode 100644 index 00000000000..7266e961fbf --- /dev/null +++ b/apps/contract-interaction/src/app/abiProviders/SourcifyAbiProvider.ts @@ -0,0 +1,181 @@ +import { AbstractAbiProvider } from './AbstractAbiProvider' + +export class SourcifyAbiProvider extends AbstractAbiProvider { + LOOKUP_STORE_DIR = 'sourcify-verified' + + // TODO + async lookupABI(contractAddress: string): Promise { + console.error("Not yet implemented") + return undefined + } + + // TODO + async lookupBytecode(contractAddress: string): Promise { + console.error("Not yet implemented") + return undefined + } +} + + +// interface SourcifyVerificationRequest { +// address: string +// chain: string +// files: Record +// creatorTxHash?: string +// chosenContract?: string +// } + +// type SourcifyVerificationStatus = 'perfect' | 'full' | 'partial' | null + +// interface SourcifyVerificationResponse { +// result: [ +// { +// address: string +// chainId: string +// status: SourcifyVerificationStatus +// libraryMap: { +// [key: string]: string +// } +// message?: string +// } +// ] +// } + +// interface SourcifyErrorResponse { +// error: string +// } + +// interface SourcifyFile { +// name: string +// path: string +// content: string +// } + +// interface SourcifyLookupResponse { +// status: Exclude +// files: SourcifyFile[] +// } + +// async verify(submittedContract: SubmittedContract, compilerAbstract: CompilerAbstract): Promise { +// const metadataStr = compilerAbstract.data.contracts[submittedContract.filePath][submittedContract.contractName].metadata +// const sources = compilerAbstract.source.sources + +// // from { "filename.sol": {content: "contract MyContract { ... }"} } +// // to { "filename.sol": "contract MyContract { ... }" } +// const formattedSources = Object.entries(sources).reduce((acc, [fileName, { content }]) => { +// acc[fileName] = content +// return acc +// }, {}) +// const body: SourcifyVerificationRequest = { +// chain: submittedContract.chainId, +// address: submittedContract.address, +// files: { +// 'metadata.json': metadataStr, +// ...formattedSources, +// }, +// } + +// const response = await fetch(new URL(this.apiUrl + '/verify').href, { +// method: 'POST', +// headers: { +// 'Content-Type': 'application/json', +// }, +// body: JSON.stringify(body), +// }) + +// if (!response.ok) { +// const errorResponse: SourcifyErrorResponse = await response.json() +// console.error('Error on Sourcify verification at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + JSON.stringify(errorResponse)) +// throw new Error(errorResponse.error) +// } + +// const verificationResponse: SourcifyVerificationResponse = await response.json() + +// if (verificationResponse.result[0].status === null) { +// console.error('Error on Sourcify verification at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + verificationResponse.result[0].message) +// throw new Error(verificationResponse.result[0].message) +// } + +// // Map to a user-facing status message +// let status: VerificationStatus = 'unknown' +// let lookupUrl: string | undefined = undefined +// if (verificationResponse.result[0].status === 'perfect' || verificationResponse.result[0].status === 'full') { +// status = 'fully verified' +// lookupUrl = this.getContractCodeUrl(submittedContract.address, submittedContract.chainId, true) +// } else if (verificationResponse.result[0].status === 'partial') { +// status = 'partially verified' +// lookupUrl = this.getContractCodeUrl(submittedContract.address, submittedContract.chainId, false) +// } + +// return { status, receiptId: null, lookupUrl } +// } + + + +// async lookup(contractAddress: string, chainId: string): Promise { +// const url = new URL(this.apiUrl + `/files/any/${chainId}/${contractAddress}`) + +// const response = await fetch(url.href, { method: 'GET' }) + +// if (!response.ok) { +// const errorResponse: SourcifyErrorResponse = await response.json() + +// if (errorResponse.error === 'Files have not been found!') { +// return { status: 'not verified' } +// } + +// console.error('Error on Sourcify lookup at ' + this.apiUrl + '\nStatus: ' + response.status + '\nResponse: ' + JSON.stringify(errorResponse)) +// throw new Error(errorResponse.error) +// } + +// const lookupResponse: SourcifyLookupResponse = await response.json() + +// let status: VerificationStatus = 'unknown' +// let lookupUrl: string | undefined = undefined +// if (lookupResponse.status === 'perfect' || lookupResponse.status === 'full') { +// status = 'fully verified' +// lookupUrl = this.getContractCodeUrl(contractAddress, chainId, true) +// } else if (lookupResponse.status === 'partial') { +// status = 'partially verified' +// lookupUrl = this.getContractCodeUrl(contractAddress, chainId, false) +// } + +// const { sourceFiles, targetFilePath } = this.processReceivedFiles(lookupResponse.files, contractAddress, chainId) + +// return { status, lookupUrl, sourceFiles, targetFilePath } +// } + +// getContractCodeUrl(address: string, chainId: string, fullMatch: boolean): string { +// const url = new URL(this.explorerUrl + `/contracts/${fullMatch ? 'full_match' : 'partial_match'}/${chainId}/${address}/`) +// return url.href +// } + +// processReceivedFiles(files: SourcifyFile[], contractAddress: string, chainId: string): { sourceFiles: SourceFile[]; targetFilePath?: string } { +// const result: SourceFile[] = [] +// let targetFilePath: string +// const filePrefix = `/${this.LOOKUP_STORE_DIR}/${chainId}/${contractAddress}` + +// for (const file of files) { +// let filePath: string +// for (const a of [contractAddress, ethers.utils.getAddress(contractAddress)]) { +// const matching = file.path.match(`/${a}/(.*)$`) +// if (matching) { +// filePath = matching[1] +// break +// } +// } + +// if (filePath) { +// result.push({ path: `${filePrefix}/${filePath}`, content: file.content }) +// } + +// if (file.name === 'metadata.json') { +// const metadata = JSON.parse(file.content) +// const compilationTarget = metadata.settings.compilationTarget +// const contractPath = Object.keys(compilationTarget)[0] +// targetFilePath = `${filePrefix}/sources/${contractPath}` +// } +// } + +// return { sourceFiles: result, targetFilePath } +// } diff --git a/apps/contract-interaction/src/app/abiProviders/index.ts b/apps/contract-interaction/src/app/abiProviders/index.ts new file mode 100644 index 00000000000..513f520aeda --- /dev/null +++ b/apps/contract-interaction/src/app/abiProviders/index.ts @@ -0,0 +1,30 @@ +import type { AbiProviderIdentifier, AbiProviderSettings } from '../types' +import { AbstractAbiProvider } from './AbstractAbiProvider' +import { BlockscoutAbiProvider } from './BlockscoutAbiProvider' +import { EtherscanAbiProvider } from './EtherscanAbiProvider' +import { SourcifyAbiProvider } from './SourcifyAbiProvider' + +export { AbstractAbiProvider } from './AbstractAbiProvider' +export { BlockscoutAbiProvider } from './BlockscoutAbiProvider' +export { SourcifyAbiProvider } from './SourcifyAbiProvider' +export { EtherscanAbiProvider } from './EtherscanAbiProvider' + +export function getAbiProvider(identifier: AbiProviderIdentifier, settings: AbiProviderSettings): AbstractAbiProvider { + switch (identifier) { + case 'Sourcify': + if (!settings?.explorerUrl) { + throw new Error('The Sourcify provider requires an explorer URL.') + } + return new SourcifyAbiProvider(settings.apiUrl, settings.explorerUrl) + case 'Etherscan': + if (!settings?.explorerUrl) { + throw new Error('The Etherscan provider requires an explorer URL.') + } + if (!settings?.apiKey) { + throw new Error('The Etherscan provider requires an API key.') + } + return new EtherscanAbiProvider(settings.apiUrl, settings.explorerUrl, settings.apiKey) + case 'Blockscout': + return new BlockscoutAbiProvider(settings.apiUrl) + } +} diff --git a/apps/contract-interaction/src/app/actions/index.ts b/apps/contract-interaction/src/app/actions/index.ts new file mode 100644 index 00000000000..a8ae5bc59e2 --- /dev/null +++ b/apps/contract-interaction/src/app/actions/index.ts @@ -0,0 +1,127 @@ +import { PluginClient } from '@remixproject/plugin'; +import ContractInteractionPluginClient from '../ContractInteractionPluginClient'; + +import { CLEAR_INSTANCES, PIN_INSTANCE, REMOVE_INSTANCE, SET_GAS_LIMIT, SET_INSTANCE, SET_SELECTED_ACCOUNT, SET_SEND_UNIT, SET_SEND_VALUE, UNPIN_INSTANCE } from "../reducers/state" +import { Chain, ContractInstance } from '../types/AbiProviderTypes'; + +let dispatch: React.Dispatch + +export const initDispatch = (_dispatch: React.Dispatch) => { + dispatch = _dispatch; +}; + +export const loadPluginAction = async () => { + await dispatch({ + type: 'SET_LOADING', + payload: { + screen: true, + }, + }); + + await ContractInteractionPluginClient.onload(); + + // await ContractVerificationPluginClient.call('layout', 'minimize', 'terminal', true); + + await dispatch({ + type: 'SET_LOADING', + payload: { + screen: false, + }, + }); +}; + +export const loadPinnedContractsAction = async (plugin: PluginClient, chain: Chain) => { + // TODO: `exists` is not exposed by the plugin-api, which would be better then throwing an error if folder does not exists. + // const isPinnedAvailable = await plugin.call('fileManager', 'exists',`./looked-up-contracts/pinned-contracts/${chainId}/${contractAddress}`) + + try { + const list = await plugin.call('fileManager', 'readdir', `.looked-up-contracts/pinned-contracts/${chain.chainId}`) + const contractAddressFilePaths = Object.keys(list) + + for (const contractAddressFilePath of contractAddressFilePaths) { + const list = await plugin.call('fileManager', 'readdir', contractAddressFilePath) + const contractInstanceFilePaths = Object.keys(list) + + for (const file of contractInstanceFilePaths) { + const pinnedContract = await plugin.call('fileManager', 'readFile', file) + const pinnedContractObj: ContractInstance = JSON.parse(pinnedContract) + pinnedContractObj.isPinned = true + + if (pinnedContractObj) setInstanceAction(pinnedContractObj) + } + } + } catch (err) { + console.log(err) + } +} + +//contractData?: ContractData, +export const setInstanceAction = (instance: ContractInstance) => { + dispatch({ + type: SET_INSTANCE, + payload: instance + }) +} + +export const pinInstanceAction = (index: number, pinnedTimestamp: number) => { + dispatch({ + type: PIN_INSTANCE, + payload: { + index, + pinnedTimestamp + } + }) +} + +export const unpinInstanceAction = (index: number) => { + dispatch({ + type: UNPIN_INSTANCE, + payload: { + index + } + }) +} + +export const removeInstanceAction = (index: number) => { + dispatch({ + type: REMOVE_INSTANCE, + payload: { + index + } + }) +} + +export const clearInstancesAction = () => { + dispatch({ + type: CLEAR_INSTANCES + }) +} + +export const setSelectedAccountAction = (selectedAccount: string) => { + dispatch({ + type: SET_SELECTED_ACCOUNT, + payload: selectedAccount + }) +} + +export const setSendValue = (sendValue: string) => { + dispatch({ + type: SET_SEND_VALUE, + payload: sendValue + }) +} + +export const setSendUnit = (sendUnit: string) => { + dispatch({ + type: SET_SEND_UNIT, + payload: sendUnit + }) +} + +export const setGasLimit = (gasLimit: number) => { + dispatch({ + type: SET_GAS_LIMIT, + payload: gasLimit + }) +} + diff --git a/apps/contract-interaction/src/app/app.tsx b/apps/contract-interaction/src/app/app.tsx new file mode 100644 index 00000000000..5364f5c46cf --- /dev/null +++ b/apps/contract-interaction/src/app/app.tsx @@ -0,0 +1,98 @@ +import { useState, useEffect, useRef, useReducer } from 'react' + +import ContractInteractionPluginClient from './ContractInteractionPluginClient' + +import { AppContext } from './AppContext' +import { InteractionFormContext } from './InteractionFormContext' +import DisplayRoutes from './routes' +import type { ContractInteractionSettings, ThemeType, Chain } from './types' +import { mergeChainSettingsWithDefaults } from './utils' +import { IntlProvider } from 'react-intl' + +import './App.css' +import { CompilerAbstract } from '@remix-project/remix-solidity' +import { useLocalStorage } from './hooks/useLocalStorage' +import { ContractDropdownSelection } from './components/ContractDropdown' +import { appReducer, appInitialState } from './reducers/state' +import { initDispatch } from './actions' + +let plugin = ContractInteractionPluginClient; + +const App = () => { + const [appState, dispatch] = useReducer(appReducer, appInitialState); + + // TODO: theme + // const [themeType, setThemeType] = useState('dark') + const [settings, setSettings] = useLocalStorage('contract-interaction:settings', { chains: {} }) + const [chains, setChains] = useState([]) // State to hold the chains data + const [compilationOutput, setCompilationOutput] = useState<{ [key: string]: CompilerAbstract } | undefined>() + + // Form values: + const [selectedChain, setSelectedChain] = useState() + const [contractAddress, setContractAddress] = useState('') + const [contractAddressError, setContractAddressError] = useState('') + const [selectedContract, setSelectedContract] = useState() + const [proxyAddress, setProxyAddress] = useState('') + const [proxyAddressError, setProxyAddressError] = useState('') + const [abiEncodedConstructorArgs, setAbiEncodedConstructorArgs] = useState('') + const [abiEncodingError, setAbiEncodingError] = useState('') + const [locale, setLocale] = useState<{ code: string; messages: any }>({ + code: 'en', + messages: null, + }) + + useEffect(() => { + plugin.internalEvents.on('interaction_activated', () => { + // Fetch compiler artefacts initially + plugin.call('compilerArtefacts' as any, 'getAllCompilerAbstracts').then((obj: any) => { + setCompilationOutput(obj) + }) + + // Subscribe to compilations + plugin.on('compilerArtefacts' as any, 'compilationSaved', (compilerAbstracts: { [key: string]: CompilerAbstract }) => { + setCompilationOutput((prev) => ({ ...(prev || {}), ...compilerAbstracts })) + }) + + // Fetch chains.json and update state + fetch('https://chainid.network/chains.json') + .then((response) => response.json()) + .then((data) => setChains(data)) + .catch((error) => console.error('Failed to fetch chains.json:', error)) + }) + + // Clean up on unmount + return () => { + plugin.off('compilerArtefacts' as any, 'compilationSaved') + } + }, []) + + useEffect(() => { + initDispatch(dispatch); + + plugin.loadPlugin().then(() => { + + // @ts-ignore + plugin.call('locale', 'currentLocale').then((locale: any) => { + setLocale(locale) + }) + // @ts-ignore + plugin.on('locale', 'localeChanged', (locale: any) => { + setLocale(locale) + }) + }); + }, []); + + return ( + + + + + + + + + + ) +} + +export default App diff --git a/apps/contract-interaction/src/app/components/ConfigInput.tsx b/apps/contract-interaction/src/app/components/ConfigInput.tsx new file mode 100644 index 00000000000..0737840115d --- /dev/null +++ b/apps/contract-interaction/src/app/components/ConfigInput.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useState } from 'react' +import { CustomTooltip } from '@remix-ui/helper' + +interface ConfigInputProps { + label: string + id: string + secret: boolean + initialValue: string + saveResult: (result: string) => void +} + +// Chooses one contract from the compilation output. +export const ConfigInput: React.FC = ({ label, id, secret, initialValue, saveResult }) => { + const [value, setValue] = useState(initialValue) + const [enabled, setEnabled] = useState(false) + + // Reset state when initialValue changes + useEffect(() => { + setValue(initialValue) + setEnabled(false) + }, [initialValue]) + + const handleChange = () => { + setEnabled(true) + } + + const handleSave = () => { + setEnabled(false) + saveResult(value) + } + + const handleCancel = () => { + setEnabled(false) + setValue(initialValue) + } + + return ( +
+ +
+ setValue(e.target.value)} + disabled={!enabled} + /> + + { enabled ? ( + <> + + + + ) : ( + + + + )} +
+
+ ) +} diff --git a/apps/contract-interaction/src/app/components/ConstructorArguments.tsx b/apps/contract-interaction/src/app/components/ConstructorArguments.tsx new file mode 100644 index 00000000000..c7464718837 --- /dev/null +++ b/apps/contract-interaction/src/app/components/ConstructorArguments.tsx @@ -0,0 +1,137 @@ +import { useContext, useEffect, useRef, useState } from 'react' +import { ethers } from 'ethers' + +import { AppContext } from '../AppContext' +import { ContractDropdownSelection } from './ContractDropdown' + +interface ConstructorArgumentsProps { + abiEncodedConstructorArgs: string + setAbiEncodedConstructorArgs: React.Dispatch> + abiEncodingError: string + setAbiEncodingError: React.Dispatch> + selectedContract: ContractDropdownSelection +} + +export const ConstructorArguments: React.FC = ({ abiEncodedConstructorArgs, setAbiEncodedConstructorArgs, abiEncodingError, setAbiEncodingError, selectedContract }) => { + return (<>) + + // const { compilationOutput } = useContext(AppContext) + // const [toggleRawInput, setToggleRawInput] = useState(false) + + // const { triggerFilePath, filePath, contractName } = selectedContract + // const selectedCompilerAbstract = triggerFilePath && compilationOutput[triggerFilePath] + // const compiledContract = selectedCompilerAbstract?.data?.contracts?.[filePath]?.[contractName] + // const abi = compiledContract?.abi + + // const constructorArgs = abi && abi.find((a) => a.type === 'constructor')?.inputs + + // const decodeConstructorArgs = (value: string) => { + // try { + // const decodedObj = ethers.utils.defaultAbiCoder.decode( + // constructorArgs.map((inp) => inp.type), + // value + // ) + // const decoded = decodedObj.map((val) => JSON.stringify(val)) + // return { decoded, errorMessage: '' } + // } catch (e) { + // console.error(e) + // const errorMessage = 'Decoding error: ' + e.message + // const decoded = Array(constructorArgs?.length ?? 0).fill('') + // return { decoded, errorMessage } + // } + // } + + // const [constructorArgsValues, setConstructorArgsValues] = useState(abiEncodedConstructorArgs ? decodeConstructorArgs(abiEncodedConstructorArgs).decoded : Array(constructorArgs?.length ?? 0).fill('')) + + // const constructorArgsInInitialState = useRef(true) + // useEffect(() => { + // if (constructorArgsInInitialState.current) { + // constructorArgsInInitialState.current = false + // return + // } + // setAbiEncodedConstructorArgs('') + // setAbiEncodingError('') + // setConstructorArgsValues(Array(constructorArgs?.length ?? 0).fill('')) + // }, [constructorArgs]) + + // const handleConstructorArgs = (value: string, index: number) => { + // const changedConstructorArgsValues = [...constructorArgsValues.slice(0, index), value, ...constructorArgsValues.slice(index + 1)] + // setConstructorArgsValues(changedConstructorArgsValues) + + // // if any constructorArgsValue is falsey (empty etc.), don't encode yet + // if (changedConstructorArgsValues.some((value) => !value)) { + // setAbiEncodedConstructorArgs('') + // setAbiEncodingError('') + // return + // } + + // const types = constructorArgs.map((inp) => inp.type) + // const parsedArgsValues = [] + // for (const arg of changedConstructorArgsValues) { + // try { + // parsedArgsValues.push(JSON.parse(arg)) + // } catch (e) { + // parsedArgsValues.push(arg) + // } + // } + + // try { + // const newAbiEncoding = ethers.utils.defaultAbiCoder.encode(types, parsedArgsValues) + // setAbiEncodedConstructorArgs(newAbiEncoding) + // setAbiEncodingError('') + // } catch (e) { + // console.error(e) + // setAbiEncodedConstructorArgs('') + // setAbiEncodingError('Encoding error: ' + e.message) + // } + // } + + // const handleRawConstructorArgs = (value: string) => { + // setAbiEncodedConstructorArgs(value) + // const { decoded, errorMessage } = decodeConstructorArgs(value) + // setConstructorArgsValues(decoded) + // setAbiEncodingError(errorMessage) + // } + + // if (!selectedContract) return null + // if (!compilationOutput && Object.keys(compilationOutput).length === 0) return null + // // No render if no constructor args + // if (!constructorArgs || constructorArgs.length === 0) return null + + // return ( + //
+ // + //
+ // setToggleRawInput(!toggleRawInput)} /> + // + //
+ // {toggleRawInput ? ( + //
+ // {' '} + //