10 |
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 |
2 |
Hello {{ userDisplayName }}
3 |
4 |
5 |
Block number: {{ blockNumber }}
6 |
Date: {{ date }}
7 |
Balance: {{ balance }} ETH
8 |
9 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
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 |
2 |
3 |
44 |
45 |
46 |
47 |
69 |
--------------------------------------------------------------------------------
/src/components/LayoutFooter.vue:
--------------------------------------------------------------------------------
1 |
2 |
23 |
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 '#