diff --git a/apps/quick-dapp/.eslintrc b/apps/quick-dapp/.eslintrc new file mode 100644 index 0000000000..2d85f9fa66 --- /dev/null +++ b/apps/quick-dapp/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": "../../.eslintrc.json", +} \ No newline at end of file diff --git a/apps/quick-dapp/README.md b/apps/quick-dapp/README.md new file mode 100644 index 0000000000..ff8a5ce950 --- /dev/null +++ b/apps/quick-dapp/README.md @@ -0,0 +1,134 @@ +# Remix LearnEth Plugin + +## Available Scripts + +In the project directory, you can run: + +### `npm run serve:plugin --plugin=learneth` + +Runs the app in the development mode.\ +Open [http://localhost:2024](http://localhost:2024) to view it in the browser. + +The page will reload if you make edits.\ +You will also see any lint errors in the console. + +### `npm run build:plugin --plugin=learneth` + +Builds the app for production to the `dist/apps/learneth` folder.\ +It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.\ +Your app is ready to be deployed! + +## Loading the plugin in remix + +When testing with localhost you should use the HTTP version of either REMIX or REMIX ALPHA. Click on the plugin manager icon and +add the plugin 'Connect to a local plugin'. Your plugin will be at http://localhost:2024/. + +## Setting up the REMIX IDE for working with the plugin + +The plugin only works when a compiler environment is loaded as well, for example on the home screen of the IDE you select 'Solidity' or 'Vyper'. Without this the plugin +cannot compile and test files in the workshops. + +## Setting up your Github workshops repo + +You can create your own workshops that can be imported in the plugin. +When importing a github repo the plugin will look for a directory structure describing the workshops. +For example: https://github.com/ethereum/remix-workshops + +### Root directories + +Root directories are individual workshops, the name used will be the name of the workshop unless you override this with the name property in the config.yml. + +### README.md + +The readme in each directory contains an explanation of what the workshop is about. If an additional summary property is provided in the config.yml that will be used in the overview section of the plugin. + +### config.yml + +This config file contains metadata describing some properties of your workshop, for example + +``` +--- +id: someid +name: my workshop name +summary: something about this workshop +level: 4 +tags: + - solidity + - beginner +``` + +Level: a level of difficulty indicator ( 1 - 5 ) + +Tags: an array of tags + +id: this is used by the system to let REMIX call startTutorial(repo,branch,id). See below for more instructions. + +### Steps + +Each workshop contains what we call steps. +Each step is a directory containing: + +- a readme describing the step, what to do. +- sol files: + - these can be sol files and test sol files. The test files should be name yoursolname_test.sol + - ANSWER files: these are named yoursolname_answer.sol and can be used to show the solution or the correct answer. The plugin will load the + file in the IDE when a user clicks on 'Show Answer' +- js files +- vyper files + +## Functions to call the plugin from the IDE + +### Add a repository: + +``` +addRepository(repoName, branch) +``` + +### Start a tutorial + +``` +startTutorial(repoName,branch,id) +``` + +You don't need to add a separate addRepository before calling startTutorial, this call will also add the repo. + +_Parameters_ + +id: this can be two things: + +- type of number, it specifies the n-th tutorial in the list +- type of string, this refers to the ID parameter in the config.yml file in the tutorial + for example: + +``` +--- +id: basics +name: 1 Basics of Solidity +summary: Some basic functions explained +level: 4 +tags: + - solidity +``` + +### How to call these functions in the REMIX IDE + +``` +(function () { +try { + // You don't need to add a separate addRepository before calling startTutorial, this is just an example + remix.call('LearnEth', 'addRepository', "ethereum/remix-workshops", "master") + remix.call('LearnEth', 'startTutorial', "ethereum/remix-workshops", "master", "basics") + remix.call('LearnEth', 'startTutorial', "ethereum/remix-workshops", "master", 2) +} catch (e) { + console.log(e.message) +} +})() +``` + +Then call this in the REMIX console + +``` +remix.exeCurrent() +``` diff --git a/apps/quick-dapp/package.json b/apps/quick-dapp/package.json new file mode 100644 index 0000000000..ed130ad7dc --- /dev/null +++ b/apps/quick-dapp/package.json @@ -0,0 +1,11 @@ +{ + "name": "quick-dapp", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@drafish/surge-client": "^1.1.1" + } +} diff --git a/apps/quick-dapp/project.json b/apps/quick-dapp/project.json new file mode 100644 index 0000000000..8ca9b6c42f --- /dev/null +++ b/apps/quick-dapp/project.json @@ -0,0 +1,70 @@ +{ + "name": "quick-dapp", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/quick-dapp/src", + "projectType": "application", + "implicitDependencies": [], + "targets": { + "build": { + "executor": "@nrwl/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "development", + "dependsOn": ["install"], + "options": { + "compiler": "babel", + "outputPath": "dist/apps/quick-dapp", + "index": "apps/quick-dapp/src/index.html", + "baseHref": "./", + "main": "apps/quick-dapp/src/main.tsx", + "polyfills": "apps/quick-dapp/src/polyfills.ts", + "tsConfig": "apps/quick-dapp/tsconfig.app.json", + "assets": ["apps/quick-dapp/src/profile.json", "apps/quick-dapp/src/assets/edit-dapp.png"], + "styles": ["apps/quick-dapp/src/index.css"], + "scripts": [], + "webpackConfig": "apps/quick-dapp/webpack.config.js" + }, + "configurations": { + "development": { + }, + "production": { + } + } + }, + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/quick-dapp/**/*.ts"], + "eslintConfig": "apps/quick-dapp/.eslintrc" + } + }, + "install": { + "executor": "nx:run-commands", + "options": { + "commands": [ + "cd apps/quick-dapp && yarn" + ], + "parallel": false + } + }, + "serve": { + "executor": "@nrwl/webpack:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "quick-dapp:build", + "hmr": true, + "baseHref": "/" + }, + "configurations": { + "development": { + "buildTarget": "quick-dapp:build:development", + "port": 2025 + }, + "production": { + "buildTarget": "quick-dapp:build:production" + } + } + } + }, + "tags": [] +} diff --git a/apps/quick-dapp/src/App.css b/apps/quick-dapp/src/App.css new file mode 100644 index 0000000000..1fa7e5637a --- /dev/null +++ b/apps/quick-dapp/src/App.css @@ -0,0 +1,146 @@ +/* You can add global styles to this file, and also import other style files */ + +.item-wrapper { + transform: translate3d(var(--translate-x, 0), var(--translate-y, 0), 0) + scaleX(var(--scale-x, 1)) scaleY(var(--scale-y, 1)); + transform-origin: 0 0; + touch-action: manipulation; + + &:hover { + .item-remove { + visibility: visible; + } + } +} + +.item-remove { + visibility: hidden; + top: 5px; + right: 5px; + width: 20px; + height: 20px; + background-color: rgba(0, 0, 0, 0.3); + + &:active { + background-color: rgba(255, 70, 70, 0.9); + } + + svg { + fill: #fff; + } +} + +.item-action { + touch-action: none; + outline: none !important; + appearance: none; + background-color: transparent; + -webkit-tap-highlight-color: transparent; + + @media (hover: hover) { + &:hover { + background-color: var(--action-background, rgba(0, 0, 0, 0.05)); + + svg { + fill: #6f7b88; + } + } + } + + svg { + overflow: visible; + fill: #919eab; + } + + &:active { + background-color: var(--background, rgba(0, 0, 0, 0.05)); + + svg { + fill: var(--fill, #788491); + } + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0), + 0 0px 0px 2px #4c9ffe; + } +} + +.container { + flex-direction: column; + + &.placeholder { + justify-content: center; + align-items: center; + cursor: pointer; + } + + &:focus-visible { + border-color: transparent; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0), 0 0px 0px 2px #4c9ffe; + } +} + + +.container-header { + &:hover { + .container-actions > * { + opacity: 1 !important; + } + } +} + +.container-actions { + > *:first-child:not(:last-child) { + opacity: 0; + + &:focus-visible { + opacity: 1; + } + } +} + +.instance-input { + background-color: var(--custom-select) !important; + color: #dfe1ea !important; + font-size: 10px; +} +.has-args { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.udapp_intro { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + white-space: pre-wrap; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.udapp_intro:hover { + -webkit-line-clamp: inherit; +} +.cursor_pointer { + cursor: pointer; +} +.cursor_pointer:hover { + color: var(--secondary); +} +.custom-dropdown-items { + padding: 0.25rem 0.25rem; + border-radius: .25rem; + background: var(--custom-select); +} + +.custom-dropdown-items a { + border-radius: .25rem; + text-transform: none; + text-decoration: none; + font-weight: normal; + font-size: 0.875rem; + padding: 0.25rem 0.25rem; + width: auto; + color: var(--text); +} diff --git a/apps/quick-dapp/src/App.tsx b/apps/quick-dapp/src/App.tsx new file mode 100644 index 0000000000..5adacb2e2f --- /dev/null +++ b/apps/quick-dapp/src/App.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useReducer } from 'react'; +import CreateInstance from './components/CreateInstance'; +import EditInstance from './components/EditInstance'; +import DeployPanel from './components/DeployPanel'; +import LoadingScreen from './components/LoadingScreen'; +import { appInitialState, appReducer } from './reducers/state'; +import { + connectRemix, + initDispatch, + updateState, + selectTheme, +} from './actions'; +import { AppContext } from './contexts'; +import remixClient from './remix-client'; +import './App.css'; + +function App(): JSX.Element { + const [appState, dispatch] = useReducer(appReducer, appInitialState); + useEffect(() => { + updateState(appState); + }, [appState]); + useEffect(() => { + initDispatch(dispatch); + updateState(appState); + connectRemix().then(() => { + remixClient.call('theme', 'currentTheme').then((theme: any) => { + selectTheme(theme.name); + }); + remixClient.on('theme', 'themeChanged', (theme: any) => { + selectTheme(theme.name); + }); + }); + }, []); + return ( + + {Object.keys(appState.instance.abi).length > 0 ? ( +
+ + +
+ ) : ( +
+ +
+ )} + +
+ ); +} + +export default App; diff --git a/apps/quick-dapp/src/actions/index.ts b/apps/quick-dapp/src/actions/index.ts new file mode 100644 index 0000000000..5990d13cec --- /dev/null +++ b/apps/quick-dapp/src/actions/index.ts @@ -0,0 +1,320 @@ +import axios from 'axios'; +import { omitBy } from 'lodash'; +import { execution } from '@remix-project/remix-lib'; +import SurgeClient from '@drafish/surge-client'; +import remixClient from '../remix-client'; +import { themeMap } from '../components/DeployPanel/theme'; + +const { encodeFunctionId } = execution.txHelper; + +const surgeClient = new SurgeClient({ + proxy: 'https://vercel-proxy-bice-six.vercel.app', + onError: (err: Error) => { + console.log(err); + }, +}); + +let dispatch: any, state: any; + +export const initDispatch = (_dispatch: any) => { + dispatch = _dispatch; +}; + +export const updateState = (_state: any) => { + state = _state; +}; + +export const connectRemix = async () => { + await dispatch({ + type: 'SET_LOADING', + payload: { + screen: true, + }, + }); + + await remixClient.onload(); + + await dispatch({ + type: 'SET_LOADING', + payload: { + screen: false, + }, + }); +}; + +export const saveDetails = async (payload: any) => { + const { abi, userInput, natSpec } = state.instance; + + await dispatch({ + type: 'SET_INSTANCE', + payload: { + abi: { + ...abi, + [payload.id]: { + ...abi[payload.id], + details: + natSpec.checked && !payload.details + ? natSpec.methods[payload.id] + : payload.details, + }, + }, + userInput: { + ...omitBy(userInput, (item) => item === ''), + methods: omitBy( + { + ...userInput.methods, + [payload.id]: payload.details, + }, + (item) => item === '' + ), + }, + }, + }); +}; + +export const saveTitle = async (payload: any) => { + const { abi } = state.instance; + + await dispatch({ + type: 'SET_INSTANCE', + payload: { + abi: { + ...abi, + [payload.id]: { ...abi[payload.id], title: payload.title }, + }, + }, + }); +}; + +export const getInfoFromNatSpec = async (value: boolean) => { + const { abi, userInput, natSpec } = state.instance; + const input = value + ? { + ...natSpec, + ...userInput, + methods: { ...natSpec.methods, ...userInput.methods }, + } + : userInput; + Object.keys(abi).forEach((id) => { + abi[id].details = input.methods[id] || ''; + }); + await dispatch({ + type: 'SET_INSTANCE', + payload: { + abi, + title: input.title || '', + details: input.details || '', + natSpec: { ...natSpec, checked: value }, + }, + }); +}; + +export const deploy = async (payload: any, callback: any) => { + const surgeToken = localStorage.getItem('__SURGE_TOKEN'); + const surgeEmail = localStorage.getItem('__SURGE_EMAIL'); + let isLogin = false; + if (surgeToken && surgeEmail === payload.email) { + try { + await surgeClient.whoami(); + isLogin = true; + } catch (error) { + /* empty */ + } + } + if (!isLogin) { + try { + await surgeClient.login({ + user: payload.email, + password: payload.password, + }); + localStorage.setItem('__SURGE_EMAIL', payload.email); + localStorage.setItem('__SURGE_PASSWORD', payload.password); + localStorage.setItem('__DISQUS_SHORTNAME', payload.shortname); + } catch (error: any) { + callback({ code: 'ERROR', error: error.message }); + return; + } + } + + const { data } = await axios.get( + 'https://remix-dapp.pages.dev/manifest.json' + ); + const { src, file, css, assets } = data['index.html']; + const paths = [src, file, ...css, ...assets]; + + const instance = state.instance; + + const files: Record = { + 'dir/instance.json': JSON.stringify({ + ...instance, + shortname: payload.shortname, + shareTo: payload.shareTo, + }), + }; + + // console.log( + // JSON.stringify({ + // ...instance, + // shareTo: payload.shareTo, + // }) + // ); + + for (let index = 0; index < paths.length; index++) { + const path = paths[index]; + const resp = await axios.get(`https://remix-dapp.pages.dev/${path}`); + files[`dir/${path}`] = resp.data; + } + + files['dir/index.html'] = files['dir/index.html'].replace( + 'assets/css/themes/remix-dark_tvx1s2.css', + themeMap[instance.theme].url + ); + + try { + await surgeClient.publish({ + files, + domain: `${payload.subdomain}.surge.sh`, + onProgress: ({ + id, + progress, + file, + }: { + id: string; + progress: number; + file: string; + }) => { + // console.log({ id, progress, file }); + }, + onTick: (tick: string) => {}, + }); + } catch (error) { + callback({ code: 'ERROR', error: 'this domain belongs to someone else' }); + return; + } + + callback({ code: 'SUCCESS', error: '' }); + return; +}; + +export const initInstance = async ({ + methodIdentifiers, + devdoc, + ...payload +}: any) => { + const functionHashes: any = {}; + const natSpec: any = { checked: false, methods: {} }; + if (methodIdentifiers && devdoc) { + for (const fun in methodIdentifiers) { + functionHashes[`0x${methodIdentifiers[fun]}`] = fun; + } + natSpec.title = devdoc.title; + natSpec.details = devdoc.details; + Object.keys(functionHashes).forEach((hash) => { + const method = functionHashes[hash]; + if (devdoc.methods[method]) { + const { details, params, returns } = devdoc.methods[method]; + const detailsStr = details ? `@dev ${details}` : ''; + const paramsStr = params + ? Object.keys(params) + .map((key) => `@param ${key} ${params[key]}`) + .join('\n') + : ''; + const returnsStr = returns + ? Object.keys(returns) + .map( + (key) => + `@return${/^_\d$/.test(key) ? '' : ' ' + key} ${returns[key]}` + ) + .join('\n') + : ''; + natSpec.methods[hash] = [detailsStr, paramsStr, returnsStr] + .filter((str) => str !== '') + .join('\n'); + } + }); + } + + const abi: any = {}; + payload.abi.forEach((item: any) => { + if (item.type === 'function') { + item.id = encodeFunctionId(item); + abi[item.id] = item; + } + }); + const ids = Object.keys(abi); + const items = + ids.length > 2 + ? { + A: ids.slice(0, ids.length / 2 + 1), + B: ids.slice(ids.length / 2 + 1), + } + : { A: ids }; + await dispatch({ + type: 'SET_INSTANCE', + payload: { + ...payload, + abi, + items, + containers: Object.keys(items), + natSpec, + }, + }); +}; + +export const resetInstance = async () => { + const abi = state.instance.abi; + const ids = Object.keys(abi); + ids.forEach((id) => { + abi[id] = { ...abi[id], title: '', details: '' }; + }); + const items = + ids.length > 1 + ? { + A: ids.slice(0, ids.length / 2 + 1), + B: ids.slice(ids.length / 2 + 1), + } + : { A: ids }; + await dispatch({ + type: 'SET_INSTANCE', + payload: { + items, + containers: Object.keys(items), + title: '', + details: '', + abi, + }, + }); +}; + +export const emptyInstance = async () => { + await dispatch({ + type: 'SET_INSTANCE', + payload: { + name: '', + address: '', + network: '', + abi: {}, + items: {}, + containers: [], + title: '', + details: '', + theme: 'Dark', + userInput: { methods: {} }, + natSpec: { checked: false, methods: {} }, + }, + }); +}; + +export const selectTheme = async (selectedTheme: string) => { + await dispatch({ type: 'SET_INSTANCE', payload: { theme: selectedTheme } }); + + const linkEles = document.querySelectorAll('link'); + const nextTheme = themeMap[selectedTheme]; // Theme + for (const link of linkEles) { + if (link.href.indexOf('/assets/css/themes/') > 0) { + link.href = 'https://remix.ethereum.org/' + nextTheme.url; + document.documentElement.style.setProperty('--theme', nextTheme.quality); + break; + } + } +}; diff --git a/apps/quick-dapp/src/assets/edit-dapp.png b/apps/quick-dapp/src/assets/edit-dapp.png new file mode 100644 index 0000000000..445ce2a218 Binary files /dev/null and b/apps/quick-dapp/src/assets/edit-dapp.png differ diff --git a/apps/quick-dapp/src/components/ContractGUI/index.tsx b/apps/quick-dapp/src/components/ContractGUI/index.tsx new file mode 100644 index 0000000000..0976531c9e --- /dev/null +++ b/apps/quick-dapp/src/components/ContractGUI/index.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from 'react'; +import { execution } from '@remix-project/remix-lib'; +import { saveDetails, saveTitle } from '../../actions'; + +const txHelper = execution.txHelper; + +const getFuncABIInputs = (funABI: any) => { + if (!funABI.inputs) { + return ''; + } + return txHelper.inputParametersDeclarationToString(funABI.inputs); +}; + +export function ContractGUI(props: { funcABI: any }) { + const isConstant = + props.funcABI.constant !== undefined ? props.funcABI.constant : false; + const lookupOnly = + props.funcABI.stateMutability === 'view' || + props.funcABI.stateMutability === 'pure' || + isConstant; + const inputs = getFuncABIInputs(props.funcABI); + const [title, setTitle] = useState(''); + const [buttonOptions, setButtonOptions] = useState<{ + title: string; + content: string; + classList: string; + dataId: string; + }>({ title: '', content: '', classList: '', dataId: '' }); + + useEffect(() => { + if (props.funcABI.name) { + setTitle(props.funcABI.name); + } else { + setTitle(props.funcABI.type === 'receive' ? '(receive)' : '(fallback)'); + } + }, [props.funcABI]); + + useEffect(() => { + if (lookupOnly) { + setButtonOptions({ + title: title + ' - call', + content: 'call', + classList: 'btn-info', + dataId: title + ' - call', + }); + } else if ( + props.funcABI.stateMutability === 'payable' || + props.funcABI.payable + ) { + setButtonOptions({ + title: title + ' - transact (payable)', + content: 'transact', + classList: 'btn-danger', + dataId: title + ' - transact (payable)', + }); + } else { + setButtonOptions({ + title: title + ' - transact (not payable)', + content: 'transact', + classList: 'btn-warning', + dataId: title + ' - transact (not payable)', + }); + } + }, [lookupOnly, props.funcABI, title]); + + return ( +
+
+ { + saveTitle({ id: props.funcABI.id, title: value }); + }} + /> +
+
+
+ +
+ 0 + ) + ? 'hidden' + : 'visible', + }} + /> +
+
+