├── .eslintignore ├── .prettierignore ├── .browserslistrc ├── src ├── types │ └── index.ts ├── assets │ └── logo.png ├── main.ts ├── views │ ├── About.vue │ ├── Error404.vue │ └── Home.vue ├── utils │ ├── formatters.ts │ ├── ethers.ts │ └── constants.ts ├── shims.d.ts ├── router │ └── index.ts ├── index.css ├── App.vue ├── store │ ├── settings.ts │ ├── data.ts │ └── wallet.ts └── components │ ├── LayoutHeader.vue │ └── LayoutFooter.vue ├── cypress.json ├── .env.template ├── public └── favicon.ico ├── postcss.config.js ├── .prettierrc ├── tests ├── e2e │ ├── .eslintrc.js │ ├── specs │ │ └── test.js │ ├── support │ │ ├── index.js │ │ └── commands.js │ └── plugins │ │ └── index.js └── unit │ └── example.spec.ts ├── .gitignore ├── vite.config.ts ├── vue.config.js ├── tailwind.config.js ├── index.html ├── tsconfig.json ├── .eslintrc.js ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | // App-specific type definition go here 2 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginsFile": "tests/e2e/plugins/index.js" 3 | } 4 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | VITE_BLOCKNATIVE_API_KEY=yourBlocknativeApiKey 2 | VITE_INFURA_API_KEY=yourInfuraApiKey -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScopeLift/vue-tailwind-ethereum-template/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ScopeLift/vue-tailwind-ethereum-template/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "printWidth": 120, 4 | "semi": true, 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from './App.vue'; 3 | import router from './router'; 4 | import './index.css'; 5 | 6 | createApp(App).use(router).mount('#app'); 7 | -------------------------------------------------------------------------------- /tests/e2e/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['cypress'], 3 | env: { 4 | mocha: true, 5 | 'cypress/globals': true, 6 | }, 7 | rules: { 8 | strict: 'off', 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /src/views/About.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /tests/e2e/specs/test.js: -------------------------------------------------------------------------------- 1 | // https://docs.cypress.io/api/introduction/api.html 2 | 3 | describe('My First Test', () => { 4 | it('Visits the app root url', () => { 5 | cy.visit('/'); 6 | cy.contains('h1', 'Welcome to Your Vue.js + TypeScript App'); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/utils/formatters.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @notice Various formatters to help display data in the UI nicely 3 | */ 4 | 5 | // Returns an address with the following format: 0x1234...abcd 6 | export function formatAddress(address: string) { 7 | if (address.length !== 42) return null; 8 | return `${address.slice(0, 6)}...${address.slice(38)}`; 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | /tests/e2e/videos/ 6 | /tests/e2e/screenshots/ 7 | 8 | 9 | # local env files 10 | .env.local 11 | .env.*.local 12 | 13 | # Log files 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | pnpm-debug.log* 18 | 19 | # Editor directories and files 20 | .idea 21 | .vscode 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /tests/unit/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { shallowMount } from '@vue/test-utils'; 3 | import HelloWorld from '@/components/LayoutFooter.vue'; 4 | 5 | describe('HelloWorld.vue', () => { 6 | it('renders props.msg when passed', () => { 7 | const msg = 'new message'; 8 | const wrapper = shallowMount(HelloWorld, { 9 | props: { msg }, 10 | }); 11 | expect(wrapper.text()).to.include(msg); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import vue from '@vitejs/plugin-vue'; 3 | import path from 'path'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [vue()], 8 | resolve: { 9 | alias: { 10 | src: path.resolve(__dirname, 'src'), 11 | assert: require.resolve('assert/'), 12 | crypto: require.resolve('crypto-browserify'), 13 | http: require.resolve('stream-http'), 14 | https: require.resolve('https-browserify'), 15 | os: require.resolve('os-browserify/browser'), 16 | stream: require.resolve('stream-browserify'), 17 | }, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); // eslint-disable-line @typescript-eslint/no-var-requires 2 | 3 | module.exports = { 4 | configureWebpack: { 5 | resolve: { 6 | alias: { 7 | src: path.resolve(__dirname, 'src'), 8 | }, 9 | fallback: { 10 | assert: require.resolve('assert/'), 11 | crypto: require.resolve('crypto-browserify'), 12 | http: require.resolve('stream-http'), 13 | https: require.resolve('https-browserify'), 14 | os: require.resolve('os-browserify/browser'), 15 | stream: require.resolve('stream-browserify'), 16 | }, 17 | }, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const colors = require('tailwindcss/colors'); // eslint-disable-line @typescript-eslint/no-var-requires 2 | 3 | module.exports = { 4 | purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], 5 | darkMode: 'class', 6 | // Default theme: https://github.com/tailwindlabs/tailwindcss/blob/master/stubs/defaultConfig.stub.js#L7 7 | theme: { 8 | extend: { 9 | colors: { 10 | primary: colors.indigo, // primary theme color 11 | secondary: colors.yellow, // secondary theme color 12 | }, 13 | }, 14 | }, 15 | variants: { 16 | extend: {}, 17 | }, 18 | plugins: [require('nightwind')], 19 | }; 20 | -------------------------------------------------------------------------------- /src/shims.d.ts: -------------------------------------------------------------------------------- 1 | // Shim for Vue composition API 2 | declare module '*.vue' { 3 | import type { DefineComponent } from 'vue'; 4 | const component: DefineComponent, Record, any>; 5 | export default component; 6 | } 7 | 8 | // Shims for dependencies which don't support TypeScript, so we use these to avoid `Could not find a declaration 9 | // file for module 'moduleName'` errors 10 | declare module '@heroicons/*'; 11 | declare module 'nightwind/helper'; 12 | 13 | // Shims for environment variables 14 | interface ImportMeta { 15 | env: { 16 | VITE_BLOCKNATIVE_API_KEY: string; 17 | VITE_INFURA_API_KEY: string; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Vite App 9 | 10 | 11 | 16 |
17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "baseUrl": ".", 5 | "esModuleInterop": true, 6 | "importHelpers": true, 7 | "jsx": "preserve", 8 | "lib": ["esnext", "dom", "dom.iterable", "scripthost"], 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "paths": { "@/*": ["src/*"] }, 12 | "resolveJsonModule": true, 13 | "skipLibCheck": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "target": "esnext", 17 | "types": ["webpack-env", "mocha", "chai"] 18 | }, 19 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "tests/**/*.ts", "tests/**/*.tsx"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /tests/e2e/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /src/views/Error404.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/typescript/recommended', 10 | '@vue/prettier', 11 | '@vue/prettier/@typescript-eslint', 12 | ], 13 | parserOptions: { 14 | ecmaVersion: 2020, 15 | }, 16 | rules: { 17 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 18 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 19 | '@typescript-eslint/no-explicit-any': 'off', 20 | '@typescript-eslint/explicit-module-boundary-types': 'off', 21 | }, 22 | overrides: [ 23 | { 24 | files: ['**/__tests__/*.{j,t}s?(x)', '**/tests/unit/**/*.spec.{j,t}s?(x)'], 25 | env: { 26 | mocha: true, 27 | }, 28 | }, 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /tests/e2e/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /tests/e2e/plugins/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable arrow-body-style */ 2 | // https://docs.cypress.io/guides/guides/plugins-guide.html 3 | 4 | // if you need a custom webpack configuration you can uncomment the following import 5 | // and then use the `file:preprocessor` event 6 | // as explained in the cypress docs 7 | // https://docs.cypress.io/api/plugins/preprocessors-api.html#Examples 8 | 9 | // /* eslint-disable import/no-extraneous-dependencies, global-require */ 10 | // const webpack = require('@cypress/webpack-preprocessor') 11 | 12 | module.exports = (on, config) => { 13 | // on('file:preprocessor', webpack({ 14 | // webpackOptions: require('@vue/cli-service/webpack.config'), 15 | // watchOptions: {} 16 | // })) 17 | 18 | return Object.assign({}, config, { 19 | fixturesFolder: 'tests/e2e/fixtures', 20 | integrationFolder: 'tests/e2e/specs', 21 | screenshotsFolder: 'tests/e2e/screenshots', 22 | videosFolder: 'tests/e2e/videos', 23 | supportFile: 'tests/e2e/support/index.js', 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; 2 | import Home from '../views/Home.vue'; 3 | 4 | // For info on using Vue Router with the Composition API, see https://next.router.vuejs.org/guide/advanced/composition-api.html 5 | 6 | const routes: Array = [ 7 | { 8 | path: '/', 9 | name: 'Home', 10 | component: Home, 11 | }, 12 | { 13 | path: '/about', 14 | name: 'About', 15 | // route level code-splitting 16 | // this generates a separate chunk (about.[hash].js) for this route which is lazy-loaded when the route is visited. 17 | component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'), 18 | }, 19 | // Fallback route for handling 404s 20 | { path: '/:pathMatch(.*)*', name: '404', component: () => import('../views/Error404.vue') }, 21 | ]; 22 | 23 | const router = createRouter({ 24 | // If app is not hosted at the domain root, make sure to pass the `base` input here: https://next.router.vuejs.org/api/#parameters 25 | history: createWebHistory(), 26 | routes, 27 | }); 28 | 29 | export default router; 30 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 30 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* Use this directive to control where Tailwind injects the responsive variations of each utility */ 6 | /* If omitted, Tailwind will append these classes to the very end of your stylesheet by default */ 7 | @tailwind screens; 8 | 9 | /* Add styles to the base styles: https://tailwindcss.com/docs/adding-base-styles */ 10 | @layer base { 11 | #app-main { 12 | @apply text-gray-700; /* Default font color */ 13 | } 14 | } 15 | 16 | /* Add styles to components */ 17 | @layer components { 18 | .btn { 19 | @apply inline-block px-4 py-2 text-base font-medium border border-transparent rounded-md; 20 | } 21 | .btn-primary { 22 | @apply font-medium text-white bg-primary-500 hover:bg-opacity-75 focus:outline-none; 23 | } 24 | .btn-secondary { 25 | @apply font-medium text-primary-700 bg-primary-100 hover:bg-primary-200; 26 | } 27 | .btn-outline { 28 | @apply font-medium text-primary-600 border border-primary-600 hover:bg-primary-50; 29 | } 30 | .btn-flat { 31 | @apply font-medium text-primary-600 hover:bg-primary-50; 32 | } 33 | } 34 | 35 | /* Add styles to utilities */ 36 | @layer utilities { 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/ethers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @notice Contains all ethers imports used by the app. This helps track which ethers packages used since all imports 3 | * are in this file, and it removes noise from having a lot of import lines in other packages 4 | * @dev In some cases we use `export type` instead of `export`. This is used for types that are imported then 5 | * reexported, such as the following from @ethersproject/networks/src.ts/index.ts 6 | * 7 | * import type { Network, Networkish } from "./types"; 8 | * export { 9 | * Network, 10 | * Networkish 11 | * }; 12 | * 13 | * If we used `export` below, Vite would throw when building with an error such as `Uncaught SyntaxError: The requested 14 | * module '/node_modules/.vite/@ethersproject_networks.js?v=d77d69a4' does not provide an export named 'Network'`. 15 | * Read more at https://github.com/vitejs/vite/issues/731 16 | */ 17 | 18 | export { getAddress } from '@ethersproject/address'; 19 | export { BigNumber } from '@ethersproject/bignumber'; 20 | export { Contract } from '@ethersproject/contracts'; 21 | export type { Network } from '@ethersproject/networks'; 22 | export { JsonRpcProvider, JsonRpcSigner, Web3Provider } from '@ethersproject/providers'; 23 | export { commify, formatUnits } from '@ethersproject/units'; 24 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | // Default RPC URL when user does not have a wallet connected 2 | export const RPC_URL = `https://mainnet.infura.io/v3/${import.meta.env.VITE_INFURA_API_KEY}`; 3 | 4 | // Read data using Multicall2: https://github.com/makerdao/multicall 5 | export const MULTICALL_ADDRESS = '0x5BA1e12693Dc8F9c48aAD8770482f4739bEeD696'; // applies to mainnet, rinkeby, goerli, ropsten, kovan 6 | export const MULTICALL_ABI = [ 7 | 'function getCurrentBlockTimestamp() view returns (uint256 timestamp)', 8 | 'function aggregate(tuple(address target, bytes callData)[] calls) view returns (uint256 blockNumber, bytes[] returnData)', 9 | 'function getLastBlockHash() view returns (bytes32 blockHash)', 10 | 'function getEthBalance(address addr) view returns (uint256 balance)', 11 | 'function getCurrentBlockDifficulty() view returns (uint256 difficulty)', 12 | 'function getCurrentBlockGasLimit() view returns (uint256 gaslimit)', 13 | 'function getCurrentBlockCoinbase() view returns (address coinbase)', 14 | 'function getBlockHash(uint256 blockNumber) view returns (bytes32 blockHash)', 15 | 'function tryAggregate(bool requireSuccess, tuple(address target, bytes callData)[] calls) public view returns (tuple(bool success, bytes returnData)[] returnData)', 16 | 'function tryBlockAndAggregate(bool requireSuccess, tuple(address target, bytes callData)[] calls) public view returns (uint256 blockNumber, bytes32 blockHash, tuple(bool success, bytes returnData)[] returnData)', 17 | ]; 18 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 30 | 31 | 53 | -------------------------------------------------------------------------------- /src/store/settings.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @dev User settings are managed here and persisted with localStorage 3 | */ 4 | 5 | import { computed, ref } from 'vue'; 6 | import nightwind from 'nightwind/helper'; 7 | 8 | // Local storage key names 9 | const settings = { 10 | lastWallet: 'last-wallet', 11 | theme: 'nightwind-mode', // this is the localStorage key name used by nightwind 12 | }; 13 | 14 | // Helper methods to load save items from local storage 15 | const load = (key: string) => window.localStorage.getItem(key); 16 | const save = (key: string, value: any) => window.localStorage.setItem(key, value); 17 | 18 | // Shared state 19 | const lastWallet = ref(); // name of last wallet used 20 | const theme = ref(); // light or dark theme 21 | 22 | // Composition function for managing state 23 | export default function useSettingsStore() { 24 | async function initializeSettings() { 25 | // Initialize nightwind (used for dark mode) 26 | nightwind.init(); 27 | 28 | // Load settings 29 | lastWallet.value = load(settings.lastWallet) ? String(load(settings.lastWallet)) : undefined; 30 | theme.value = load(settings.theme) ? String(load(settings.theme)) : 'light'; 31 | if (theme.value === 'dark') toggleDarkMode(); // make sure to set app to dark mode when required 32 | } 33 | 34 | function setLastWallet(walletName: string) { 35 | save(settings.lastWallet, walletName); 36 | } 37 | 38 | function toggleDarkMode() { 39 | // Nightwind uses localStorage to save its state, so no need to manage it 40 | nightwind.toggle(); 41 | } 42 | 43 | return { 44 | initializeSettings, 45 | // Wallet 46 | setLastWallet, 47 | lastWallet: computed(() => lastWallet.value), 48 | // Theme 49 | isDark: computed(() => (theme.value === 'dark' ? true : false)), 50 | toggleDarkMode, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-tailwind-ethereum-template", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vue-tsc --noEmit && vite build", 8 | "serve": "vite preview", 9 | "test:unit": "vue-cli-service test:unit", 10 | "test:e2e": "vue-cli-service test:e2e", 11 | "lint": "eslint --ext .ts,.js,.vue .", 12 | "prettier": "prettier --config .prettierrc --write \"**/*.{js,json,md,sol,js,ts}\"" 13 | }, 14 | "dependencies": { 15 | "@headlessui/vue": "^1.2.0", 16 | "@heroicons/vue": "^1.0.1", 17 | "assert": "^2.0.0", 18 | "bnc-onboard": "^1.27.0", 19 | "crypto-browserify": "^3.12.0", 20 | "ethers": "^5.3.1", 21 | "https-browserify": "^1.0.0", 22 | "nightwind": "^1.1.11", 23 | "os-browserify": "^0.3.0", 24 | "stream-browserify": "^3.0.0", 25 | "stream-http": "^3.2.0", 26 | "tailwindcss": "^2.1.4", 27 | "vue": "^3.0.0", 28 | "vue-router": "^4.0.0-0" 29 | }, 30 | "devDependencies": { 31 | "@types/chai": "^4.2.11", 32 | "@types/mocha": "^8.2.2", 33 | "@typescript-eslint/eslint-plugin": "^4.26.1", 34 | "@typescript-eslint/parser": "^4.26.1", 35 | "@vitejs/plugin-vue": "^1.2.3", 36 | "@vue/cli-plugin-e2e-cypress": "^5.0.0-alpha.5", 37 | "@vue/cli-plugin-eslint": "^5.0.0-alpha.5", 38 | "@vue/cli-plugin-router": "^5.0.0-alpha.5", 39 | "@vue/cli-plugin-typescript": "^5.0.0-alpha.5", 40 | "@vue/cli-plugin-unit-mocha": "^5.0.0-alpha.5", 41 | "@vue/compiler-sfc": "^3.1.1", 42 | "@vue/eslint-config-prettier": "^6.0.0", 43 | "@vue/eslint-config-typescript": "^7.0.0", 44 | "@vue/test-utils": "^2.0.0-0", 45 | "autoprefixer": "^10.2.6", 46 | "chai": "^4.1.2", 47 | "eslint": "^7.28.0", 48 | "eslint-plugin-prettier": "^3.3.1", 49 | "eslint-plugin-vue": "^7.11.1", 50 | "lint-staged": "^11.0.0", 51 | "postcss": "^8.3.2", 52 | "prettier": "^2.3.1", 53 | "typescript": "~4.3.2", 54 | "vite": "^2.3.7", 55 | "vue-tsc": "^0.1.7" 56 | }, 57 | "simple-git-hooks": { 58 | "pre-commit": "yarn prettier" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/store/data.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @dev Poll on each block to read data 3 | */ 4 | 5 | import { ref } from 'vue'; 6 | import { BigNumber, Contract } from 'src/utils/ethers'; 7 | import useWalletStore from 'src/store/wallet'; 8 | import { MULTICALL_ADDRESS, MULTICALL_ABI } from 'src/utils/constants'; 9 | 10 | const { provider, userAddress } = useWalletStore(); 11 | 12 | // Most recent data read is saved as state 13 | const lastBlockNumber = ref(0); 14 | const lastBlockTimestamp = ref(0); 15 | const ethBalance = ref(); 16 | 17 | export default function useDataStore() { 18 | async function poll(multicall: Contract) { 19 | // Don't poll if user has not connected wallet 20 | if (!userAddress.value) return; 21 | 22 | // Define calls to be read using multicall 23 | const calls = [ 24 | { target: MULTICALL_ADDRESS, callData: multicall.interface.encodeFunctionData('getCurrentBlockTimestamp') }, 25 | { target: MULTICALL_ADDRESS, callData: multicall.interface.encodeFunctionData('getEthBalance', [userAddress.value]) }, // prettier-ignore 26 | ]; 27 | 28 | // Execute calls 29 | const { blockNumber, returnData } = await multicall.tryBlockAndAggregate(false, calls); 30 | 31 | // Parse return data 32 | const [timestampEncoded, ethBalanceEncoded] = returnData; 33 | const { timestamp } = multicall.interface.decodeFunctionResult('getCurrentBlockTimestamp', timestampEncoded.returnData); // prettier-ignore 34 | const { balance } = multicall.interface.decodeFunctionResult('getEthBalance', ethBalanceEncoded.returnData); // prettier-ignore 35 | 36 | // Save off data 37 | lastBlockNumber.value = (blockNumber as BigNumber).toNumber(); 38 | lastBlockTimestamp.value = (timestamp as BigNumber).toNumber(); 39 | ethBalance.value = (balance as BigNumber).toBigInt(); 40 | } 41 | 42 | // Call this method to poll now, then poll on each new block 43 | function startPolling() { 44 | const multicall = new Contract(MULTICALL_ADDRESS, MULTICALL_ABI, provider.value); 45 | provider.value.on('block', (/* block: number */) => void poll(multicall)); 46 | } 47 | 48 | return { 49 | // Methods 50 | startPolling, 51 | // Data 52 | lastBlockNumber, 53 | lastBlockTimestamp, 54 | ethBalance, 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ethereum App Template 2 | 3 | Ethereum frontend app template with the following features: 4 | 5 | - [Vue 3](https://v3.vuejs.org/) as the foundation 6 | - [Tailwind CSS](https://tailwindcss.com) for styling 7 | - [Nightwind](https://github.com/jjranalli/nightwind) for easy dark mode support 8 | - [Ethers](https://docs.ethers.io/v5/single-page/) for interacting with Ethereum 9 | - [Vite](https://vitejs.dev/) for 10x-100x faster builds 10 | - [Onboard](https://docs.blocknative.com/onboard) for connecting wallets 11 | - [Multicall2](https://github.com/makerdao/multicall) for polling for data each block 12 | 13 | ## Setup 14 | 15 | ```sh 16 | # Install packages 17 | yarn install 18 | 19 | # Run in development mode 20 | yarn dev 21 | 22 | # Compiles and minifies for production 23 | yarn build 24 | 25 | # Format files 26 | yarn prettier 27 | 28 | # Run linter 29 | yarn lint 30 | 31 | ### Run your unit tests and end-to-end tests (not yet setup) 32 | yarn test:unit 33 | yarn test:e2e 34 | ``` 35 | 36 | ## Notes / Customization 37 | 38 | Notes on customizing this app: 39 | 40 | - Primary and secondary theme colors are defined in `tailwind.config.js`. Other colors are inlined as classes, e.g. `text-gray-400`. 41 | - Dark mode is handled with [Nightwind](https://github.com/jjranalli/nightwind), which is a Tailwind CSS plugin that generates a dark theme by automatically inverting color classes. The resulting dark mode will not look as a good as a fully customized/hand-crafted dark mode, but this is much less work to implement, and Nightwind does offer some control over the output 42 | - Vite does not use `process.env.MY_VARIABLE` for environment variables, but instead uses `import.meta.env.VITE_MY_VARIABLE`. Values in `.env` that are prefixed with `VITE_` are automatically included. Update the type definitions in `src/shims.d.ts` for any new environment variables 43 | - The Vue router is configured to use `history` mode and assumes the app is hosted at the domain root. Both of these defaults can be changed in `src/router/index.ts` 44 | - Blocknative's [onboard.js](https://docs.blocknative.com/onboard) is used for connecting wallets. Like Vue 3, Vite does not automatically polyfill defaults like `os`, `http`, and `crypto` that are needed by onboard.js, so we `require` this in `vite.config.ts` 45 | - The store modules live in `src/store`, and there are three setup by default 46 | - `wallet.ts` manages the user's wallet connection 47 | - `data.ts` atomically polls for data each block using `Multicall2` 48 | - `settings.ts` saves and manages user settings such as dark mode and wallet selection 49 | -------------------------------------------------------------------------------- /src/components/LayoutHeader.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 69 | -------------------------------------------------------------------------------- /src/components/LayoutFooter.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 83 | -------------------------------------------------------------------------------- /src/store/wallet.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @dev Information about the user's wallet, network, etc. are stored and managed here 3 | * 4 | * @dev State is handled in reusable components, where each component is its own self-contained file consisting of 5 | * one function defined used the composition API. Since we want the wallet state to be shared between all instances 6 | * when this file is imported, we defined state outside of the function definition. 7 | * 8 | * @dev When assigning ethers objects as refs, we must wrap the object in `markRaw` for assignment. This is not required 9 | * with Vue 2's reactivity system based on Object.defineProperty, but is required for Vue 3's reactivity system based 10 | * on ES6 proxies. The Vue 3 reactivity system does not work well with non-configurable, non-writable properties on 11 | * objects, and many ethers classes, such as providers and networks, use non-configurable or non-writable properties. 12 | * Therefore we wrap the object in `markRaw` to prevent it from being converted to a Proxy. If you do not do this, 13 | * you'll see errors like this when using ethers objects as refs: 14 | * Uncaught (in promise) TypeError: 'get' on proxy: property '_network' is a read-only and non-configurable data 15 | * property on the proxy target but the proxy did not return its actual value (expected '#' but got 16 | * '[object Object]') 17 | * Read more here: 18 | * - https://stackoverflow.com/questions/65693108/threejs-component-working-in-vuejs-2-but-not-3 19 | * - https://github.com/vuejs/vue-next/issues/3024 20 | * - https://v3.vuejs.org/api/basic-reactivity.html#markraw 21 | */ 22 | 23 | import { computed, ref, markRaw } from 'vue'; 24 | import useDataStore from 'src/store/data'; 25 | import useSettingsStore from 'src/store/settings'; 26 | import { JsonRpcProvider, JsonRpcSigner, Network, Web3Provider } from 'src/utils/ethers'; 27 | import { formatAddress } from 'src/utils/formatters'; 28 | import Onboard from 'bnc-onboard'; 29 | import { API as OnboardAPI } from 'bnc-onboard/dist/src/interfaces'; 30 | import { getAddress } from 'src/utils/ethers'; 31 | import { RPC_URL } from 'src/utils/constants'; 32 | 33 | const { startPolling } = useDataStore(); 34 | const { setLastWallet } = useSettingsStore(); 35 | const defaultProvider = new JsonRpcProvider(RPC_URL); 36 | 37 | // State variables 38 | let onboard: OnboardAPI; // instance of Blocknative's onboard.js library 39 | const supportedChainIds = [1, 4]; // chain IDs supported by this app 40 | const rawProvider = ref(); // raw provider from the user's wallet, e.g. EIP-1193 provider 41 | const provider = ref(defaultProvider); // ethers provider 42 | const signer = ref(); // ethers signer 43 | const userAddress = ref(); // user's wallet address 44 | const userEns = ref(); // user's ENS name 45 | const network = ref(); // connected network, derived from provider 46 | 47 | // Reset state when, e.g.user switches wallets. Provider/signer are automatically updated by ethers so are not cleared 48 | function resetState() { 49 | userAddress.value = undefined; 50 | network.value = undefined; 51 | } 52 | 53 | // Settings 54 | const infuraApiKey = import.meta.env.VITE_INFURA_API_KEY; 55 | const walletChecks = [{ checkName: 'connect' }]; 56 | const wallets = [ 57 | { walletName: 'metamask', preferred: true }, 58 | { walletName: 'walletConnect', infuraKey: infuraApiKey, preferred: true }, 59 | { walletName: 'torus', preferred: true }, 60 | { walletName: 'ledger', rpcUrl: RPC_URL, preferred: true }, 61 | { walletName: 'lattice', rpcUrl: RPC_URL, appName: 'Umbra' }, 62 | ]; 63 | 64 | export default function useWalletStore() { 65 | // ------------------------------------------------ Wallet Connection ------------------------------------------------ 66 | /** 67 | * @notice Initialize the onboard.js module 68 | */ 69 | function initializeOnboard() { 70 | onboard = Onboard({ 71 | dappId: import.meta.env.VITE_BLOCKNATIVE_API_KEY, 72 | darkMode: false, 73 | networkId: 1, 74 | walletSelect: { wallets }, 75 | walletCheck: walletChecks, 76 | subscriptions: { 77 | // On wallet connection, save wallet in local storage and set provider 78 | wallet: (wallet) => { 79 | setProvider(wallet.provider); 80 | if (wallet.name) setLastWallet(wallet.name); 81 | }, 82 | // On address or network change, re-run configureProvider 83 | address: async (address) => { 84 | if (userAddress.value && userAddress.value !== getAddress(address)) await configureProvider(); 85 | }, 86 | network: async (chainId) => { 87 | if (network.value?.chainId && network.value.chainId !== chainId) await configureProvider(); 88 | }, 89 | }, 90 | }); 91 | } 92 | 93 | /** 94 | * @notice Prompt user to connect wallet, or attempt to connect to wallet specified by `name` 95 | * @param name Wallet name to connect, or undefined to prompt user to select a wallet 96 | */ 97 | async function connectWallet(name: string | undefined | MouseEvent = undefined) { 98 | // If user already connected wallet, return 99 | if (userAddress.value) return; 100 | 101 | // If input type is MouseEvent, this method was ran from clicking a DOM element, so set name to undefined 102 | if (name && typeof name !== 'string' && 'pageX' in name) name = undefined; 103 | 104 | // Otherwise, prompt them for connection / wallet change 105 | if (!onboard) initializeOnboard(); // instantiate Onboard instance 106 | onboard.walletReset(); // clear existing wallet selection 107 | await onboard.walletSelect(name); // wait for user to select wallet 108 | await onboard.walletCheck(); // run any specified checks 109 | await configureProvider(); // load info based on user's address 110 | } 111 | 112 | // ----------------------------------------------------- Actions ----------------------------------------------------- 113 | 114 | // When user connects their wallet, we call this method to update the provider 115 | function setProvider(p: any) { 116 | rawProvider.value = p; 117 | } 118 | 119 | // Any actions or data to fetch dependent on user's wallet are done here 120 | async function configureProvider() { 121 | // Set network/wallet properties 122 | if (!rawProvider.value) return; 123 | const _provider = new Web3Provider(rawProvider.value); 124 | const _signer = _provider.getSigner(); 125 | 126 | // Get user and network information 127 | const [_userAddress, _network] = await Promise.all([ 128 | _signer.getAddress(), // get user's address 129 | _provider.getNetwork(), // get information on the connected network 130 | ]); 131 | 132 | // If nothing has changed, no need to continue configuring 133 | if (_userAddress === userAddress.value && _network.chainId === network.value?.chainId) return; 134 | 135 | // Clear state 136 | resetState(); 137 | 138 | // Exit if not a valid network 139 | const chainId = _provider.network.chainId; // must be done after the .getNetwork() call 140 | if (!supportedChainIds.includes(chainId)) { 141 | network.value = markRaw(_network); // save network for checking if this is a supported network 142 | return; 143 | } 144 | 145 | // Get ENS name 146 | const _userEns = await _provider.lookupAddress(_userAddress); 147 | 148 | // Now we save the user's info to the store. We don't do this earlier because the UI is reactive based on these 149 | // parameters, and we want to ensure this method completed successfully before updating the UI 150 | provider.value = markRaw(_provider); 151 | signer.value = _signer; 152 | userAddress.value = _userAddress; 153 | userEns.value = _userEns; 154 | network.value = markRaw(_network); 155 | 156 | // Start polling for data 157 | startPolling(); 158 | } 159 | 160 | // ---------------------------------------------------- Exports ---------------------------------------------------- 161 | // Define parts of the store to expose. Only expose computed properties or methods to avoid direct mutation of state 162 | return { 163 | // Methods 164 | configureProvider, 165 | connectWallet, 166 | setProvider, 167 | // Properties 168 | isSupportedNetwork: computed(() => (network.value ? supportedChainIds.includes(network.value.chainId) : true)), // assume valid if we have no network information 169 | network: computed(() => network.value), 170 | provider: computed(() => provider.value), 171 | signer: computed(() => signer.value), 172 | userAddress: computed(() => userAddress.value), 173 | userDisplayName: computed(() => userEns.value || formatAddress(userAddress.value || '')), 174 | }; 175 | } 176 | --------------------------------------------------------------------------------