├── .env ├── .env.local.example ├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── LICENSE.md ├── README.md ├── abi ├── dummyToken.abi.json └── stakingVault.abi.json ├── contracts ├── DummyToken.sol └── StakingVault.sol ├── hardhat.config.js ├── index.html ├── justfile ├── netlify.toml ├── package-lock.json ├── package.json ├── public ├── favicon.svg └── opn.png ├── scripts ├── deployStaking.js └── deployToken.js ├── src ├── App.jsx ├── components │ ├── dummyToken.jsx │ └── staking.jsx ├── main.jsx └── web3.js └── vite.config.js /.env: -------------------------------------------------------------------------------- 1 | VITE_DUMMY_TOKEN_ADDRESS="0xAf400297EaBb34dA11bBE3AD51DD1f3a6C4BbcCD" 2 | VITE_STAKING_ADDRESS="0x34f66A45f5773A8aF8251f418ddD1272BDa831BD" 3 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | BSC_SCAN_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 2 | GETBLOCKIO_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 3 | PRIVATE_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:react/recommended", "prettier"], 7 | "overrides": [], 8 | "parserOptions": { 9 | "ecmaFeatures": { 10 | "jsx": true 11 | }, 12 | "ecmaVersion": "latest", 13 | "sourceType": "module" 14 | }, 15 | "settings": { 16 | "react": { 17 | "version": "detect" 18 | } 19 | }, 20 | "plugins": ["react", "prettier"], 21 | "rules": { "prettier/prettier": ["error"], "react/prop-types": 0 } 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Hardhat files 27 | cache 28 | artifacts 29 | 30 | # Local Netlify folder 31 | .netlify 32 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "arrowParens": "avoid", 5 | "semi": true 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | ## Copyright (c) 2022 Atahan Yorgancı 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dApp Development with React Workshop 2 | 3 | ![JavaScript](https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black) 4 | ![React.js](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB) 5 | ![OpenZeppelin](https://img.shields.io/badge/OpenZeppelin-4E5EE4?logo=openzeppelin&logoColor=fff&style=for-the-badge) 6 | [![Netlify Deployment](https://img.shields.io/badge/Netlify-00C7B7?style=for-the-badge&logo=netlify&logoColor=white)](https://ata-token.netlify.app) 7 | 8 | In this workshop we will be building a DeFi application with a custom ERC20 token and staking vault using [vite][vite] to bundle a [React.js][react] application and [ethers][ethers] library to connect to the blockchain. You can checkout the finished project [here][production]. The project is deployed on [Avalanche Fuji Testnet][fuji], you can receive funds from testnet's faucet [here][faucet]. 9 | 10 | ## Initialize React Application with [`vite`][vite] 11 | 12 | [`vite`][vite] next generation tooling for building frontend applications. Get started with [`vite`][vite] by running following command. 13 | 14 | ```bash 15 | npm create vite@latest 16 | ``` 17 | 18 | We can run the development server using `npm run dev` and local server will start will start with automatic reloads! 19 | 20 | > You can remove unnecessary markup and CSS that `vite` creates 21 | 22 | ## Connecting to MetaMask 23 | 24 | MetaMask adds a global object `ethereum` that can be used to interact with MetaMask. This object can be accessed by `window.ethereum`. 25 | 26 | We can send requests to MetaMask using `window.ethereum.request()` method, we ask the user to connect their account by sending the `eth_requestAccounts` request. Details of this request can be found in [MetaMask's documentation](https://docs.metamask.io/guide/rpc-api.html#eth-requestaccounts). 27 | 28 | ```js 29 | const [account] = await window.ethereum.request({ 30 | method: "eth_requestAccounts", 31 | }); 32 | ``` 33 | 34 | We can use `useEffect` and `useState` hooks to initialize `account` state when the component mounts. 35 | 36 | > Providing `[]` (empty array) to dependency section of `useEffect(func, [])` 37 | > runs `func` only once when the component mounts! 38 | 39 | ```js 40 | const requestAccounts = async () => { 41 | if (!window.ethereum) { 42 | return null; 43 | } 44 | const [account] = await window.ethereum.request({ 45 | method: "eth_requestAccounts", 46 | }); 47 | return account; 48 | }; 49 | 50 | function App() { 51 | const [account, setAccount] = useState(null); 52 | 53 | useEffect(() => { 54 | requestAccounts().then(setAccount).catch(console.error); 55 | }, []); 56 | 57 | //... 58 | } 59 | ``` 60 | 61 | How ever there are two problems here! First the user gets prompted before they can see the app and if they change their account the app doesn't update! 62 | 63 | ### Listening to Account Changes 64 | 65 | `ethereum` object is an `EventEmitter` so we can listen to `accountsChanged` event when we initialize our application. 66 | 67 | ```js 68 | window.ethereum.on("accountsChanged", accounts => { 69 | setAccount(accounts[0]); // set new account state 70 | }); 71 | ``` 72 | 73 | ### Accessing Accounts 74 | 75 | There is an alternative method called `eth_accounts` that query MetaMask if the user has already connected their account to our application. If user has already connected their account MetaMask will simply return the account without prompting. 76 | 77 | ```js 78 | const [account] = await window.ethereum.request({ 79 | method: "eth_accounts", 80 | }); 81 | ``` 82 | 83 | ## Interacting with EVM using [`ethers`][ethers] 84 | 85 | We will be using [`ethers`][ethers] to interact with the blockchain. `ethers` can be installed with `npm` simply by running following command in the terminal. 86 | 87 | ```bash 88 | npm i ethers 89 | ``` 90 | 91 | `ethers` library includes multiple types of providers for accessing onchain data. These include popular providers like [`InfuraProvider`](https://docs.ethers.io/v5/api/providers/api-providers/#InfuraProvider) (a popular JSON-RPC endpoint provider, [website](https://infura.io/)), generic providers such as [`JsonRpcProvider`](https://docs.ethers.io/v5/api/providers/api-providers/#InfuraProvider) and [`Web3Provider`](https://docs.ethers.io/v5/api/providers/other/#Web3Provider) which connects using MetaMask. 92 | 93 | We can initialize our provider with global `ethereum` object as follows. 94 | 95 | ```js 96 | import { ethers } from "ethers"; 97 | 98 | // ... 99 | 100 | const provider = new ethers.providers.Web3Provider(window.ethereum); 101 | ``` 102 | 103 | Having initialized our provider we can now access chain data! Let's start by building a `Balance` React component that display's chain's default coin in this case AVAX. `Balance` component receives `account` and `provider` as props and computes the balance and displays it. 104 | 105 | ```jsx 106 | const Balance = ({ provider, account }) => { 107 | const [balance, setBalance] = useState(""); 108 | 109 | useEffect(() => { 110 | const getBalance = async () => { 111 | const balance = await provider.getBalance(account); 112 | return ethers.utils.formatEther(balance); 113 | }; 114 | getBalance().then(setBalance).catch(console.error); 115 | }, [account, provider]); 116 | 117 | if (!balance) { 118 | return

Loading...

; 119 | } 120 | return

Balance: {balance} AVAX

; 121 | }; 122 | ``` 123 | 124 | We derive our balance state from `account` and `provider` using `useEffect` hook. If the user changes their account their balance is recalculated. We use `provider.getBalance(account)` function to access user's AVAX balance and convert it to string using `formatEther` function. 125 | 126 | > In EVM balance of a ERC20 token is stored as a unsigned 256-bit integer. However, JavaScript `Number` type is a [double-precision 64-bit binary format IEEE 754][float] so balance of an account can be larger than JavaScript's numbers allow. `ethers` library represents these numbers as `BigNumber` type and `formatEther` utility function can be used to convert `BigNumber` to `String`. 127 | 128 | ## Balance of Custom ERC20 Token 129 | 130 | What is web3 without custom tokens? Let's bring our project's ERC20 token into our application. To do this, we will be using `ethers.Contract`. This class can be used to instantiate custom EVM contracts from their address and ABI. 131 | 132 | > `ethers.Contract` is a meta class under the hood, meaning it's a class that creates classes not instances! `Contract` class receives ABI and constructs a new class that has ABI's exported properties. 133 | 134 | For our purposes ERC20 token called 'DummyToken (DT)' has deployed to Avalanche Fuji testnet at `0x5E8F49F4062d3a163cED98261396821ae2996596`. We can use [SnowTrace block explorer][snowtrace] to inspect contract's methods and ABI, token contract on explorer can be found [here](https://testnet.snowtrace.io/address/0x5E8F49F4062d3a163cED98261396821ae2996596). We can import ABI as a regular JSON file and initialize contract! 135 | 136 | ```js 137 | import DummyTokenABI from "../../abi/dummyToken.abi.json"; // Path to ABI's JSON file 138 | 139 | const DUMMY_TOKEN_ADDRESS = "0x5E8F49F4062d3a163cED98261396821ae2996596"; 140 | const DUMMY_TOKEN = new ethers.Contract(DUMMY_TOKEN_ADDRESS, DummyTokenABI); 141 | ``` 142 | 143 | Then, we can read token balance using `balanceOf(address)` method similar to AVAX balance. 144 | 145 | ```js 146 | useEffect(() => { 147 | const getBalance = async () => { 148 | const dummyToken = DUMMY_TOKEN.connect(provider); 149 | const balance = await dummyToken.balanceOf(account); 150 | return ethers.utils.formatEther(balance); 151 | }; 152 | getBalance().then(setBalance).catch(console.error); 153 | }, [provider, account]); 154 | ``` 155 | 156 | As expected DummyToken balance turns out to be 0. Fortunately, DummyToken contract exports a function to obtain some tokens. 157 | 158 | ## Claiming DummyToken 159 | 160 | We can check if an account has claimed using a similar function `hasClaimed()` function and we can modify `useEffect` to check when the `AtaBalance` mounts. 161 | 162 | ```js 163 | const getBalanceAndClaimed = async () => { 164 | const dummyToken = DUMMY_TOKEN.connect(provider); 165 | const [balance, claimed] = await Promise.all([ 166 | dummyToken.balanceOf(account), 167 | dummyToken.hasClaimed(), 168 | ]); 169 | return [ethers.utils.formatEther(balance), claimed]; 170 | }; 171 | ``` 172 | 173 | > `Promise.all([awaitable1, awaitable2, ...])` can be used to await multiple async calls at the same time and receive resolved promises in order awaitables' order. 174 | 175 | If the user hasn't claimed we can a render a button that when pressed will invoke `claim()` method on the contract. Since, we are modifying state of blockchain it's not enough for us to use a [`Provider`][provider] as they provide a **readonly** view of blockchain. We will be using [`Signer`][signer] which can used to send transactions. 176 | 177 | ```js 178 | const claim = async () => { 179 | const signer = provider.getSigner(); 180 | const dummyToken = DUMMY_TOKEN.connect(signer); 181 | 182 | const tx = await dummyToken.claim(); 183 | await tx.wait(); 184 | }; 185 | ``` 186 | 187 | If we refresh the page we can see our funds arrive! However, it isn't such a good user experience if they have to refresh the page every time they make transaction. With some refactoring we can solve this issue. 188 | 189 | ```js 190 | const getBalanceAndClaimed = async (account, provider) => { 191 | const dummyToken = DUMMY_TOKEN.connect(provider); 192 | const [balance, claimed] = await Promise.all([ 193 | dummyToken.balanceOf(account), 194 | dummyToken.hasClaimed(account), 195 | ]); 196 | return [ethers.utils.formatEther(balance), claimed]; 197 | }; 198 | 199 | const DummyToken = ({ account, provider }) => { 200 | // `DummyToken` component state 201 | 202 | const claim = async () => { 203 | // ... 204 | await tx.wait(); 205 | 206 | getBalanceAndClaimed(account, provider) 207 | .then(/* set balance and claimed */) 208 | .catch(); 209 | }; 210 | 211 | useEffect(() => { 212 | getBalanceAndClaimed(account, provider) 213 | .then(/* set balance and claimed */) 214 | .catch(); 215 | }, [provider, account]); 216 | 217 | // ... 218 | }; 219 | ``` 220 | 221 | ## Adding DummyToken to MetaMask 222 | 223 | Even tough, users can claim their tokens, DummyToken doesn't show up in MetaMask wallet. We can remedy this situation by sending `wallet_watchAsset` request through global `ethereum` object. We provide address of the token, symbol, decimals and lastly image for MetaMask to use. 224 | 225 | ```js 226 | const addDummyTokenToMetaMask = async () => { 227 | if (!window.ethereum) { 228 | return false; 229 | } 230 | try { 231 | const added = await window.ethereum.request({ 232 | method: "wallet_watchAsset", 233 | params: { 234 | type: "ERC20", 235 | options: { 236 | address: DUMMY_TOKEN_ADDRESS, 237 | symbol: "DT", 238 | decimals: 18, 239 | image: "https://ata-token.netlify.app/opn.png", 240 | }, 241 | }, 242 | }); 243 | return added; 244 | } catch (error) { 245 | return false; 246 | } 247 | }; 248 | ``` 249 | 250 | ## Integrating Staking Contract 251 | 252 | ERC20 allocation staking is one of most common practices in web3 launchpads and DeFi applications. Usually, users lock some amount of funds into smart contract and receive certain amount of rewards funds in return as interest. In case of launchpads like [OpenPad][openpad] in addition to receiving interest users are able to invest in launchpad project. 253 | 254 | Lastly, for our application we will integrating a staking contact. DummyToken staking contract is deployed at `0xAC1BdE0464D932bf1097A9492dCa8c3144194890` and we can inspect the contract code and ABI [here](https://testnet.snowtrace.io/address/0xAC1BdE0464D932bf1097A9492dCa8c3144194890#code). 255 | 256 | Staking contract exports stake and reward token amount for a given address and also total staked token amounts. We can read these values like any other contract value using `stakedOf()`, `rewardOf()` and `totalStaked()` respectively. 257 | 258 | ```js 259 | const getStakingViews = async (account, provider) => { 260 | const signer = provider.getSigner(account); 261 | const staking = STAKING_CONTRACT.connect(signer); 262 | const [staked, reward, totalStaked] = await Promise.all([ 263 | staking.stakedOf(account), 264 | staking.rewardOf(account), 265 | staking.totalStaked(), 266 | ]); 267 | return { 268 | staked: ethers.utils.formatEther(staked), 269 | reward: ethers.utils.formatEther(reward), 270 | totalStaked: ethers.utils.formatEther(totalStaked), 271 | }; 272 | }; 273 | ``` 274 | 275 | ### Staking and Withdrawing Funds 276 | 277 | Users can stake their tokens using `stake(uint256 amount)` function and withdraw their locked funds using `withdraw(uint256 amount)` function. Most important of them all they can claim rewards using `claimReward()` function. Since these functions modify state of the contract we have to use a [`Signer`][signer]. 278 | 279 | We can write a simple form for user to fill out while staking and fire off relevant contract function when the form is submitted. 280 | 281 | ```js 282 | const Staking = ({ account, provider }) => { 283 | // ... 284 | const [stake, setStake] = useState(""); 285 | 286 | const handleStake = async event => { 287 | event.preventDefault(); // prevent page refresh when form is submitted 288 | const signer = provider.getSigner(account); 289 | const staking = STAKING_CONTRACT.connect(signer); 290 | 291 | const tx = await staking.stake(ethers.utils.parseEther(stake), { 292 | gasLimit: 1_000_000, 293 | }); 294 | await tx.wait(); 295 | }; 296 | // ... 297 | return ( 298 |
299 | {/* ... */} 300 |
301 | 302 | setStake(e.target.value)} 307 | /> 308 | 311 |
312 | {/* ... */} 313 |
314 | ); 315 | }; 316 | ``` 317 | 318 | Withdrawing funds from contract can be implemented similarly. However, if we try staking our tokens the contract will throw out an error! This is due to fact that we are not transferring native currency of the chain. While transferring ERC20 tokens into a contract we have **approve** a certain amount of **allowance** for that contract to use. 319 | 320 | ### Allowance and Approval 321 | 322 | We can check if for allowance of a smart contract -_spender_- from an address -_owner_- on ERC20 contract using `allowance(owner, spender)` view function. If allowance is less than amount we want stake, we have to increase the allowance by signing `approve(spender, amount)` message. 323 | 324 | ```js 325 | const handleStake = async event => { 326 | const signer = provider.getSigner(account); 327 | const amount = ethers.utils.parseEther(stake); 328 | 329 | const dummyToken = DUMMY_TOKEN.connect(signer); 330 | const allowance = await dummyToken.allowance( 331 | account, 332 | STAKING_CONTRACT.address 333 | ); 334 | if (allowance.lt(amount)) { 335 | const tx = await dummyToken.approve(STAKING_CONTRACT.address, amount); 336 | await tx.wait(); 337 | } 338 | // ... 339 | }; 340 | ``` 341 | 342 | Voila! With allowance out of our way, we are free to stake and withdraw funds as we like. 343 | 344 | ### Claiming Rewards 345 | 346 | What is DeFi without rewards? Let's finish off our application by allowing users to claim their rewards. This is simple task since we aren't spending ERC20 tokens we don't have to deal with the allowance. The user only has to sign `claimRewards()` function and we are done! 347 | 348 | ```js 349 | const handleClaimReward = async () => { 350 | const signer = provider.getSigner(account); 351 | const staking = STAKING_CONTRACT.connect(signer); 352 | 353 | const tx = await staking.claimReward({ 354 | gasLimit: 1_000_000, 355 | }); 356 | await tx.wait(); 357 | }; 358 | ``` 359 | 360 | ## Next Steps 361 | 362 | - Add [TypeScript](https://www.typescriptlang.org/) support for large DeFi applications 363 | - Add [`@tanstack/react-query`](https://tanstack.com/query/v4/) for async state management 364 | - More smart contracts! Mint NFTs with ERC721? 365 | 366 | [react]: https://reactjs.org 367 | [vite]: https://vitejs.dev/ 368 | [ethers]: https://github.com/ethers-io/ethers.js 369 | [float]: https://en.wikipedia.org/wiki/Floating-point_arithmetic 370 | [snowtrace]: https://snowtrace.io 371 | [provider]: https://docs.ethers.io/v5/api/providers/provider/ 372 | [signer]: https://docs.ethers.io/v5/api/signer/#Signer 373 | [openpad]: https://openpad.app 374 | [production]: https://ata-token.netlify.app 375 | [fuji]: https://docs.avax.network/quickstart/fuji-workflow 376 | [faucet]: https://faucet.avax.network/ 377 | -------------------------------------------------------------------------------- /abi/dummyToken.abi.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "inputs": [], 4 | "stateMutability": "nonpayable", 5 | "type": "constructor" 6 | }, 7 | { 8 | "anonymous": false, 9 | "inputs": [ 10 | { 11 | "indexed": true, 12 | "internalType": "address", 13 | "name": "owner", 14 | "type": "address" 15 | }, 16 | { 17 | "indexed": true, 18 | "internalType": "address", 19 | "name": "spender", 20 | "type": "address" 21 | }, 22 | { 23 | "indexed": false, 24 | "internalType": "uint256", 25 | "name": "value", 26 | "type": "uint256" 27 | } 28 | ], 29 | "name": "Approval", 30 | "type": "event" 31 | }, 32 | { 33 | "anonymous": false, 34 | "inputs": [ 35 | { 36 | "indexed": true, 37 | "internalType": "address", 38 | "name": "previousOwner", 39 | "type": "address" 40 | }, 41 | { 42 | "indexed": true, 43 | "internalType": "address", 44 | "name": "newOwner", 45 | "type": "address" 46 | } 47 | ], 48 | "name": "OwnershipTransferred", 49 | "type": "event" 50 | }, 51 | { 52 | "anonymous": false, 53 | "inputs": [ 54 | { 55 | "indexed": true, 56 | "internalType": "address", 57 | "name": "from", 58 | "type": "address" 59 | }, 60 | { 61 | "indexed": true, 62 | "internalType": "address", 63 | "name": "to", 64 | "type": "address" 65 | }, 66 | { 67 | "indexed": false, 68 | "internalType": "uint256", 69 | "name": "value", 70 | "type": "uint256" 71 | } 72 | ], 73 | "name": "Transfer", 74 | "type": "event" 75 | }, 76 | { 77 | "inputs": [ 78 | { 79 | "internalType": "address", 80 | "name": "owner", 81 | "type": "address" 82 | }, 83 | { 84 | "internalType": "address", 85 | "name": "spender", 86 | "type": "address" 87 | } 88 | ], 89 | "name": "allowance", 90 | "outputs": [ 91 | { 92 | "internalType": "uint256", 93 | "name": "", 94 | "type": "uint256" 95 | } 96 | ], 97 | "stateMutability": "view", 98 | "type": "function" 99 | }, 100 | { 101 | "inputs": [ 102 | { 103 | "internalType": "address", 104 | "name": "spender", 105 | "type": "address" 106 | }, 107 | { 108 | "internalType": "uint256", 109 | "name": "amount", 110 | "type": "uint256" 111 | } 112 | ], 113 | "name": "approve", 114 | "outputs": [ 115 | { 116 | "internalType": "bool", 117 | "name": "", 118 | "type": "bool" 119 | } 120 | ], 121 | "stateMutability": "nonpayable", 122 | "type": "function" 123 | }, 124 | { 125 | "inputs": [ 126 | { 127 | "internalType": "address", 128 | "name": "account", 129 | "type": "address" 130 | } 131 | ], 132 | "name": "balanceOf", 133 | "outputs": [ 134 | { 135 | "internalType": "uint256", 136 | "name": "", 137 | "type": "uint256" 138 | } 139 | ], 140 | "stateMutability": "view", 141 | "type": "function" 142 | }, 143 | { 144 | "inputs": [], 145 | "name": "claim", 146 | "outputs": [], 147 | "stateMutability": "nonpayable", 148 | "type": "function" 149 | }, 150 | { 151 | "inputs": [], 152 | "name": "decimals", 153 | "outputs": [ 154 | { 155 | "internalType": "uint8", 156 | "name": "", 157 | "type": "uint8" 158 | } 159 | ], 160 | "stateMutability": "view", 161 | "type": "function" 162 | }, 163 | { 164 | "inputs": [ 165 | { 166 | "internalType": "address", 167 | "name": "spender", 168 | "type": "address" 169 | }, 170 | { 171 | "internalType": "uint256", 172 | "name": "subtractedValue", 173 | "type": "uint256" 174 | } 175 | ], 176 | "name": "decreaseAllowance", 177 | "outputs": [ 178 | { 179 | "internalType": "bool", 180 | "name": "", 181 | "type": "bool" 182 | } 183 | ], 184 | "stateMutability": "nonpayable", 185 | "type": "function" 186 | }, 187 | { 188 | "inputs": [ 189 | { 190 | "internalType": "address", 191 | "name": "_account", 192 | "type": "address" 193 | } 194 | ], 195 | "name": "hasClaimed", 196 | "outputs": [ 197 | { 198 | "internalType": "bool", 199 | "name": "", 200 | "type": "bool" 201 | } 202 | ], 203 | "stateMutability": "view", 204 | "type": "function" 205 | }, 206 | { 207 | "inputs": [ 208 | { 209 | "internalType": "address", 210 | "name": "spender", 211 | "type": "address" 212 | }, 213 | { 214 | "internalType": "uint256", 215 | "name": "addedValue", 216 | "type": "uint256" 217 | } 218 | ], 219 | "name": "increaseAllowance", 220 | "outputs": [ 221 | { 222 | "internalType": "bool", 223 | "name": "", 224 | "type": "bool" 225 | } 226 | ], 227 | "stateMutability": "nonpayable", 228 | "type": "function" 229 | }, 230 | { 231 | "inputs": [ 232 | { 233 | "internalType": "address", 234 | "name": "_account", 235 | "type": "address" 236 | }, 237 | { 238 | "internalType": "uint256", 239 | "name": "_amount", 240 | "type": "uint256" 241 | } 242 | ], 243 | "name": "mint", 244 | "outputs": [], 245 | "stateMutability": "nonpayable", 246 | "type": "function" 247 | }, 248 | { 249 | "inputs": [], 250 | "name": "name", 251 | "outputs": [ 252 | { 253 | "internalType": "string", 254 | "name": "", 255 | "type": "string" 256 | } 257 | ], 258 | "stateMutability": "view", 259 | "type": "function" 260 | }, 261 | { 262 | "inputs": [], 263 | "name": "owner", 264 | "outputs": [ 265 | { 266 | "internalType": "address", 267 | "name": "", 268 | "type": "address" 269 | } 270 | ], 271 | "stateMutability": "view", 272 | "type": "function" 273 | }, 274 | { 275 | "inputs": [], 276 | "name": "renounceOwnership", 277 | "outputs": [], 278 | "stateMutability": "nonpayable", 279 | "type": "function" 280 | }, 281 | { 282 | "inputs": [], 283 | "name": "symbol", 284 | "outputs": [ 285 | { 286 | "internalType": "string", 287 | "name": "", 288 | "type": "string" 289 | } 290 | ], 291 | "stateMutability": "view", 292 | "type": "function" 293 | }, 294 | { 295 | "inputs": [], 296 | "name": "totalSupply", 297 | "outputs": [ 298 | { 299 | "internalType": "uint256", 300 | "name": "", 301 | "type": "uint256" 302 | } 303 | ], 304 | "stateMutability": "view", 305 | "type": "function" 306 | }, 307 | { 308 | "inputs": [ 309 | { 310 | "internalType": "address", 311 | "name": "to", 312 | "type": "address" 313 | }, 314 | { 315 | "internalType": "uint256", 316 | "name": "amount", 317 | "type": "uint256" 318 | } 319 | ], 320 | "name": "transfer", 321 | "outputs": [ 322 | { 323 | "internalType": "bool", 324 | "name": "", 325 | "type": "bool" 326 | } 327 | ], 328 | "stateMutability": "nonpayable", 329 | "type": "function" 330 | }, 331 | { 332 | "inputs": [ 333 | { 334 | "internalType": "address", 335 | "name": "from", 336 | "type": "address" 337 | }, 338 | { 339 | "internalType": "address", 340 | "name": "to", 341 | "type": "address" 342 | }, 343 | { 344 | "internalType": "uint256", 345 | "name": "amount", 346 | "type": "uint256" 347 | } 348 | ], 349 | "name": "transferFrom", 350 | "outputs": [ 351 | { 352 | "internalType": "bool", 353 | "name": "", 354 | "type": "bool" 355 | } 356 | ], 357 | "stateMutability": "nonpayable", 358 | "type": "function" 359 | }, 360 | { 361 | "inputs": [ 362 | { 363 | "internalType": "address", 364 | "name": "newOwner", 365 | "type": "address" 366 | } 367 | ], 368 | "name": "transferOwnership", 369 | "outputs": [], 370 | "stateMutability": "nonpayable", 371 | "type": "function" 372 | } 373 | ] 374 | -------------------------------------------------------------------------------- /abi/stakingVault.abi.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "inputs": [ 4 | { 5 | "internalType": "address", 6 | "name": "_stakingToken", 7 | "type": "address" 8 | }, 9 | { 10 | "internalType": "address", 11 | "name": "_stakingBank", 12 | "type": "address" 13 | }, 14 | { 15 | "internalType": "uint256", 16 | "name": "_rewardRate", 17 | "type": "uint256" 18 | } 19 | ], 20 | "stateMutability": "nonpayable", 21 | "type": "constructor" 22 | }, 23 | { 24 | "anonymous": false, 25 | "inputs": [ 26 | { 27 | "indexed": true, 28 | "internalType": "address", 29 | "name": "previousOwner", 30 | "type": "address" 31 | }, 32 | { 33 | "indexed": true, 34 | "internalType": "address", 35 | "name": "newOwner", 36 | "type": "address" 37 | } 38 | ], 39 | "name": "OwnershipTransferred", 40 | "type": "event" 41 | }, 42 | { 43 | "anonymous": false, 44 | "inputs": [ 45 | { 46 | "indexed": false, 47 | "internalType": "address", 48 | "name": "account", 49 | "type": "address" 50 | } 51 | ], 52 | "name": "Paused", 53 | "type": "event" 54 | }, 55 | { 56 | "anonymous": false, 57 | "inputs": [ 58 | { 59 | "indexed": false, 60 | "internalType": "address", 61 | "name": "_from", 62 | "type": "address" 63 | }, 64 | { 65 | "indexed": false, 66 | "internalType": "uint256", 67 | "name": "_amount", 68 | "type": "uint256" 69 | } 70 | ], 71 | "name": "RewardClaimed", 72 | "type": "event" 73 | }, 74 | { 75 | "anonymous": false, 76 | "inputs": [ 77 | { 78 | "indexed": false, 79 | "internalType": "address", 80 | "name": "_from", 81 | "type": "address" 82 | }, 83 | { 84 | "indexed": false, 85 | "internalType": "uint256", 86 | "name": "_amount", 87 | "type": "uint256" 88 | } 89 | ], 90 | "name": "Staked", 91 | "type": "event" 92 | }, 93 | { 94 | "anonymous": false, 95 | "inputs": [ 96 | { 97 | "indexed": false, 98 | "internalType": "address", 99 | "name": "account", 100 | "type": "address" 101 | } 102 | ], 103 | "name": "Unpaused", 104 | "type": "event" 105 | }, 106 | { 107 | "anonymous": false, 108 | "inputs": [ 109 | { 110 | "indexed": false, 111 | "internalType": "address", 112 | "name": "_from", 113 | "type": "address" 114 | }, 115 | { 116 | "indexed": false, 117 | "internalType": "uint256", 118 | "name": "_amount", 119 | "type": "uint256" 120 | } 121 | ], 122 | "name": "Withdrawn", 123 | "type": "event" 124 | }, 125 | { 126 | "inputs": [ 127 | { 128 | "internalType": "uint256", 129 | "name": "_amount", 130 | "type": "uint256" 131 | }, 132 | { 133 | "internalType": "uint256", 134 | "name": "_from", 135 | "type": "uint256" 136 | } 137 | ], 138 | "name": "calculateReward", 139 | "outputs": [ 140 | { 141 | "internalType": "uint256", 142 | "name": "", 143 | "type": "uint256" 144 | } 145 | ], 146 | "stateMutability": "view", 147 | "type": "function" 148 | }, 149 | { 150 | "inputs": [], 151 | "name": "claimReward", 152 | "outputs": [], 153 | "stateMutability": "nonpayable", 154 | "type": "function" 155 | }, 156 | { 157 | "inputs": [], 158 | "name": "owner", 159 | "outputs": [ 160 | { 161 | "internalType": "address", 162 | "name": "", 163 | "type": "address" 164 | } 165 | ], 166 | "stateMutability": "view", 167 | "type": "function" 168 | }, 169 | { 170 | "inputs": [], 171 | "name": "pause", 172 | "outputs": [], 173 | "stateMutability": "nonpayable", 174 | "type": "function" 175 | }, 176 | { 177 | "inputs": [], 178 | "name": "paused", 179 | "outputs": [ 180 | { 181 | "internalType": "bool", 182 | "name": "", 183 | "type": "bool" 184 | } 185 | ], 186 | "stateMutability": "view", 187 | "type": "function" 188 | }, 189 | { 190 | "inputs": [], 191 | "name": "renounceOwnership", 192 | "outputs": [], 193 | "stateMutability": "nonpayable", 194 | "type": "function" 195 | }, 196 | { 197 | "inputs": [ 198 | { 199 | "internalType": "address", 200 | "name": "_account", 201 | "type": "address" 202 | } 203 | ], 204 | "name": "rewardOf", 205 | "outputs": [ 206 | { 207 | "internalType": "uint256", 208 | "name": "", 209 | "type": "uint256" 210 | } 211 | ], 212 | "stateMutability": "view", 213 | "type": "function" 214 | }, 215 | { 216 | "inputs": [], 217 | "name": "rewardsToken", 218 | "outputs": [ 219 | { 220 | "internalType": "contract IERC20", 221 | "name": "", 222 | "type": "address" 223 | } 224 | ], 225 | "stateMutability": "view", 226 | "type": "function" 227 | }, 228 | { 229 | "inputs": [ 230 | { 231 | "internalType": "address", 232 | "name": "_stakingBank", 233 | "type": "address" 234 | } 235 | ], 236 | "name": "setStakingBank", 237 | "outputs": [], 238 | "stateMutability": "nonpayable", 239 | "type": "function" 240 | }, 241 | { 242 | "inputs": [ 243 | { 244 | "internalType": "uint256", 245 | "name": "_amount", 246 | "type": "uint256" 247 | } 248 | ], 249 | "name": "stake", 250 | "outputs": [], 251 | "stateMutability": "nonpayable", 252 | "type": "function" 253 | }, 254 | { 255 | "inputs": [ 256 | { 257 | "internalType": "address", 258 | "name": "_account", 259 | "type": "address" 260 | } 261 | ], 262 | "name": "stakedOf", 263 | "outputs": [ 264 | { 265 | "internalType": "uint256", 266 | "name": "", 267 | "type": "uint256" 268 | } 269 | ], 270 | "stateMutability": "view", 271 | "type": "function" 272 | }, 273 | { 274 | "inputs": [], 275 | "name": "stakingToken", 276 | "outputs": [ 277 | { 278 | "internalType": "contract IERC20", 279 | "name": "", 280 | "type": "address" 281 | } 282 | ], 283 | "stateMutability": "view", 284 | "type": "function" 285 | }, 286 | { 287 | "inputs": [], 288 | "name": "totalStaked", 289 | "outputs": [ 290 | { 291 | "internalType": "uint256", 292 | "name": "", 293 | "type": "uint256" 294 | } 295 | ], 296 | "stateMutability": "view", 297 | "type": "function" 298 | }, 299 | { 300 | "inputs": [ 301 | { 302 | "internalType": "address", 303 | "name": "newOwner", 304 | "type": "address" 305 | } 306 | ], 307 | "name": "transferOwnership", 308 | "outputs": [], 309 | "stateMutability": "nonpayable", 310 | "type": "function" 311 | }, 312 | { 313 | "inputs": [], 314 | "name": "unpause", 315 | "outputs": [], 316 | "stateMutability": "nonpayable", 317 | "type": "function" 318 | }, 319 | { 320 | "inputs": [ 321 | { 322 | "internalType": "uint256", 323 | "name": "_amount", 324 | "type": "uint256" 325 | } 326 | ], 327 | "name": "withdraw", 328 | "outputs": [], 329 | "stateMutability": "nonpayable", 330 | "type": "function" 331 | } 332 | ] 333 | -------------------------------------------------------------------------------- /contracts/DummyToken.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity >=0.8.0 <0.9.0; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | import "@openzeppelin/contracts/access/Ownable.sol"; 6 | 7 | contract DummyToken is ERC20, Ownable { 8 | uint256 public constant INITIAL_SUPPLY = 1_000_000 * 10 ** 18; 9 | uint256 public constant REWARD_AMOUNT = 1000 * 10 ** 18; 10 | 11 | enum ClaimStatus { 12 | NOT_CLAIMED, 13 | CLAIMED 14 | } 15 | 16 | mapping(address => ClaimStatus) private claimants; 17 | 18 | constructor() ERC20("DummyToken", "DT") { 19 | _mint(msg.sender, INITIAL_SUPPLY); 20 | } 21 | 22 | function mint(address _account, uint256 _amount) public onlyOwner { 23 | _mint(_account, _amount); 24 | } 25 | 26 | function claim() public { 27 | require(!hasClaimed(msg.sender), "Already claimed"); 28 | _mint(msg.sender, REWARD_AMOUNT); 29 | claimants[msg.sender] = ClaimStatus.CLAIMED; 30 | } 31 | 32 | function hasClaimed(address _account) public view returns (bool) { 33 | return claimants[_account] == ClaimStatus.CLAIMED; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /contracts/StakingVault.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity >=0.8.0 <0.9.0; 3 | 4 | import "@openzeppelin/contracts/access/Ownable.sol"; 5 | import "@openzeppelin/contracts/security/Pausable.sol"; 6 | import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; 7 | import "@openzeppelin/contracts/utils/math/SafeMath.sol"; 8 | import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; 9 | 10 | contract StakingVault is Ownable, Pausable, ReentrancyGuard { 11 | using SafeERC20 for IERC20; 12 | 13 | event Staked(address _from, uint256 _amount); 14 | event Withdrawn(address _from, uint256 _amount); 15 | event RewardClaimed(address _from, uint256 _amount); 16 | 17 | struct StakeState { 18 | uint256 amount; 19 | uint256 lastUpdated; 20 | uint256 previousReward; 21 | } 22 | 23 | address private stakingBank; 24 | IERC20 public immutable rewardsToken; 25 | IERC20 public immutable stakingToken; 26 | 27 | // Reward rate per second per token staked 28 | uint256 private rewardRate; 29 | 30 | // Total amount of tokens staked 31 | uint256 private totalSupply; 32 | 33 | // Mapping of staked balances 34 | mapping(address => StakeState) private balances; 35 | 36 | constructor( 37 | address _stakingToken, 38 | address _stakingBank, 39 | uint256 _rewardRate 40 | ) { 41 | require( 42 | _stakingToken != address(0), 43 | "StakingVault: staking token address cannot be 0" 44 | ); 45 | require( 46 | _stakingBank != address(0), 47 | "StakingVault: staking bank address cannot be 0" 48 | ); 49 | require( 50 | _rewardRate > 0, 51 | "StakingVault: reward rate must be greater than 0" 52 | ); 53 | 54 | stakingBank = _stakingBank; 55 | stakingToken = IERC20(_stakingToken); 56 | rewardsToken = IERC20(_stakingToken); 57 | 58 | rewardRate = _rewardRate; 59 | } 60 | 61 | function calculateReward( 62 | uint256 _amount, 63 | uint256 _from 64 | ) public view returns (uint256) { 65 | return ((_amount * (block.timestamp - _from)) * rewardRate) / 1e18; 66 | } 67 | 68 | function stakedOf(address _account) public view returns (uint256) { 69 | return balances[_account].amount; 70 | } 71 | 72 | function rewardOf(address _account) public view returns (uint256) { 73 | return 74 | balances[_account].previousReward + 75 | calculateReward( 76 | balances[_account].amount, 77 | balances[_account].lastUpdated 78 | ); 79 | } 80 | 81 | function totalStaked() public view returns (uint256) { 82 | return totalSupply; 83 | } 84 | 85 | function pause() public onlyOwner { 86 | _pause(); 87 | } 88 | 89 | function unpause() public onlyOwner { 90 | _unpause(); 91 | } 92 | 93 | function setStakingBank(address _stakingBank) public onlyOwner { 94 | require( 95 | _stakingBank != address(0), 96 | "StakingVault: staking bank address cannot be 0" 97 | ); 98 | stakingBank = _stakingBank; 99 | } 100 | 101 | modifier updateReward(address _account) { 102 | uint256 staked = balances[_account].amount; 103 | if (staked > 0) { 104 | uint256 reward = calculateReward( 105 | staked, 106 | balances[_account].lastUpdated 107 | ); 108 | balances[_account].previousReward += reward; 109 | } 110 | balances[msg.sender].lastUpdated = block.timestamp; 111 | _; 112 | } 113 | 114 | function stake( 115 | uint256 _amount 116 | ) public nonReentrant updateReward(msg.sender) { 117 | require(_amount > 0, "StakingVault: amount must be greater than 0"); 118 | 119 | stakingToken.safeTransferFrom(msg.sender, address(this), _amount); 120 | totalSupply += _amount; 121 | balances[msg.sender].amount += _amount; 122 | 123 | emit Staked(msg.sender, _amount); 124 | } 125 | 126 | function withdraw( 127 | uint256 _amount 128 | ) public nonReentrant updateReward(msg.sender) { 129 | uint256 staked = balances[msg.sender].amount; 130 | require( 131 | _amount <= staked, 132 | "StakingVault: withdraw amount cannot be greater than staked amount" 133 | ); 134 | require(staked > 0, "StakingVault: no tokens staked"); 135 | 136 | stakingToken.safeTransferFrom(address(this), msg.sender, _amount); 137 | 138 | totalSupply -= _amount; 139 | balances[msg.sender].amount -= _amount; 140 | emit Withdrawn(msg.sender, _amount); 141 | } 142 | 143 | function claimReward() public nonReentrant updateReward(msg.sender) { 144 | uint256 reward = balances[msg.sender].previousReward; 145 | require(reward >= 0, "StakingVault: no rewards to claim"); 146 | 147 | rewardsToken.safeTransferFrom(stakingBank, msg.sender, reward); 148 | balances[msg.sender].previousReward = 0; 149 | emit RewardClaimed(msg.sender, reward); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /hardhat.config.js: -------------------------------------------------------------------------------- 1 | require("@nomicfoundation/hardhat-toolbox"); 2 | const { config } = require("dotenv"); 3 | const path = require("path"); 4 | 5 | function loadConfig() { 6 | const env = process.env.NODE_ENV || "development"; 7 | [".env", ".env.local", `.env.${env}`, `.env.${env}.local`] 8 | .map(file => path.join(__dirname, file)) 9 | .forEach(file => config({ path: file })); 10 | } 11 | loadConfig(); 12 | 13 | const GETBLOCKIO_API_KEY = process.env.GETBLOCKIO_API_KEY; 14 | const BSC_SCAN_API_KEY = process.env.BSC_SCAN_API_KEY; 15 | const PRIVATE_KEY = process.env.PRIVATE_KEY; 16 | 17 | /** 18 | * @type import('hardhat/config').HardhatUserConfig 19 | */ 20 | module.exports = { 21 | solidity: { 22 | version: "0.8.17", 23 | settings: { 24 | viaIR: true, 25 | optimizer: { 26 | enabled: true, 27 | runs: 1000, 28 | }, 29 | }, 30 | }, 31 | networks: { 32 | bsc: { 33 | url: "https://bsc.getblock.io/mainnet/", 34 | chainId: 56, 35 | gasPrice: 20000000000, 36 | accounts: [PRIVATE_KEY], 37 | httpHeaders: { 38 | "x-api-key": GETBLOCKIO_API_KEY, 39 | }, 40 | }, 41 | bscTestnet: { 42 | url: "https://bsc.getblock.io/testnet/", 43 | chainId: 97, 44 | gasPrice: 20000000000, 45 | accounts: [PRIVATE_KEY], 46 | httpHeaders: { 47 | "x-api-key": GETBLOCKIO_API_KEY, 48 | }, 49 | }, 50 | }, 51 | etherscan: { 52 | apiKey: { 53 | bsc: BSC_SCAN_API_KEY, 54 | bscTestnet: BSC_SCAN_API_KEY, 55 | }, 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | dApp with React Workshop 8 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | set export 2 | set dotenv-load 3 | 4 | HARDHAT_NETWORK := "bscTestnet" 5 | 6 | compile: 7 | npx hardhat compile 8 | 9 | abi: compile 10 | #!/bin/env python 11 | import json 12 | import os 13 | from pathlib import Path 14 | 15 | cwd = Path.cwd() 16 | abi_home = cwd / "abi" 17 | contracts = cwd / "artifacts" / "contracts" 18 | 19 | for contract in contracts.iterdir(): 20 | if not contract.is_dir(): 21 | continue 22 | name, _ = os.path.splitext(contract.name) 23 | artifact = contracts / contract / f"{name}.json" 24 | 25 | with open(artifact, "r", encoding="utf-8") as f: 26 | abi = json.load(f)["abi"] 27 | 28 | name = "".join([name[0].lower(), *name[1:]]) 29 | abi_file = abi_home / f"{name}.abi.json" 30 | with open(abi_file, "w", encoding="utf-8") as f: 31 | json.dump(abi, f, indent=2) 32 | f.write("\n") 33 | print(f"Generated {abi_file} for {name}") 34 | 35 | deploy_token: compile 36 | npx hardhat run --network $HARDHAT_NETWORK scripts/deployToken.js 37 | 38 | deploy_staking reward: compile 39 | node scripts/deployStaking.js $VITE_DUMMY_TOKEN_ADDRESS {{ reward }} 40 | 41 | clean: 42 | npx hardhat clean 43 | rm -rf dist cache 44 | 45 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [dev] 2 | command = "npm run dev" 3 | port = 3000 4 | targetPort = 5173 5 | 6 | [build] 7 | publish = "dist" 8 | command = "npm run build" 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dapp-with-react", 3 | "description": "DeFi application with staking and custom ERC20 token and React.js frontend.", 4 | "version": "0.0.1", 5 | "author": { 6 | "name": "Atahan Yorgancı" 7 | }, 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/atahanyorganci/dapp-with-react.git" 12 | }, 13 | "scripts": { 14 | "dev": "vite", 15 | "build": "vite build", 16 | "preview": "vite preview" 17 | }, 18 | "dependencies": { 19 | "ethers": "^5.7.2", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0" 22 | }, 23 | "devDependencies": { 24 | "@nomicfoundation/hardhat-chai-matchers": "^1.0.0", 25 | "@nomicfoundation/hardhat-network-helpers": "^1.0.0", 26 | "@nomicfoundation/hardhat-toolbox": "^1.0.2", 27 | "@nomiclabs/hardhat-ethers": "^2.0.0", 28 | "@nomiclabs/hardhat-etherscan": "^3.0.0", 29 | "@openzeppelin/contracts": "^4.8.1", 30 | "@typechain/ethers-v5": "^10.1.0", 31 | "@typechain/hardhat": "^6.1.2", 32 | "@types/chai": "^4.2.0", 33 | "@types/mocha": "^9.1.0", 34 | "@types/react": "^18.0.27", 35 | "@types/react-dom": "^18.0.10", 36 | "@vitejs/plugin-react": "^2.2.0", 37 | "chai": "^4.3.7", 38 | "dotenv": "^16.0.3", 39 | "eslint": "^8.32.0", 40 | "eslint-config-prettier": "^8.6.0", 41 | "eslint-plugin-import": "^2.27.5", 42 | "eslint-plugin-jsx-a11y": "^6.7.1", 43 | "eslint-plugin-prettier": "^4.2.1", 44 | "eslint-plugin-react": "^7.32.1", 45 | "eslint-plugin-react-hooks": "^4.6.0", 46 | "hardhat": "^2.12.6", 47 | "hardhat-gas-reporter": "^1.0.8", 48 | "prettier": "^2.8.3", 49 | "solidity-coverage": "^0.7.21", 50 | "typechain": "^8.1.0", 51 | "vite": "^3.2.5" 52 | }, 53 | "volta": { 54 | "node": "18.13.0", 55 | "npm": "9.3.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /public/opn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atahanyorganci/dapp-with-react/865580bf9b6068b225c929ec59cf7f80cf2a848c/public/opn.png -------------------------------------------------------------------------------- /scripts/deployStaking.js: -------------------------------------------------------------------------------- 1 | const hre = require("hardhat"); 2 | 3 | async function main() { 4 | const dummyTokenBuildInfo = await hre.artifacts.readArtifact("DummyToken"); 5 | 6 | const [deployer] = await hre.ethers.getSigners(); 7 | console.log(`Deployer address: ${deployer.address}`); 8 | 9 | const [_node, _script, tokenAddress, rewardRate] = process.argv; 10 | console.log(`Token address: ${tokenAddress}`); 11 | console.log(`Reward rate: ${rewardRate}`); 12 | 13 | if (!_node || !_script || !tokenAddress || !rewardRate) { 14 | throw new Error("Usage: node deployStaking.js "); 15 | } 16 | const constructorArguments = [ 17 | tokenAddress, 18 | deployer.address, 19 | hre.ethers.utils.parseEther(rewardRate), 20 | ]; 21 | 22 | const StakingVault = await hre.ethers.getContractFactory("StakingVault"); 23 | const stakingVault = await StakingVault.deploy(...constructorArguments); 24 | 25 | await stakingVault.deployed(); 26 | console.log(`Deployed StakingVault at ${stakingVault.address}`); 27 | 28 | const dummyTokenContract = new hre.ethers.Contract( 29 | tokenAddress, 30 | dummyTokenBuildInfo.abi, 31 | deployer 32 | ); 33 | 34 | const tx = await dummyTokenContract.approve( 35 | stakingVault.address, 36 | hre.ethers.utils.parseEther("1000000000") 37 | ); 38 | const receipt = await tx.wait(); 39 | console.log( 40 | `Approved StakingVault contract tx hash: ${receipt.transactionHash}` 41 | ); 42 | 43 | await hre.run("verify:verify", { 44 | address: stakingVault.address, 45 | constructorArguments, 46 | }); 47 | } 48 | 49 | main() 50 | .then(() => process.exit(0)) 51 | .catch(error => { 52 | console.error(error); 53 | process.exit(1); 54 | }); 55 | -------------------------------------------------------------------------------- /scripts/deployToken.js: -------------------------------------------------------------------------------- 1 | const hre = require("hardhat"); 2 | 3 | async function main() { 4 | const [deployer] = await hre.ethers.getSigners(); 5 | console.log(`Deployer address: ${deployer.address}`); 6 | 7 | const DummyToken = await hre.ethers.getContractFactory("DummyToken"); 8 | const dummyToken = await DummyToken.deploy(); 9 | 10 | await dummyToken.deployed(); 11 | console.log(`Deployed DummyToken at ${dummyToken.address}`); 12 | 13 | await hre.run("verify:verify", { 14 | address: dummyToken.address, 15 | constructorArguments: [], 16 | }); 17 | } 18 | 19 | main() 20 | .then(() => process.exit(0)) 21 | .catch(error => { 22 | console.error(error); 23 | process.exit(1); 24 | }); 25 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import { ethers } from "ethers"; 2 | import React, { useEffect, useState } from "react"; 3 | import DummyToken from "./components/dummyToken"; 4 | import Staking from "./components/staking"; 5 | import { provider } from "./web3"; 6 | 7 | const Balance = ({ account }) => { 8 | const [balance, setBalance] = useState(""); 9 | 10 | useEffect(() => { 11 | const getBalance = async () => { 12 | const balance = await provider.getBalance(account); 13 | return ethers.utils.formatEther(balance); 14 | }; 15 | getBalance().then(setBalance).catch(console.error); 16 | }, [account, provider]); 17 | 18 | if (!balance) { 19 | return

Loading...

; 20 | } 21 | return

Balance: {balance} tBNB

; 22 | }; 23 | 24 | function App() { 25 | const [account, setAccount] = useState(null); 26 | 27 | const checkAccounts = async () => { 28 | if (!window.ethereum) { 29 | return null; 30 | } 31 | const [account] = await window.ethereum.request({ 32 | method: "eth_accounts", 33 | }); 34 | window.ethereum.on("accountsChanged", accounts => { 35 | setAccount(accounts[0]); 36 | }); 37 | return account; 38 | }; 39 | 40 | const requestAccounts = async () => { 41 | if (!window.ethereum) { 42 | return null; 43 | } 44 | const [account] = await window.ethereum.request({ 45 | method: "eth_requestAccounts", 46 | }); 47 | return account; 48 | }; 49 | 50 | useEffect(() => { 51 | checkAccounts().then(setAccount).catch(console.error); 52 | }, []); 53 | 54 | return ( 55 |
56 |

dApp with React

57 | {account ? ( 58 |

59 | Account:{" "} 60 | {account} 61 |

62 | ) : ( 63 | 66 | )} 67 | {provider && account && ( 68 | <> 69 | 70 | 71 | 72 | 73 | )} 74 |
75 | ); 76 | } 77 | 78 | export default App; 79 | -------------------------------------------------------------------------------- /src/components/dummyToken.jsx: -------------------------------------------------------------------------------- 1 | import { ethers } from "ethers"; 2 | import React, { useEffect, useState } from "react"; 3 | import { DUMMY_TOKEN, DUMMY_TOKEN_ADDRESS, provider } from "../web3"; 4 | 5 | const getBalanceAndClaimed = async account => { 6 | const dummyToken = DUMMY_TOKEN.connect(provider); 7 | const [balance, claimed] = await Promise.all([ 8 | dummyToken.balanceOf(account), 9 | dummyToken.hasClaimed(account), 10 | ]); 11 | return [ethers.utils.formatEther(balance), claimed]; 12 | }; 13 | 14 | const addDummyTokenToMetaMask = async () => { 15 | if (!window.ethereum) { 16 | return false; 17 | } 18 | try { 19 | await window.ethereum.request({ 20 | method: "wallet_watchAsset", 21 | params: { 22 | type: "ERC20", 23 | options: { 24 | address: DUMMY_TOKEN_ADDRESS, 25 | symbol: "DT", 26 | decimals: 18, 27 | }, 28 | }, 29 | }); 30 | } catch (error) { 31 | console.error(error); 32 | } 33 | }; 34 | 35 | const DummyToken = ({ account }) => { 36 | const [balance, setBalance] = useState(""); 37 | const [claimed, setClaimed] = useState(false); 38 | 39 | const claim = async () => { 40 | const signer = provider.getSigner(); 41 | const dummyToken = DUMMY_TOKEN.connect(signer); 42 | const tx = await dummyToken.claim(); 43 | const receipt = await tx.wait(); 44 | console.log(receipt); 45 | 46 | getBalanceAndClaimed(account, provider) 47 | .then(([balance, claimed]) => { 48 | setBalance(balance); 49 | setClaimed(claimed); 50 | }) 51 | .catch(console.error); 52 | }; 53 | 54 | useEffect(() => { 55 | getBalanceAndClaimed(account, provider) 56 | .then(([balance, claimed]) => { 57 | setBalance(balance); 58 | setClaimed(claimed); 59 | }) 60 | .catch(console.error); 61 | }, [provider, account]); 62 | 63 | if (!balance) { 64 | return ( 65 |
66 |

Dummy Token

67 |

Loading...

68 |
69 | ); 70 | } 71 | 72 | return ( 73 |
74 |

Dummy Token

75 |

76 | DummyToken balance: {balance} DT 77 |

78 | {claimed ? ( 79 |

You have already claimed your DT

80 | ) : ( 81 | 82 | )} 83 | 84 |
85 | ); 86 | }; 87 | 88 | export default DummyToken; 89 | -------------------------------------------------------------------------------- /src/components/staking.jsx: -------------------------------------------------------------------------------- 1 | import { ethers } from "ethers"; 2 | import React, { useEffect, useState } from "react"; 3 | import { DUMMY_TOKEN, provider, STAKING_CONTRACT } from "../web3"; 4 | 5 | const getStakingViews = async account => { 6 | const signer = provider.getSigner(account); 7 | const staking = STAKING_CONTRACT.connect(signer); 8 | const [staked, reward, totalStaked] = await Promise.all([ 9 | staking.stakedOf(account), 10 | staking.rewardOf(account), 11 | staking.totalStaked(), 12 | ]); 13 | return { 14 | staked: ethers.utils.formatEther(staked), 15 | reward: ethers.utils.formatEther(reward), 16 | totalStaked: ethers.utils.formatEther(totalStaked), 17 | }; 18 | }; 19 | 20 | const Staking = ({ account }) => { 21 | const [views, setViews] = useState({}); 22 | const [stake, setStake] = useState(""); 23 | const [withdraw, setWithdraw] = useState(""); 24 | 25 | const handleStake = async event => { 26 | event.preventDefault(); 27 | const signer = provider.getSigner(account); 28 | const amount = ethers.utils.parseEther(stake); 29 | 30 | const dummyToken = DUMMY_TOKEN.connect(signer); 31 | const allowance = await dummyToken.allowance( 32 | account, 33 | STAKING_CONTRACT.address 34 | ); 35 | if (allowance.lt(amount)) { 36 | const tx = await dummyToken.approve( 37 | STAKING_CONTRACT.address, 38 | amount 39 | ); 40 | await tx.wait(); 41 | } 42 | 43 | const staking = STAKING_CONTRACT.connect(signer); 44 | 45 | const tx = await staking.stake(amount); 46 | await tx.wait(); 47 | }; 48 | 49 | const handleWithdraw = async event => { 50 | event.preventDefault(); 51 | const signer = provider.getSigner(account); 52 | const staking = STAKING_CONTRACT.connect(signer); 53 | 54 | const amount = ethers.utils.parseEther(withdraw); 55 | const tx = await staking.withdraw(amount); 56 | await tx.wait(); 57 | }; 58 | 59 | const handleClaimReward = async () => { 60 | const signer = provider.getSigner(account); 61 | const staking = STAKING_CONTRACT.connect(signer); 62 | 63 | const tx = await staking.claimReward(); 64 | await tx.wait(); 65 | }; 66 | 67 | useEffect(() => { 68 | getStakingViews(account, provider).then(setViews).catch(console.error); 69 | }, [account, provider]); 70 | 71 | if (!views.staked) { 72 | return ( 73 |
74 |

Staking

75 |

Loading...

76 |
77 | ); 78 | } 79 | 80 | return ( 81 |
82 |

Staking

83 |

84 | Staked: {views.staked} DT 85 |

86 |

87 | Reward: {views.reward} DT 88 |

89 |

90 | Total Staked: {views.totalStaked} DT 91 |

92 |
93 |
94 | 95 | setStake(e.target.value)} 100 | /> 101 | 102 |
103 |
104 | 105 | setWithdraw(e.target.value)} 110 | /> 111 | 112 |
113 |
114 | 115 |
116 | ); 117 | }; 118 | 119 | export default Staking; 120 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | 5 | ReactDOM.createRoot(document.getElementById("root")).render( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /src/web3.js: -------------------------------------------------------------------------------- 1 | import { ethers } from "ethers"; 2 | import DummyTokenABI from "../abi/dummyToken.abi.json"; 3 | import StakingAbi from "../abi/stakingVault.abi.json"; 4 | 5 | function getWeb3Provider() { 6 | if (window.ethereum) { 7 | return new ethers.providers.Web3Provider(window.ethereum); 8 | } 9 | return null; 10 | } 11 | 12 | export const provider = getWeb3Provider(); 13 | 14 | export const DUMMY_TOKEN_ADDRESS = import.meta.env.VITE_DUMMY_TOKEN_ADDRESS; 15 | export const DUMMY_TOKEN = new ethers.Contract( 16 | DUMMY_TOKEN_ADDRESS, 17 | DummyTokenABI 18 | ); 19 | 20 | export const STAKING_ADDRESS = import.meta.env.VITE_STAKING_ADDRESS; 21 | export const STAKING_CONTRACT = new ethers.Contract( 22 | STAKING_ADDRESS, 23 | StakingAbi 24 | ); 25 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()] 7 | }) 8 | --------------------------------------------------------------------------------