├── .github └── workflows │ ├── ci.yml │ ├── create_release.yml │ ├── dev_deploy.yml │ ├── prod_deploy.yaml │ └── slack_release_notification.yml ├── .gitignore ├── .husky └── .gitignore ├── .nvmrc ├── .prettierrc.json ├── LICENSE ├── README.md ├── package.json ├── packages ├── app │ ├── .eslintrc.json │ ├── .gitignore │ ├── LICENSE │ ├── abi │ │ ├── token.ts │ │ └── tokenLock.ts │ ├── app │ │ ├── layout.tsx │ │ └── page.tsx │ ├── components │ │ ├── AmountInput.tsx │ │ ├── Balance.module.css │ │ ├── Balance.tsx │ │ ├── Button.module.css │ │ ├── Button.tsx │ │ ├── Card.module.css │ │ ├── Card.tsx │ │ ├── Connect │ │ │ ├── Identicon.module.css │ │ │ ├── Identicon.tsx │ │ │ ├── index.module.css │ │ │ └── index.tsx │ │ ├── ConnectHint.tsx │ │ ├── Deposit.tsx │ │ ├── DepositAndWithdraw.module.css │ │ ├── DepositAndWithdraw.tsx │ │ ├── Field.module.css │ │ ├── Field.tsx │ │ ├── GnosisLogo.module.css │ │ ├── GnosisLogo.tsx │ │ ├── IconButton.module.css │ │ ├── IconButton.tsx │ │ ├── Input.module.css │ │ ├── LockedBalance.tsx │ │ ├── LockedGnoLogo.module.css │ │ ├── LockedGnoLogo.tsx │ │ ├── Notice.module.css │ │ ├── Notice.tsx │ │ ├── PercentOfTotal.module.css │ │ ├── PercentOfTotalHint.tsx │ │ ├── Spinner.module.css │ │ ├── Spinner.tsx │ │ ├── UseGnoBanner │ │ │ ├── UseGnoBanner.module.css │ │ │ └── index.tsx │ │ ├── Withdraw.tsx │ │ ├── index.ts │ │ ├── stats │ │ │ ├── Stats.module.css │ │ │ ├── StatsDeposit.tsx │ │ │ ├── StatsLocked.tsx │ │ │ ├── StatsWithdraw.tsx │ │ │ ├── TotalLockedBreakdown.module.css │ │ │ ├── TotalLockedBreakdown.tsx │ │ │ ├── TotalLockedStat.tsx │ │ │ ├── formatDuration.ts │ │ │ ├── formatToken.ts │ │ │ └── index.ts │ │ ├── tokenContract.ts │ │ ├── tokenLockContract.ts │ │ ├── useTokenLockConfig.tsx │ │ ├── useTokenPrice.ts │ │ └── useTotalLocked.ts │ ├── config.ts │ ├── config │ │ └── index.ts │ ├── context │ │ └── index.tsx │ ├── next-env.d.ts │ ├── next.config.js │ ├── old │ │ └── pages │ │ │ ├── _app.tsx │ │ │ └── index.tsx │ ├── package-lock.json │ ├── package.json │ ├── public │ │ ├── Averta-ExtraBold.woff2 │ │ ├── Averta-normal.woff2 │ │ ├── AvertaBold.woff2 │ │ ├── arrow.svg │ │ ├── close.svg │ │ ├── connectors │ │ │ ├── coinbase.svg │ │ │ ├── injected.svg │ │ │ ├── metamask.svg │ │ │ └── walletconnect.svg │ │ ├── copy.svg │ │ ├── discordicon.svg │ │ ├── etherscan.svg │ │ ├── favicon.ico │ │ ├── github.png │ │ ├── gno.svg │ │ ├── gnochainbg.svg │ │ ├── gnochainfuture.png │ │ ├── gnosisguild.png │ │ ├── google9cc8a77ba2e504cb.html │ │ ├── identicon.svg │ │ ├── info.svg │ │ ├── lock.svg │ │ ├── manifest.json │ │ ├── open.svg │ │ ├── twittericon.svg │ │ └── unlocked.svg │ ├── styles │ │ ├── Home.module.css │ │ ├── globals.css │ │ └── utility.module.css │ ├── tsconfig.json │ ├── wagmi.ts │ └── yarn.lock └── contracts │ ├── .env.template │ ├── .eslintrc.js │ ├── .gitignore │ ├── .mocharc.json │ ├── .solcover.js │ ├── .solhint.json │ ├── .vscode │ └── settings.json │ ├── audits │ └── GnosisTokenLockJan2022.pdf │ ├── contracts │ ├── TokenLock.sol │ └── test │ │ ├── TestToken.sol │ │ ├── TestTokenFailingTransfer.sol │ │ └── TestTokenFailingTransferFrom.sol │ ├── hardhat.config.ts │ ├── package.json │ ├── src │ └── tasks │ │ ├── initialDeploy.ts │ │ ├── initializeImplementation.ts │ │ ├── upgrade.ts │ │ └── verify.ts │ ├── test │ └── TokenLock.spec.ts │ ├── tsconfig.json │ └── yarn.lock └── yarn.lock /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push] 3 | 4 | jobs: 5 | tests: 6 | runs-on: ubuntu-latest 7 | defaults: 8 | run: 9 | working-directory: ./packages/contracts 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v2 13 | with: 14 | node-version: 16 15 | - uses: actions/cache@v2 16 | with: 17 | path: "**/node_modules" 18 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 19 | - run: yarn 20 | - run: yarn build 21 | - run: yarn coverage 22 | - name: Coveralls 23 | uses: coverallsapp/github-action@master 24 | with: 25 | path-to-lcov: ./packages/contracts/coverage/lcov.info 26 | github-token: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/create_release.yml: -------------------------------------------------------------------------------- 1 | name: Create Github Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+' 7 | 8 | # Permission can be added at job level or workflow level 9 | permissions: 10 | contents: write # This is required for actions/checkout and create release 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | release: 18 | name: Github Release 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Create Github Release 23 | uses: actions/github-script@v6 24 | with: 25 | github-token: ${{ github.token }} 26 | script: | 27 | if (!${{ toJson(github.ref_name) }}) { 28 | core.setFailed("RELEASE_TAG is not defined.") 29 | 30 | return; 31 | } 32 | try { 33 | const response = await github.rest.repos.createRelease({ 34 | name: ${{ toJson(github.ref_name) }}, 35 | tag_name: ${{ toJson(github.ref_name) }}, 36 | draft: false, 37 | generate_release_notes: true, 38 | owner: context.repo.owner, 39 | prerelease: false, 40 | repo: context.repo.repo, 41 | }); 42 | 43 | core.exportVariable('RELEASE_ID', response.data.id); 44 | core.exportVariable('RELEASE_UPLOAD_URL', response.data.upload_url); 45 | } catch (error) { 46 | core.setFailed(error.message); 47 | } -------------------------------------------------------------------------------- /.github/workflows/dev_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Automatic Deployment to Dev/Staging 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | release: 7 | types: [published] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | deploy: 15 | name: Deployment 16 | runs-on: ubuntu-latest 17 | defaults: 18 | run: 19 | working-directory: ./packages/app 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up node 26 | uses: actions/setup-node@v2 27 | with: 28 | node-version-file: ".nvmrc" 29 | 30 | - name: Cache yarn cache 31 | uses: actions/cache@v2 32 | with: 33 | path: "**/node_modules" 34 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 35 | 36 | - name: Install dependencies 37 | run: | 38 | yarn install 39 | pip install awscli --upgrade --user 40 | 41 | - name: Build website 42 | run: yarn build 43 | 44 | - name: Configure AWS Dev credentials 45 | uses: aws-actions/configure-aws-credentials@v1 46 | with: 47 | aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }} 48 | aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} 49 | aws-region: ${{ secrets.AWS_DEFAULT_REGION }} 50 | 51 | # Script to deploy to the dev environment 52 | - name: 'Deploy to S3: Dev' 53 | if: github.ref == 'refs/heads/master' 54 | run: | 55 | aws s3 sync build/ s3://${{ secrets.DEV_BUCKET_NAME }}/current --delete --exclude "*.html" --cache-control max-age=86400,public 56 | aws s3 sync build/ s3://${{ secrets.DEV_BUCKET_NAME }}/current --delete --exclude "*" --include "*.html" --cache-control max-age=0,no-cache,no-store,must-revalidate --content-type text/html 57 | 58 | - name: 'Cloudfront Production: cache invalidation' 59 | if: (startsWith(github.event.ref, 'refs/tags/v') || github.event_name == 'release') 60 | run: | 61 | aws cloudfront create-invalidation --distribution-id ${{ secrets.DEV_AWS_CLOUDFRONT_ID }} --paths "/*" 62 | 63 | - name: Get the version 64 | id: get_version 65 | run: echo ::set-output name=VERSION::$(echo $GITHUB_REF | cut -d / -f 3) 66 | 67 | # Script to upload release files 68 | - name: 'Upload release build files for staging' 69 | if: github.event.action == 'published' 70 | run: | 71 | aws s3 sync build/ s3://${{ secrets.DEV_BUCKET_NAME }}/releases/${{ steps.get_version.outputs.VERSION }} --delete --exclude "*.html" --cache-control max-age=86400,public 72 | aws s3 sync build/ s3://${{ secrets.DEV_BUCKET_NAME }}/releases/${{ steps.get_version.outputs.VERSION }} --delete --exclude "*" --include "*.html" --cache-control max-age=0,no-cache,no-store,must-revalidate --content-type text/html 73 | -------------------------------------------------------------------------------- /.github/workflows/prod_deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Manual Deployment to Production 2 | 3 | # Run on pushes to main or PRs 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | tag: 8 | description: Tagged version to deploy 9 | required: true 10 | type: string 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | deploy: 18 | name: Deployment 19 | runs-on: ubuntu-latest 20 | defaults: 21 | run: 22 | working-directory: ./packages/app 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - name: Tag checkout 28 | run: | 29 | git fetch --prune --unshallow --tags 30 | git checkout ${{ github.event.inputs.tag }} 31 | 32 | - name: Setup Node.js 33 | uses: actions/setup-node@v2 34 | with: 35 | node-version-file: ".nvmrc" 36 | 37 | - uses: actions/cache@v2 38 | with: 39 | path: '**/node_modules' 40 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 41 | 42 | - name: Install dependencies 43 | run: | 44 | yarn install 45 | pip install awscli --upgrade --user 46 | 47 | - name: Build App 48 | run: yarn build 49 | 50 | - name: Configure AWS Production credentials 51 | uses: aws-actions/configure-aws-credentials@v1 52 | with: 53 | aws-access-key-id: ${{ secrets.PROD_AWS_ACCESS_KEY_ID }} 54 | aws-secret-access-key: ${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }} 55 | aws-region: ${{ secrets.AWS_DEFAULT_REGION }} 56 | 57 | - name: 'Deploy to S3: Production' 58 | run: | 59 | aws s3 sync build/ s3://${{ secrets.PROD_BUCKET_NAME }} --delete --exclude "*.html" --cache-control max-age=86400,public 60 | aws s3 sync build/ s3://${{ secrets.PROD_BUCKET_NAME }} --delete --exclude "*" --include "*.html" --cache-control max-age=0,no-cache,no-store,must-revalidate --content-type text/html 61 | 62 | - name: 'Cloudfront Production: cache invalidation' 63 | if: (startsWith(github.event.ref, 'refs/tags/v') || github.event_name == 'release') 64 | run: | 65 | aws cloudfront create-invalidation --distribution-id ${{ secrets.PROD_AWS_CLOUDFRONT_ID }} --paths "/*" 66 | 67 | notify: 68 | uses: ./.github/workflows/slack_release_notification.yml 69 | if: ${{ always() }} 70 | needs: deploy 71 | secrets: 72 | RELEASES_SLACK_WEBHOOK_URL: ${{ secrets.RELEASES_SLACK_WEBHOOK_URL }} 73 | with: 74 | environment: Production 75 | service: GC Token Lock UI 76 | success: ${{ contains(join(needs.*.result, ','), 'success') }} 77 | message: "deploy service `GC Token Lock UI` version `${{ inputs.tag }}`. Triggered by `${{ github.actor }}`." -------------------------------------------------------------------------------- /.github/workflows/slack_release_notification.yml: -------------------------------------------------------------------------------- 1 | name: Slack Notify Release 2 | on: 3 | workflow_call: 4 | secrets: 5 | RELEASES_SLACK_WEBHOOK_URL: 6 | required: true 7 | inputs: 8 | environment: 9 | type: string 10 | required: true 11 | message: 12 | type: string 13 | required: true 14 | service: 15 | type: string 16 | required: true 17 | success: 18 | type: boolean 19 | required: true 20 | 21 | jobs: 22 | notify: 23 | name: Notify ${{ inputs.service }} release in ${{ inputs.environment }} 24 | runs-on: ubuntu-latest 25 | environment: ${{ inputs.environment }} 26 | steps: 27 | - name: Extract branch name 28 | shell: bash 29 | run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT 30 | id: extract_branch 31 | 32 | - name: Extract commit 33 | id: commit 34 | uses: prompt/actions-commit-hash@v2 35 | 36 | - name: Get current date 37 | id: date 38 | run: echo "::set-output name=date::$(date +'%Y-%m-%dT%H:%M:%S')" 39 | 40 | - id: slack 41 | uses: slackapi/slack-github-action@v1.24.0 42 | with: 43 | payload: "{\"username\":\"Releases\",\"icon_url\":\"https://avatars3.githubusercontent.com/u/134083290\",\"text\":\"${{ inputs.message }} - ${{ github.event.head_commit.message }}\",\"attachments\":[{\"text\":\"\",\"color\":\"${{ inputs.success == true && '#36a64f' || '#FF3131' }}\",\"author_name\":\"${{ inputs.service }}\",\"title\":\"\",\"fields\":[{\"title\":\"Environment\",\"short\":true,\"value\":\"`${{ inputs.environment }}`\"},{\"title\":\"Branch\",\"short\":true,\"value\":\"${{ steps.extract_branch.outputs.branch }}\"},{\"title\":\"Commit\",\"short\":true,\"value\":\"${{ steps.commit.outputs.short }}\"},{\"title\":\"Status\",\"short\":true,\"value\":\"${{ inputs.success == true && '🟢 SUCCEEDED' || '🔴 FAILED' }}\"},{\"title\":\"Time\",\"short\":true,\"value\":\"${{ steps.date.outputs.date }}\"}]}]}" 44 | env: 45 | SLACK_WEBHOOK_URL: ${{ secrets.RELEASES_SLACK_WEBHOOK_URL }} 46 | SLACK_WEBHOOK_TYPE: "INCOMING_WEBHOOK" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pem 3 | node_modules -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.20.1 -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": false, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Token Lock 2 | 3 | Lock ERC-20 token for a pre-defined amount of time 4 | 5 | This is a mono-repo including two packages: [contract](#contract) and [app](#app) 6 | 7 | --- 8 | 9 | ## Contract 10 | 11 | [![Build Status](https://github.com/gnosis/token-lock/actions/workflows/ci.yml/badge.svg)](https://github.com/gnosis/token-lock/actions/workflows/ci.yml) 12 | [![Coverage Status](https://coveralls.io/repos/github/gnosis/token-lock/badge.svg?branch=master)](https://coveralls.io/github/gnosis/token-lock) 13 | 14 | A contract for locking balances of a designated ERC-20 token for a pre-defined amount of time after a deposit period. 15 | 16 | 1. **Deposit period:** Anyone can deposit the designated token, receiving an equivalent balance of non-transferrable lock claim token. Withdrawals are possible. 17 | 2. **Lock period:** No more deposits and withdrawals are possible. 18 | 3. **After the lock period:** Tokens can be withdrawn in redemption for lock claim tokens. 19 | 20 | ### Setup 21 | 22 | We deploy the contract via the @openzeppelin/hardhat-upgrades plugin so it can be upgraded to modify its code, while preserving the address, state, and balances. 23 | 24 | #### Configuration 25 | 26 | The contract is initialized with the following set of parameters: 27 | 28 | - `owner`: Address of the owner 29 | - `token`: Address of the token to lock 30 | - `depositDeadline`: Unix timestamp (seconds) of the deposit deadline, 31 | - `lockDuration`: Lock duration in seconds, period starts after the deposit deadline 32 | - `name`: Name of the token representing the claim on the locked token, e.g.: "Locked Gnosis" 33 | - `symbol`: Symbol of the token representing the claim on the locked token, e.g.: "LGNO" 34 | 35 | Before running any of the hardhat tasks below, make sure to create a .env file based on the provided .env.template file. 36 | 37 | #### Initial deployment 38 | 39 | Deploys the implementation contract and an upgradable proxy. 40 | When run multiple times, it will create multiple proxies sharing the same implementation contract. 41 | 42 | ``` 43 | cd packages/contracts 44 | yarn build 45 | yarn deploy rinkeby \ 46 | --owner \ 47 | --token 0xd0dab4e640d95e9e8a47545598c33e31bdb53c7c \ 48 | --deposit-deadline 1642498466 \ 49 | --lock-duration 31536000 \ 50 | --name 'Locked Gnosis' \ 51 | --symbol LGNO 52 | ``` 53 | 54 | The task prints the addresses of the proxy and the implementation contracts, which you'll need for verification and future upgrades. 55 | You can also find these values in .openzeppelin/.json. 56 | 57 | #### Verification 58 | 59 | Verifies the implementation contract at the specified address in Etherscan. 60 | 61 | ``` 62 | cd packages/contracts 63 | yarn verify rinkeby --implementation 64 | ``` 65 | 66 | You still need to manually mark the proxy contract as a proxy on Etherscan. 67 | This is done with a [simple click on a button](https://medium.com/etherscan-blog/and-finally-proxy-contract-support-on-etherscan-693e3da0714b) in Etherscan. 68 | 69 | #### Upgrade 70 | 71 | Deploys the latest version of the implementation contract (if necessary) and upgrades the existing proxy contract to use this one. 72 | 73 | ``` 74 | cd packages/contracts 75 | yarn run upgrade rinkeby --proxy 76 | ``` 77 | 78 | (Note that you must not omit `run`, because `upgrade` is also the name of a yarn command.) 79 | 80 | ### Solidity Compiler 81 | 82 | The contracts have been developed with [Solidity 0.8.6](https://github.com/ethereum/solidity/releases/tag/v0.8.6). This version of Solidity made all arithmetic checked by default, therefore eliminating the need for explicit overflow or underflow (or other arithmetic) checks. This version of solidity was chosen as it allows to easily cast bytes to bytes4 and bytes32. 83 | 84 | ### Audits 85 | 86 | [TokenLock.sol](./packages/contracts/contracts/TokenLock.sol) has been audited by the [G0 group](https://github.com/g0-group). 87 | 88 | All issues and notes of the audit have been addressed in commit [f974d1e8643c30d07dc005d10f9389085e74556d](https://github.com/gnosis/token-lock/commit/f974d1e8643c30d07dc005d10f9389085e74556d). 89 | 90 | The audit results are available as a pdf [in this repo](./packages/contracts/audits/GnosisTokenLockJan2022.pdf). 91 | 92 | ### Security and Liability 93 | 94 | All contracts are WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 95 | 96 | --- 97 | 98 | ## App 99 | 100 | A front-end for the token lock contract based on next.js. 101 | 102 | ### Setup 103 | 104 | #### Config 105 | 106 | In local development you might want to connect the app to different contract instances on Rinkeby. To do this edit `CONTRACT_ADDRESSES` map in ./packages/app/config.ts 107 | 108 | #### Start local dev server 109 | 110 | ``` 111 | cd packages/app 112 | yarn install 113 | yarn dev 114 | ``` 115 | 116 | #### Build for production 117 | 118 | ``` 119 | cd packages/app 120 | yarn install 121 | yarn static 122 | ``` 123 | 124 | The output is written to packages/app/out 125 | 126 | --- 127 | 128 | ## License 129 | 130 | Created under the [LGPL-3.0+ license](LICENSE). 131 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "token-lock-monorepo", 3 | "private": true, 4 | "description": "Lock ERC-20 tokens for a pre-defined amount of time", 5 | "scripts": { 6 | "prepare": "husky install", 7 | "postinstall": "yarn contracts:install", 8 | "contracts:install": "cd packages/contracts && yarn install", 9 | "app:install": "cd packages/app && yarn install" 10 | }, 11 | "repository": { 12 | "type": "git" 13 | }, 14 | "author": "jan-felix.schwarz@gnosis.io", 15 | "license": "LGPL-3.0+", 16 | "devDependencies": { 17 | "husky": "7.0.4" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/app/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "@next/next/no-html-link-for-pages": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/app/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /.next/ 4 | /out/ 5 | /build 6 | 7 | # debug 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # local env files 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | # vercel 19 | .vercel 20 | 21 | # typescript 22 | *.tsbuildinfo 23 | -------------------------------------------------------------------------------- /packages/app/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Gnosis 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 | -------------------------------------------------------------------------------- /packages/app/abi/token.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnosis/token-lock/a5d2ffc3a554e19d1bc184c9d6c49bfc0b08da48/packages/app/abi/token.ts -------------------------------------------------------------------------------- /packages/app/abi/tokenLock.ts: -------------------------------------------------------------------------------- 1 | export const TOKEN_LOCK_ABI = [ 2 | { 3 | type: "function", 4 | name: "balanceOf", 5 | constant: true, 6 | stateMutability: "view", 7 | payable: false, 8 | inputs: [ 9 | { 10 | type: "address", 11 | }, 12 | ], 13 | outputs: [ 14 | { 15 | type: "uint256", 16 | }, 17 | ], 18 | }, 19 | { 20 | type: "function", 21 | name: "decimals", 22 | constant: true, 23 | stateMutability: "view", 24 | payable: false, 25 | inputs: [], 26 | outputs: [ 27 | { 28 | type: "uint256", 29 | }, 30 | ], 31 | }, 32 | { 33 | type: "function", 34 | name: "deposit", 35 | constant: false, 36 | payable: false, 37 | inputs: [ 38 | { 39 | type: "uint256", 40 | name: "amount", 41 | }, 42 | ], 43 | outputs: [], 44 | }, 45 | { 46 | type: "function", 47 | name: "depositDeadline", 48 | constant: true, 49 | stateMutability: "view", 50 | payable: false, 51 | inputs: [], 52 | outputs: [ 53 | { 54 | type: "uint256", 55 | }, 56 | ], 57 | }, 58 | { 59 | type: "function", 60 | name: "lockDuration", 61 | constant: true, 62 | stateMutability: "view", 63 | payable: false, 64 | inputs: [], 65 | outputs: [ 66 | { 67 | type: "uint256", 68 | }, 69 | ], 70 | }, 71 | { 72 | type: "function", 73 | name: "token", 74 | constant: true, 75 | stateMutability: "view", 76 | payable: false, 77 | inputs: [], 78 | outputs: [ 79 | { 80 | type: "address", 81 | }, 82 | ], 83 | }, 84 | { 85 | type: "function", 86 | name: "name", 87 | constant: true, 88 | stateMutability: "view", 89 | payable: false, 90 | inputs: [], 91 | outputs: [ 92 | { 93 | type: "string", 94 | }, 95 | ], 96 | }, 97 | { 98 | type: "function", 99 | name: "symbol", 100 | constant: true, 101 | stateMutability: "view", 102 | payable: false, 103 | inputs: [], 104 | outputs: [ 105 | { 106 | type: "string", 107 | }, 108 | ], 109 | }, 110 | { 111 | type: "function", 112 | name: "totalSupply", 113 | constant: true, 114 | stateMutability: "view", 115 | payable: false, 116 | inputs: [], 117 | outputs: [ 118 | { 119 | type: "uint256", 120 | }, 121 | ], 122 | }, 123 | { 124 | type: "function", 125 | name: "withdraw", 126 | constant: false, 127 | payable: false, 128 | inputs: [ 129 | { 130 | type: "uint256", 131 | name: "amount", 132 | }, 133 | ], 134 | outputs: [], 135 | }, 136 | ] -------------------------------------------------------------------------------- /packages/app/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css" 2 | import { ProvideConfig } from "../components" 3 | import ContextProvider from "../context" 4 | import { Metadata } from "next" 5 | 6 | export const metadata: Metadata = { 7 | title: "Gnosis Lock", 8 | description: "Gnosis Lock", 9 | } 10 | 11 | export default async function RootLayout({ 12 | children, 13 | }: Readonly<{ 14 | children: React.ReactNode 15 | }>) { 16 | 17 | return ( 18 | 19 | 20 | 21 | {children} 22 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /packages/app/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import styles from "../styles/Home.module.css" 3 | import { CHAINS } from "../config" 4 | import { 5 | Connect, 6 | ConnectHint, 7 | GnosisLogo, 8 | LockedGnoLogo, 9 | LockedBalance, 10 | useTokenLockConfig, 11 | Withdraw, 12 | DepositAndWithdraw, 13 | StatsDeposit, 14 | StatsLocked, 15 | StatsWithdraw, 16 | } from "../components" 17 | import UseGNOBanner from "../components/UseGnoBanner" 18 | import { useChainId } from "wagmi" 19 | 20 | export default function Page() { 21 | const config = useTokenLockConfig() 22 | const chainId = useChainId() 23 | 24 | const connected = chainId && CHAINS.some(({ id }) => id === chainId) 25 | 26 | const depositPeriodOngoing = config.depositDeadline.getTime() > Date.now() 27 | const lockPeriodOngoing = 28 | config.depositDeadline.getTime() < Date.now() && 29 | config.depositDeadline.getTime() + config.lockDuration > Date.now() 30 | const lockPeriodOver = 31 | config.depositDeadline.getTime() + config.lockDuration < Date.now() 32 | 33 | return ( 34 |
35 |
36 | 37 | 38 | 39 |
40 | 41 |
42 | 43 | {depositPeriodOngoing && ( 44 | <> 45 | 46 | {connected && } 47 | 48 | )} 49 | 50 | {lockPeriodOngoing && ( 51 | <> 52 | 53 | {connected && } 54 | 55 | )} 56 | 57 | {lockPeriodOver && ( 58 | <> 59 | 60 | {connected && } 61 | 62 | )} 63 | 64 | {!connected && } 65 |
66 | 67 | 159 |
160 | ) 161 | } 162 | -------------------------------------------------------------------------------- /packages/app/components/AmountInput.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, ReactNode, useEffect, useState } from "react" 2 | import cls from "./Input.module.css" 3 | import Field from "./Field" 4 | import React from "react" 5 | import { BigNumber } from "ethers" 6 | import { formatUnits, parseUnits } from "ethers/lib/utils" 7 | 8 | type Props = ComponentProps & { 9 | value: BigNumber | undefined 10 | max: BigNumber | undefined 11 | onChange(value: BigNumber | undefined): void 12 | decimals: number 13 | disabled?: boolean 14 | unit?: ReactNode 15 | name?: string 16 | } 17 | 18 | const sanitize = (str: string) => { 19 | // keep only numbers and . 20 | let result = str.replace(/[^0-9\.]/g, "") 21 | // prepend a 0 if starts with . 22 | result = result.startsWith(".") ? `0${result}` : result 23 | // remove all . chars after the first 24 | const i = result.indexOf(".") 25 | return result.substring(0, i + 1) + result.substring(i + 1).replace(/\./g, "") 26 | } 27 | 28 | const AmountInput = React.forwardRef( 29 | ( 30 | { 31 | name, 32 | label, 33 | value, 34 | max, 35 | decimals, 36 | disabled, 37 | onChange, 38 | meta, 39 | unit, 40 | className, 41 | }, 42 | ref 43 | ) => { 44 | const [state, setState] = useState( 45 | value ? formatUnits(value, decimals) : "" 46 | ) 47 | 48 | useEffect(() => { 49 | let parsed: BigNumber | undefined = undefined 50 | try { 51 | parsed = parseUnits(state, decimals) 52 | } catch (e) {} 53 | 54 | if (parsed && value && parsed.eq(value)) { 55 | return 56 | } 57 | 58 | setState(value ? formatUnits(value, decimals) : "") 59 | }, [state, value, decimals]) 60 | 61 | return ( 62 | 63 |
64 | { 73 | const value = sanitize(ev.target.value) 74 | setState(value) 75 | 76 | let parsed: BigNumber | undefined = undefined 77 | try { 78 | parsed = parseUnits(value, decimals) 79 | } catch (e) { 80 | } finally { 81 | onChange(parsed) 82 | } 83 | }} 84 | /> 85 | {unit && {unit}} 86 |
87 | {value && max && value.gt(max) && ( 88 |
89 | You've entered an amount that exceeds your balance. 90 |
91 | )} 92 |
93 | ) 94 | } 95 | ) 96 | AmountInput.displayName = "AmountInput" 97 | 98 | export default AmountInput 99 | -------------------------------------------------------------------------------- /packages/app/components/Balance.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | border-radius: 0.5em; 3 | display: flex; 4 | background: #f0efee; 5 | height: 56px; 6 | align-items: center; 7 | position: relative; 8 | } 9 | 10 | .wrapper.hasLocked { 11 | border-radius: 0.5em 0 0 0.5em; 12 | width: calc(100% - 28px); 13 | } 14 | 15 | .icon { 16 | margin: 0 12px; 17 | display: flex; 18 | } 19 | 20 | .balanceInUsd { 21 | font-size: 0.875em; 22 | color: #5d6d74; 23 | } 24 | 25 | .percentLockedWrapper { 26 | background: #ffffff; 27 | border-radius: 999px; 28 | border: 4px solid #d2d2d1; 29 | position: absolute; 30 | display: flex; 31 | justify-content: center; 32 | align-items: center; 33 | flex-direction: column; 34 | right: 0; 35 | top: 0; 36 | bottom: 0; 37 | aspect-ratio: 1/1; 38 | transform: translateX(50%); 39 | } 40 | 41 | .percentLockedIndicator { 42 | position: absolute; 43 | } 44 | .percentLockedCircle { 45 | transition: 0.35s stroke-dashoffset; 46 | transform: rotate(-90deg); 47 | transform-origin: 50% 50%; 48 | } 49 | 50 | .percentLockedAmount { 51 | font-size: 12px; 52 | font-weight: 700; 53 | } 54 | 55 | .percentLockedTitle { 56 | color: #5d6d74; 57 | font-size: 8px; 58 | text-transform: uppercase; 59 | } 60 | -------------------------------------------------------------------------------- /packages/app/components/Balance.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react" 2 | 3 | import Field from "./Field" 4 | import cls from "./Balance.module.css" 5 | import { useTokenContractRead } from "./tokenContract" 6 | import { useAccount } from "wagmi" 7 | import { useTokenLockContractRead } from "./tokenLockContract" 8 | import { BigNumber } from "ethers" 9 | import { formatUnits } from "ethers/lib/utils" 10 | import useTokenPrice from "./useTokenPrice" 11 | import clsx from "clsx" 12 | import useTokenLockConfig from "./useTokenLockConfig" 13 | import PercentOfTotalHint from "./PercentOfTotalHint" 14 | 15 | const CIRCLE_RADIUS = 26 16 | const CIRCUMFERENCE = CIRCLE_RADIUS * 2 * Math.PI 17 | 18 | const formatToken = (bigNumber: BigNumber, decimals: number) => 19 | new Intl.NumberFormat("en-US", { 20 | maximumSignificantDigits: 6, 21 | }).format(parseFloat(formatUnits(bigNumber, decimals))) 22 | 23 | const formatUsd = (number: number) => 24 | new Intl.NumberFormat("en-US", { 25 | maximumSignificantDigits: 2, 26 | }).format(number) 27 | 28 | type Props = ComponentProps & { 29 | lockToken?: boolean 30 | } 31 | const Balance: React.FC = ({ lockToken, ...rest }) => { 32 | const { decimals, tokenName, lockTokenName, tokenSymbol, lockTokenSymbol } = 33 | useTokenLockConfig() 34 | const accountData = useAccount() 35 | 36 | const { data: balanceTokenData } = useTokenContractRead("balanceOf", { 37 | args: [accountData.address], 38 | enabled: !!accountData.address, 39 | watch: true, 40 | }) 41 | const { data: balanceLockTokenData } = useTokenLockContractRead("balanceOf", { 42 | args: [accountData?.address], 43 | enabled: !!accountData?.address, 44 | watch: true, 45 | }) 46 | 47 | const balanceToken = 48 | balanceTokenData === undefined 49 | ? undefined 50 | : BigNumber.from(balanceTokenData) 51 | const balanceLockToken = 52 | balanceLockTokenData === undefined 53 | ? undefined 54 | : BigNumber.from(balanceLockTokenData) 55 | 56 | const percentLocked = 57 | balanceLockToken && balanceToken && balanceLockToken.gt(0) 58 | ? balanceLockToken 59 | .mul(100) 60 | .div(balanceLockToken.add(balanceToken)) 61 | .toNumber() 62 | : 0 63 | 64 | const balance = lockToken ? balanceLockToken : balanceToken 65 | 66 | const tokenPrice = useTokenPrice() 67 | const balanceInUsd = 68 | tokenPrice && 69 | balance && 70 | parseFloat(formatUnits(balance || 0, decimals)) * tokenPrice 71 | 72 | return ( 73 | } 76 | > 77 |
78 |
79 | {lockToken 85 |
86 | 87 |
88 |
89 | {balance ? formatToken(balance, decimals) : "…"}{" "} 90 | {lockToken ? lockTokenSymbol : tokenSymbol} 91 |
92 | {balanceInUsd !== undefined && ( 93 |
94 | {!balance?.isZero() && "~"}$ {formatUsd(balanceInUsd)} 95 |
96 | )} 97 |
98 | {balance && lockToken && ( 99 |
100 | 107 | 121 | 122 | {balanceToken && balanceLockToken && ( 123 | <> 124 |
{percentLocked}%
125 |
Locked
126 | 127 | )} 128 |
129 | )} 130 |
131 |
132 | ) 133 | } 134 | 135 | export default Balance 136 | -------------------------------------------------------------------------------- /packages/app/components/Button.module.css: -------------------------------------------------------------------------------- 1 | .default { 2 | background: transparent; 3 | border-radius: 8px; 4 | border: 2px solid #001428; 5 | box-shadow: 1px 2px 10px rgba(40, 54, 61, 0.18); 6 | color: #001428; 7 | cursor: pointer; 8 | display: block; 9 | font-size: 1em; 10 | font-weight: 700; 11 | line-height: 46px; 12 | margin: 0.5em auto; 13 | max-width: 360px; 14 | transition: background 0.12s ease-in-out; 15 | width: 100%; 16 | } 17 | .default:hover { 18 | background: #04203d; 19 | } 20 | .default:disabled { 21 | cursor: not-allowed; 22 | opacity: 0.4; 23 | } 24 | 25 | .primary { 26 | background: #001428; 27 | border-radius: 8px; 28 | border: none; 29 | box-shadow: 1px 2px 10px rgba(40, 54, 61, 0.18); 30 | color: white; 31 | cursor: pointer; 32 | display: flex; 33 | flex-wrap: nowrap; 34 | justify-content: center; 35 | font-size: 1em; 36 | font-weight: 700; 37 | line-height: 50px; 38 | margin: 1em auto 0.5em; 39 | max-width: 360px; 40 | white-space: nowrap; 41 | transition: background 0.12s ease-in-out; 42 | width: 100%; 43 | } 44 | .primary:hover { 45 | background: #04203d; 46 | } 47 | .primary:disabled { 48 | color: #b3aeb7; 49 | cursor: not-allowed; 50 | opacity: 0.4; 51 | } 52 | 53 | .link { 54 | background: none; 55 | border: none; 56 | color: #001428; 57 | cursor: pointer; 58 | display: inline; 59 | padding: 0; 60 | text-decoration: underline; 61 | } 62 | 63 | .link:hover { 64 | color: #5d6d74; 65 | } 66 | -------------------------------------------------------------------------------- /packages/app/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | ButtonHTMLAttributes, 3 | ComponentProps, 4 | DetailedHTMLProps, 5 | } from "react" 6 | import clsx from "clsx" 7 | import cls from "./Button.module.css" 8 | 9 | type Props = ComponentProps<"button"> & { 10 | primary?: boolean 11 | link?: boolean 12 | } 13 | const Button: React.FC = ({ className, primary, link, ...rest }) => ( 14 | 45 | 46 | {showDropdown && ( 47 |
48 | {isConnected ? ( 49 | <> 50 |
51 |
52 | 53 |
54 | {address && ( 55 |
56 |
57 | {ensName 58 | ? `${ensName} (${truncateEthAddress(address)})` 59 | : truncateEthAddress(address)} 60 |
61 | { 63 | copy(address) 64 | }} 65 | icon="copy" 66 | title="Copy to clipboard" 67 | /> 68 | {explorer && ( 69 | 75 | )} 76 |
77 | )} 78 |
79 |
80 |
81 | Status 82 |
83 |
84 | Connected 85 |
86 |
87 |
88 |
89 | Network 90 |
91 |
92 | {publicClient?.chain?.name || "Unsupported network"} 93 |
94 |
95 | {connector?.id !== "gnosisSafe" && ( 96 | <> 97 |
98 |
99 | 109 |
110 | 111 | )} 112 | 113 | ) : ( 114 |
115 | Connect a Wallet 116 |
117 | 118 |
119 |
120 | 127 |
128 |
129 | )} 130 |
131 | )} 132 |
133 | 134 | ) 135 | } 136 | 137 | export default Connect 138 | -------------------------------------------------------------------------------- /packages/app/components/ConnectHint.tsx: -------------------------------------------------------------------------------- 1 | import { useAccount, useSwitchChain, useChainId } from "wagmi" 2 | import { CHAINS } from "../config" 3 | import Card from "./Card" 4 | import Button from "./Button" 5 | import { useAppKit } from "@reown/appkit/react" 6 | 7 | const ConnectHint: React.FC = () => { 8 | const { isConnected } = useAccount() 9 | const chainId = useChainId() 10 | const { switchChain } = useSwitchChain() 11 | 12 | const { open } = useAppKit() 13 | 14 | const connectedToUnsupportedChain = 15 | isConnected && !CHAINS.some(({ id }) => id === chainId) 16 | 17 | if (chainId && !connectedToUnsupportedChain) { 18 | return null 19 | } 20 | 21 | return ( 22 | 23 | {!isConnected && ( 24 | 27 | )} 28 | {connectedToUnsupportedChain && ( 29 | 36 | )} 37 | 38 | ) 39 | } 40 | 41 | export default ConnectHint 42 | -------------------------------------------------------------------------------- /packages/app/components/Deposit.tsx: -------------------------------------------------------------------------------- 1 | import { BigNumber } from "ethers" 2 | import { useEffect, useMemo, useState } from "react" 3 | import { 4 | useAccount, 5 | useChainId, 6 | useWaitForTransactionReceipt, 7 | useWriteContract, 8 | } from "wagmi" 9 | import { CONTRACT_ADDRESSES } from "../config" 10 | import Balance from "./Balance" 11 | import Button from "./Button" 12 | import Card from "./Card" 13 | import AmountInput from "./AmountInput" 14 | import Spinner from "./Spinner" 15 | import { useTokenContractRead } from "./tokenContract" 16 | import useTokenLockConfig from "./useTokenLockConfig" 17 | import utility from "../styles/utility.module.css" 18 | import Notice from "./Notice" 19 | import { erc20Abi } from "viem" 20 | import { TOKEN_LOCK_ABI } from "../abi/tokenLock" 21 | 22 | const Deposit: React.FC = () => { 23 | const [amount, setAmount] = useState(undefined) 24 | 25 | const [dismissedErrors, setDismissedErrors] = useState([]) 26 | 27 | const chainId = useChainId() 28 | const { decimals, tokenSymbol } = useTokenLockConfig() 29 | const accountData = useAccount() 30 | const { data: balanceOf } = useTokenContractRead("balanceOf", { 31 | args: [accountData?.address], 32 | enabled: !!accountData?.address, 33 | watch: true, 34 | }) 35 | 36 | const balance = 37 | balanceOf === undefined ? undefined : BigNumber.from(balanceOf) 38 | 39 | const contractAddress = CONTRACT_ADDRESSES[chainId] 40 | const allowanceArgs = useMemo( 41 | () => [accountData?.address, contractAddress], 42 | [accountData?.address, contractAddress] 43 | ) 44 | const { data: allowance } = useTokenContractRead("allowance", { 45 | args: allowanceArgs, 46 | enabled: !!accountData?.address, 47 | watch: true, 48 | }) 49 | 50 | const { 51 | data: hash, 52 | error: txError, 53 | isPending, 54 | writeContract, 55 | } = useWriteContract() 56 | const { tokenAddress } = useTokenLockConfig() 57 | 58 | function approve() { 59 | writeContract({ 60 | address: tokenAddress as `0x${string}`, 61 | abi: erc20Abi, 62 | functionName: "approve", 63 | args: [contractAddress as `0x${string}`, amount?.toBigInt() || BigInt(0)], 64 | }) 65 | } 66 | 67 | const approveWait = useWaitForTransactionReceipt({ 68 | hash: hash, 69 | }) 70 | 71 | function deposit() { 72 | writeContract({ 73 | address: CONTRACT_ADDRESSES[chainId] as `0x${string}`, 74 | abi: TOKEN_LOCK_ABI, 75 | functionName: "deposit", 76 | args: [amount?.toBigInt()], 77 | }) 78 | } 79 | 80 | const depositWait = useWaitForTransactionReceipt({ 81 | hash: hash, 82 | }) 83 | 84 | const needsAllowance = 85 | amount && amount.gt(0) && allowance && BigNumber.from(allowance).lt(amount) 86 | 87 | const approvePending = isPending || approveWait.isLoading 88 | const depositPending = isPending || depositWait.isLoading 89 | 90 | const error = txError || approveWait.error || depositWait.error 91 | 92 | // clear input after successful deposit 93 | const depositedBlock = depositWait.data?.blockHash 94 | useEffect(() => { 95 | if (depositedBlock) { 96 | setAmount(undefined) 97 | } 98 | }, [depositedBlock]) 99 | 100 | return ( 101 | 102 | 103 | { 119 | if (balance) { 120 | setAmount(balance) 121 | } 122 | }} 123 | > 124 | Lock Max 125 | 126 | } 127 | /> 128 | {needsAllowance ? ( 129 | 142 | ) : ( 143 | 160 | )} 161 | 162 | 163 | 164 | {error && !dismissedErrors.includes(error) && ( 165 | { 167 | setDismissedErrors([...dismissedErrors, error]) 168 | }} 169 | > 170 | {error.message} 171 | 172 | )} 173 | 174 | ) 175 | } 176 | 177 | export default Deposit 178 | -------------------------------------------------------------------------------- /packages/app/components/DepositAndWithdraw.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | } 3 | 4 | .header { 5 | display: flex; 6 | margin-bottom: 8px; 7 | } 8 | 9 | .title { 10 | background: none; 11 | border-bottom: 4px solid transparent !important; 12 | border: 0; 13 | color: #5D6D74; 14 | cursor: pointer; 15 | flex-grow: 1; 16 | font-size: 1em; 17 | font-weight: bold; 18 | padding: 6px 0; 19 | transition: opacity 0.12 ease-in-out; 20 | } 21 | .title:not(.active):hover { 22 | opacity: 0.7; 23 | } 24 | .title.active { 25 | border-bottom: 4px solid #001428 !important; 26 | color: #001428; 27 | cursor: default; 28 | } 29 | -------------------------------------------------------------------------------- /packages/app/components/DepositAndWithdraw.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import clsx from "clsx" 3 | import { useState } from "react" 4 | import Deposit from "./Deposit" 5 | import Withdraw from "./Withdraw" 6 | import cls from "./DepositAndWithdraw.module.css" 7 | 8 | const DepositAndWithdraw: React.FC<{}> = () => { 9 | const [activeTab, setActiveTab] = useState<"deposit" | "withdraw">("deposit") 10 | return ( 11 |
12 |
13 | 19 | 25 |
26 |
27 | {activeTab === "deposit" && } 28 | {activeTab === "withdraw" && } 29 |
30 |
31 | ) 32 | } 33 | 34 | export default DepositAndWithdraw 35 | -------------------------------------------------------------------------------- /packages/app/components/Field.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: block; 3 | } 4 | 5 | .header { 6 | display: flex; 7 | } 8 | 9 | .label { 10 | font-size: 0.875em; 11 | font-weight: 700; 12 | color: #5d6d74; 13 | display: block; 14 | } 15 | 16 | .meta { 17 | font-weight: 400; 18 | margin-left: auto; 19 | font-size: 0.875em; 20 | } 21 | -------------------------------------------------------------------------------- /packages/app/components/Field.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react" 2 | import clsx from "clsx" 3 | import cls from "./Field.module.css" 4 | 5 | type Props = { 6 | className?: string 7 | label?: ReactNode 8 | meta?: ReactNode 9 | htmlFor?: string 10 | } 11 | 12 | const Field: React.FC = ({ htmlFor, label, meta, children, className }) => ( 13 | 20 | ) 21 | 22 | export default Field 23 | -------------------------------------------------------------------------------- /packages/app/components/GnosisLogo.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | justify-content: flex-start; 4 | width: 100%; 5 | } 6 | 7 | .logo { 8 | cursor: pointer; 9 | transition: opacity 0.12s ease-in-out; 10 | } 11 | 12 | .logo:hover { 13 | opacity: 0.7; 14 | } -------------------------------------------------------------------------------- /packages/app/components/GnosisLogo.tsx: -------------------------------------------------------------------------------- 1 | import cls from "./GnosisLogo.module.css" 2 | 3 | const GnosisLogo: React.FC = () => ( 4 |
5 | Gnosis Logo 12 |
13 | ) 14 | 15 | export default GnosisLogo 16 | -------------------------------------------------------------------------------- /packages/app/components/IconButton.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | background-color: transparent; 3 | border: none; 4 | cursor: pointer; 5 | border-radius: 12px; 6 | width: 24px; 7 | height: 24px; 8 | display: block; 9 | padding: 0; 10 | align-items: center; 11 | justify-content: center; 12 | display: flex; 13 | } 14 | 15 | .button:hover { 16 | background-color: #f0efee; 17 | } 18 | -------------------------------------------------------------------------------- /packages/app/components/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import cls from "./IconButton.module.css" 2 | 3 | interface Props { 4 | onClick?(): void 5 | icon: string 6 | title: string 7 | } 8 | 9 | const IconButton: React.FC = ({ onClick, icon, title }) => ( 10 | 13 | ) 14 | 15 | type LinkProps = Props & { 16 | href: string 17 | external: boolean 18 | } 19 | 20 | export const IconLinkButton: React.FC = ({ 21 | href, 22 | onClick, 23 | icon, 24 | title, 25 | external, 26 | }) => ( 27 | 34 | {title} 35 | 36 | ) 37 | 38 | export default IconButton 39 | -------------------------------------------------------------------------------- /packages/app/components/Input.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | background: #f0efee; 3 | border-radius: 0.5em; 4 | color: #5D6D74; 5 | display: flex; 6 | line-height: 56px; 7 | } 8 | 9 | .input { 10 | background: none; 11 | border: none; 12 | flex-grow: 1; 13 | font-family: Averta, sans-serif; 14 | font-size: 1em; 15 | padding: 0 16px; 16 | } 17 | 18 | .unit { 19 | padding: 0 16px; 20 | } 21 | 22 | .errorText { 23 | color: #F02525; 24 | font-size: 12px; 25 | } -------------------------------------------------------------------------------- /packages/app/components/LockedBalance.tsx: -------------------------------------------------------------------------------- 1 | import Balance from "./Balance" 2 | import Card from "./Card" 3 | import utility from "../styles/utility.module.css" 4 | 5 | const LockedBalance: React.FC = () => ( 6 | 7 | 8 | 9 | ) 10 | 11 | export default LockedBalance 12 | -------------------------------------------------------------------------------- /packages/app/components/LockedGnoLogo.module.css: -------------------------------------------------------------------------------- 1 | .heading { 2 | display: flex; 3 | align-items: center; 4 | gap: 8px; 5 | } 6 | 7 | .logo { 8 | margin-right: 0.5em; 9 | width: 36px; 10 | height: 36px; 11 | font-size: 0; 12 | color: transparent; 13 | display: inline-block; 14 | } 15 | .locked { 16 | background-image: url("/lock.svg"); 17 | } 18 | .unlocked { 19 | background-image: url("/unlocked.svg"); 20 | } 21 | 22 | .gno { 23 | font-size: 32px; 24 | font-weight: 900; 25 | letter-spacing: 1px; 26 | transform: translateY(3px); 27 | } 28 | -------------------------------------------------------------------------------- /packages/app/components/LockedGnoLogo.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx" 2 | import cls from "./LockedGnoLogo.module.css" 3 | 4 | type Props = { 5 | locked?: boolean 6 | } 7 | 8 | const LockedGnoLogo: React.FC = ({ locked }) => ( 9 |

10 | 11 | Lock 12 | 13 | GNO 14 |

15 | ) 16 | 17 | export default LockedGnoLogo 18 | -------------------------------------------------------------------------------- /packages/app/components/Notice.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background: #ffffff; 3 | border-radius: 8px; 4 | border-top: 4px solid #f02525; 5 | bottom: 80px; 6 | box-shadow: 0 24px 24px rgba(0, 0, 0, 0.05); 7 | color: #5d6d74; 8 | left: 1em; 9 | padding: 2rem; 10 | position: fixed; 11 | right: 1em; 12 | z-index: 999; 13 | } 14 | 15 | .close { 16 | position: absolute; 17 | right: 0.5rem; 18 | top: 0.5rem; 19 | } 20 | 21 | @media screen and (min-width: 650px) { 22 | .container { 23 | left: auto; 24 | max-width: 350px; 25 | right: 2em; 26 | } 27 | } 28 | 29 | .noticeTitle { 30 | font-weight: 700; 31 | margin-bottom: 1em; 32 | } 33 | 34 | .errorText { 35 | font-family: monospace; 36 | font-size: 14px; 37 | max-height: calc(100vh - 300px); 38 | overflow: auto; 39 | } 40 | -------------------------------------------------------------------------------- /packages/app/components/Notice.tsx: -------------------------------------------------------------------------------- 1 | import IconButton from "./IconButton" 2 | import cls from "./Notice.module.css" 3 | 4 | const Notice: React.FC<{ onDismiss: () => void }> = ({ 5 | children, 6 | onDismiss, 7 | }) => ( 8 |
9 |
10 | 11 |
12 |
There was an error. Please try again.
13 |
{children}
14 |
15 | ) 16 | 17 | export default Notice 18 | -------------------------------------------------------------------------------- /packages/app/components/PercentOfTotal.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | gap: 4px; 4 | } 5 | 6 | .infoBubble { 7 | position: relative; 8 | } 9 | .infoBubble img { 10 | width: 16px; 11 | height: 16px; 12 | cursor: help; 13 | } 14 | 15 | .dropdown { 16 | background: #ffffff; 17 | border-radius: 0.5rem; 18 | box-shadow: 1px 2px 10px rgba(40, 54, 61, 0.18); 19 | padding: 0.5rem 1rem; 20 | position: absolute; 21 | right: 0; 22 | bottom: calc(100% + 4px); 23 | width: 360px; 24 | display: none; 25 | z-index: 99; 26 | } 27 | .infoBubble:hover .dropdown { 28 | display: block; 29 | } 30 | -------------------------------------------------------------------------------- /packages/app/components/PercentOfTotalHint.tsx: -------------------------------------------------------------------------------- 1 | import { BigNumber } from "ethers" 2 | import cls from "./PercentOfTotal.module.css" 3 | import useTotalLocked from "./useTotalLocked" 4 | 5 | const SUPPLY_OF_COW = 50000000 6 | 7 | const formatAmount = (amount: number) => 8 | new Intl.NumberFormat("en-US", { 9 | maximumSignificantDigits: 6, 10 | }).format(amount) 11 | 12 | const PercentOfTotalHint: React.FC<{ balance?: BigNumber }> = ({ balance }) => { 13 | const [totalLocked] = useTotalLocked() 14 | 15 | const percent = 16 | balance && totalLocked && totalLocked.gt(0) 17 | ? balance.mul(100).mul(1e4).div(totalLocked).toNumber() / 1e4 // precision to 4 decimal places 18 | : 0 19 | 20 | if (percent === 0) return null 21 | 22 | return ( 23 |
24 | {percent}% of total 25 |
26 | Learn more 27 |
28 |

29 | Your current share of the total locked GNO would give you a grant of 30 | 31 | ~{formatAmount((percent / 100) * SUPPLY_OF_COW)} $COW. 32 | {" "} 33 | This value will go down as more people lock their GNO. 34 |

35 |

36 | A total of 50M $COW are distributed to people committing to hold 37 | their GNO long-term. 38 |

39 |
40 |
41 |
42 | ) 43 | } 44 | 45 | export default PercentOfTotalHint 46 | -------------------------------------------------------------------------------- /packages/app/components/Spinner.module.css: -------------------------------------------------------------------------------- 1 | .spinner { 2 | padding: 0 8px; 3 | text-align: center; 4 | display: inline-block; 5 | } 6 | 7 | .spinner > div { 8 | width: 8px; 9 | height: 8px; 10 | background-color: white; 11 | 12 | border-radius: 100%; 13 | display: inline-block; 14 | -webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both; 15 | animation: sk-bouncedelay 1.4s infinite ease-in-out both; 16 | } 17 | 18 | .spinner .bounce1 { 19 | -webkit-animation-delay: -0.32s; 20 | animation-delay: -0.32s; 21 | } 22 | 23 | .spinner .bounce2 { 24 | -webkit-animation-delay: -0.16s; 25 | animation-delay: -0.16s; 26 | } 27 | 28 | @-webkit-keyframes sk-bouncedelay { 29 | 0%, 30 | 80%, 31 | 100% { 32 | -webkit-transform: scale(0); 33 | } 34 | 40% { 35 | -webkit-transform: scale(1); 36 | } 37 | } 38 | 39 | @keyframes sk-bouncedelay { 40 | 0%, 41 | 80%, 42 | 100% { 43 | -webkit-transform: scale(0); 44 | transform: scale(0); 45 | } 46 | 40% { 47 | -webkit-transform: scale(1); 48 | transform: scale(1); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/app/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import cls from "./Spinner.module.css" 2 | 3 | const Spinner: React.FC = () => ( 4 |
5 |
6 |
7 |
8 |
9 | ) 10 | 11 | export default Spinner 12 | -------------------------------------------------------------------------------- /packages/app/components/UseGnoBanner/UseGnoBanner.module.css: -------------------------------------------------------------------------------- 1 | .cardLink { 2 | } 3 | 4 | .card { 5 | background-image: url(/gnochainbg.svg); 6 | background-size: 120%; 7 | background-position: center; 8 | padding: 30px; 9 | position: relative; 10 | color: white; 11 | box-shadow: 1px 2px 10px 0px #0b243185; 12 | transition: all 0.3s ease; 13 | } 14 | 15 | .cardLink:hover .card { 16 | transform: translateY(-2px); 17 | filter: brightness(1.06); 18 | box-shadow: 2px 4px 15px 0px #0b243185; 19 | } 20 | 21 | .card img { 22 | position: absolute; 23 | right: -75px; 24 | top: -10px; 25 | height: 120%; 26 | } 27 | 28 | .card h2 { 29 | margin: 0 0 0.5rem 0; 30 | font-size: 1.2rem; 31 | } 32 | 33 | .card p { 34 | margin: 0; 35 | width: 70%; 36 | line-height: 1.5; 37 | } 38 | 39 | @media screen and (max-width: 650px) { 40 | .card { 41 | overflow: hidden; 42 | background-size: initial; 43 | } 44 | .card h2, 45 | .card p { 46 | width: 60%; 47 | } 48 | 49 | .card img { 50 | right: -100px; 51 | top: -5px; 52 | height: 120%; 53 | } 54 | } 55 | 56 | @media screen and (max-width: 415px) { 57 | .card img { 58 | right: -130px; 59 | top: -5px; 60 | height: 120%; 61 | } 62 | } 63 | 64 | @media screen and (max-width: 350px) { 65 | .card h2, 66 | .card p { 67 | width: 100%; 68 | text-align: center; 69 | } 70 | .card img { 71 | display: none; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/app/components/UseGnoBanner/index.tsx: -------------------------------------------------------------------------------- 1 | import Card from "../Card" 2 | 3 | import classes from "./UseGnoBanner.module.css" 4 | 5 | const UseGNOBanner = () => { 6 | return ( 7 | 8 | 9 | an imagined solarpunk future 10 |

Not sure where to use your GNO?

11 |

12 | Click here to explore all the valuable uses on Gnosis Chain and 13 | Ethereum 14 |

15 |
16 |
17 | ) 18 | } 19 | 20 | export default UseGNOBanner 21 | -------------------------------------------------------------------------------- /packages/app/components/Withdraw.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { BigNumber } from "ethers" 3 | import { useEffect, useState } from "react" 4 | import { 5 | useAccount, 6 | useChainId, 7 | useWaitForTransactionReceipt, 8 | useWriteContract, 9 | } from "wagmi" 10 | import Balance from "./Balance" 11 | import Button from "./Button" 12 | import Card from "./Card" 13 | import AmountInput from "./AmountInput" 14 | import Spinner from "./Spinner" 15 | import utility from "../styles/utility.module.css" 16 | import { 17 | useTokenLockContractRead, 18 | } from "./tokenLockContract" 19 | import useTokenLockConfig from "./useTokenLockConfig" 20 | import Notice from "./Notice" 21 | import { CONTRACT_ADDRESSES } from "../config" 22 | import { TOKEN_LOCK_ABI } from "../abi/tokenLock" 23 | 24 | const Withdraw: React.FC = () => { 25 | const [amount, setAmount] = useState(undefined) 26 | 27 | const [dismissedErrors, setDismissedErrors] = useState([]) 28 | 29 | const { decimals, tokenSymbol } = useTokenLockConfig() 30 | const accountData = useAccount() 31 | const { data: balanceOf } = useTokenLockContractRead("balanceOf", { 32 | args: [accountData.address], 33 | enabled: !!accountData?.address, 34 | watch: true, 35 | }) 36 | 37 | const balance = 38 | balanceOf === undefined ? undefined : BigNumber.from(balanceOf) 39 | 40 | const { 41 | data: hash, 42 | error: txError, 43 | isPending, 44 | writeContract, 45 | } = useWriteContract() 46 | 47 | const chainId = useChainId() 48 | function withdraw() { 49 | writeContract({ 50 | address: CONTRACT_ADDRESSES[chainId] as `0x${string}`, 51 | abi: TOKEN_LOCK_ABI, 52 | functionName: "withdraw", 53 | args: [amount?.toBigInt()], 54 | }) 55 | } 56 | 57 | const wait = useWaitForTransactionReceipt({ 58 | hash: hash, 59 | }) 60 | 61 | const pending = isPending || wait.isLoading 62 | const error = txError || wait.error 63 | 64 | // clear input after successful deposit 65 | const withdrawnBlock = wait.data?.blockHash 66 | useEffect(() => { 67 | if (withdrawnBlock) { 68 | setAmount(undefined) 69 | } 70 | }, [withdrawnBlock]) 71 | 72 | return ( 73 | 74 | 75 | { 89 | if (balance) { 90 | setAmount(balance) 91 | } 92 | }} 93 | > 94 | Unlock Max 95 | 96 | } 97 | /> 98 | 99 | 116 | 117 | 118 | 119 | {error && !dismissedErrors.includes(error) && ( 120 | { 122 | setDismissedErrors([...dismissedErrors, error]) 123 | }} 124 | > 125 | {error.message} 126 | 127 | )} 128 | 129 | ) 130 | } 131 | 132 | export default Withdraw 133 | -------------------------------------------------------------------------------- /packages/app/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./stats" 2 | export { default as Connect } from "./Connect" 3 | export { default as ConnectHint } from "./ConnectHint" 4 | export { default as GnosisLogo } from "./GnosisLogo" 5 | export { default as LockedGnoLogo } from "./LockedGnoLogo" 6 | export { default as DepositAndWithdraw } from "./DepositAndWithdraw" 7 | export { default as LockedBalance } from "./LockedBalance" 8 | export { default as Withdraw } from "./Withdraw" 9 | export { 10 | default as useTokenLockConfig, 11 | ProvideConfig, 12 | } from "./useTokenLockConfig" 13 | -------------------------------------------------------------------------------- /packages/app/components/stats/Stats.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-wrap: wrap; 4 | text-align: center; 5 | margin: 0; 6 | } 7 | 8 | .item { 9 | margin-top: 1rem; 10 | width: 50%; 11 | } 12 | .fullWidth { 13 | margin-top: 2rem; 14 | width: 100%; 15 | } 16 | 17 | .item > dt { 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | } 22 | .label { 23 | color: #5d6d74; 24 | font-size: 0.75em; 25 | font-weight: 700; 26 | } 27 | 28 | .item > dd { 29 | margin: 0; 30 | font-size: 1.5em; 31 | font-weight: 700; 32 | } 33 | 34 | .time { 35 | font-size: 14px; 36 | font-weight: 400; 37 | } 38 | -------------------------------------------------------------------------------- /packages/app/components/stats/StatsDeposit.tsx: -------------------------------------------------------------------------------- 1 | import Card from "../Card" 2 | import cls from "./Stats.module.css" 3 | import useTokenLockConfig from "../useTokenLockConfig" 4 | import formatDuration from "./formatDuration" 5 | import TotalLockedStat from "./TotalLockedStat" 6 | 7 | const StatsDeposit: React.FC = () => { 8 | const config = useTokenLockConfig() 9 | const deadlineIsToday = 10 | config.depositDeadline.toDateString() === new Date().toDateString() 11 | return ( 12 | 13 |
14 |
15 |
Lock Deadline
16 |
17 | {new Intl.DateTimeFormat("default", { dateStyle: "medium" }).format( 18 | config.depositDeadline 19 | )} 20 | 21 |
22 | {deadlineIsToday && 23 | new Intl.DateTimeFormat("default", { 24 | timeStyle: "long", 25 | }).format(config.depositDeadline)} 26 |
27 |
28 |
29 |
30 |
Lock Duration
31 |
{formatDuration(config.lockDuration)}
32 |
33 | 34 | 35 |
36 |
37 | ) 38 | } 39 | 40 | export default StatsDeposit 41 | -------------------------------------------------------------------------------- /packages/app/components/stats/StatsLocked.tsx: -------------------------------------------------------------------------------- 1 | import Card from "../Card" 2 | import cls from "./Stats.module.css" 3 | import useTokenLockConfig from "../useTokenLockConfig" 4 | import formatDuration, { pluralize } from "./formatDuration" 5 | import TotalLockedStat from "./TotalLockedStat" 6 | 7 | const StatsLocked: React.FC = () => { 8 | const config = useTokenLockConfig() 9 | const durationPassed = Date.now() - config.depositDeadline.getTime() 10 | 11 | const hoursRemaining = (config.lockDuration - durationPassed) / 1000 / 60 / 60 12 | const daysRemaining = Math.round( 13 | (config.lockDuration - durationPassed) / 1000 / 60 / 60 / 24 14 | ) 15 | 16 | return ( 17 | 18 |
19 |
20 |
Unlock Date
21 |
22 | {new Intl.DateTimeFormat("default", { dateStyle: "medium" }).format( 23 | new Date(config.depositDeadline.getTime() + config.lockDuration) 24 | )} 25 |
26 |
27 |
28 |
Time remaining
29 |
30 | {hoursRemaining <= 48 31 | ? pluralize(hoursRemaining, "Hour") 32 | : `${daysRemaining} Days`} 33 |
34 |
35 | 36 | 37 |
38 |
39 | ) 40 | } 41 | 42 | export default StatsLocked 43 | -------------------------------------------------------------------------------- /packages/app/components/stats/StatsWithdraw.tsx: -------------------------------------------------------------------------------- 1 | import Card from "../Card" 2 | import cls from "./Stats.module.css" 3 | import utility from "../../styles/utility.module.css" 4 | import clsx from "clsx" 5 | import TotalLockedStat from "./TotalLockedStat" 6 | 7 | const StatsWithdraw: React.FC = () => ( 8 | 9 |
10 |
11 |
Unlock Date
12 |
Lock Period Over 🎉
13 |
14 | 15 | 16 |
17 |
18 | ) 19 | 20 | export default StatsWithdraw 21 | -------------------------------------------------------------------------------- /packages/app/components/stats/TotalLockedBreakdown.module.css: -------------------------------------------------------------------------------- 1 | .infoBubble { 2 | text-align: left; 3 | position: relative; 4 | margin-left: 4px; 5 | margin-top: -5px; 6 | } 7 | .infoBubble img { 8 | width: 16px; 9 | height: 16px; 10 | cursor: help; 11 | display: block; 12 | } 13 | 14 | .dropdown { 15 | background: #ffffff; 16 | border-radius: 0.5rem; 17 | box-shadow: 1px 2px 10px rgba(40, 54, 61, 0.18); 18 | padding: 0.5rem 1rem; 19 | position: absolute; 20 | right: 0; 21 | top: calc(100% + 4px); 22 | width: 360px; 23 | display: none; 24 | z-index: 99; 25 | } 26 | .infoBubble:hover .dropdown { 27 | display: block; 28 | } 29 | 30 | .breakdown > div { 31 | display: flex; 32 | justify-content: space-between; 33 | margin-bottom: 0.5rem; 34 | } 35 | .breakdown dd { 36 | color: #5d6d74; 37 | font-weight: bold; 38 | margin-left: 2.5rem; 39 | } 40 | .breakdown dt { 41 | font-size: 12px; 42 | } 43 | 44 | .total { 45 | border-top: 1px solid #e8e7e6; 46 | padding-top: 8px; 47 | } 48 | .total dd { 49 | color: #001428; 50 | } 51 | -------------------------------------------------------------------------------- /packages/app/components/stats/TotalLockedBreakdown.tsx: -------------------------------------------------------------------------------- 1 | import { BigNumber } from "ethers" 2 | import cls from "./TotalLockedBreakdown.module.css" 3 | import useTokenLockConfig from "../useTokenLockConfig" 4 | import useTotalLocked from "../useTotalLocked" 5 | import { formatToken } from "./formatToken" 6 | 7 | const TotalLockedBreakdown: React.FC<{ balance?: BigNumber }> = () => { 8 | const config = useTokenLockConfig() 9 | const [totalLocked, breakdown] = useTotalLocked() 10 | 11 | return ( 12 |
13 | Show details 14 |
15 |
16 |
17 |
GNO locked on Mainnet:
18 |
19 | {breakdown.mainnet 20 | ? formatToken(breakdown.mainnet, config.decimals) 21 | : "…"} 22 |
23 |
24 |
25 |
GNO locked on Gnosis Chain:
26 |
27 | {breakdown.gnosisChain 28 | ? formatToken(breakdown.gnosisChain, config.decimals) 29 | : "…"} 30 |
31 |
32 |
33 |
GNO staked by Gnosis Beacon Chain validators:
34 |
35 | {breakdown.staked 36 | ? formatToken(breakdown.staked, config.decimals) 37 | : "…"} 38 |
39 |
40 |
41 |
Total locked GNO:
42 |
43 | {totalLocked ? formatToken(totalLocked, config.decimals) : "…"} 44 |
45 |
46 |
47 |
48 |
49 | ) 50 | } 51 | 52 | export default TotalLockedBreakdown 53 | -------------------------------------------------------------------------------- /packages/app/components/stats/TotalLockedStat.tsx: -------------------------------------------------------------------------------- 1 | import { formatToken } from "./formatToken" 2 | import cls from "./Stats.module.css" 3 | import useTokenLockConfig from "../useTokenLockConfig" 4 | import useTotalLocked from "../useTotalLocked" 5 | import TotalLockedBreakdown from "./TotalLockedBreakdown" 6 | 7 | const TotalLockedStat: React.FC = () => { 8 | const config = useTokenLockConfig() 9 | const [totalLocked] = useTotalLocked() 10 | 11 | return ( 12 |
13 |
14 |
Total GNO Locked
15 | 16 |
17 |
{totalLocked ? formatToken(totalLocked, config.decimals) : "…"}
18 |
19 | ) 20 | } 21 | 22 | export default TotalLockedStat 23 | -------------------------------------------------------------------------------- /packages/app/components/stats/formatDuration.ts: -------------------------------------------------------------------------------- 1 | const MILLIS_PER_DAY = 24 * 60 * 60 * 1000 2 | 3 | export const pluralize = (number: number, unit: string) => { 4 | const rounded = Math.round(number) 5 | return `${rounded} ${unit}${rounded === 1 ? "" : "s"}` 6 | } 7 | 8 | const closeToFull = (dividend: number, divisor: number, fuzziness = 2) => { 9 | const remainder = dividend % divisor 10 | return remainder <= fuzziness || divisor - remainder <= fuzziness 11 | } 12 | 13 | const formatDuration = (millis: number) => { 14 | const days = millis / MILLIS_PER_DAY 15 | 16 | if ((days >= 365 && closeToFull(days, 365)) || days > 3 * 365) { 17 | return pluralize(days / 365, "Year") 18 | } 19 | 20 | if ((days >= 30 && closeToFull(days, 30)) || days > 90) { 21 | return pluralize(days / 30, "Month") 22 | } 23 | 24 | return pluralize(days, "Day") 25 | } 26 | 27 | export default formatDuration 28 | -------------------------------------------------------------------------------- /packages/app/components/stats/formatToken.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from "ethers" 2 | import { formatUnits } from "ethers/lib/utils" 3 | 4 | export const formatToken = (bigNumber: BigNumber, decimals: number) => 5 | new Intl.NumberFormat("en-US", { 6 | maximumFractionDigits: 0, 7 | }).format(parseFloat(formatUnits(bigNumber, decimals))) 8 | -------------------------------------------------------------------------------- /packages/app/components/stats/index.ts: -------------------------------------------------------------------------------- 1 | export { default as StatsDeposit } from "./StatsDeposit" 2 | export { default as StatsLocked } from "./StatsLocked" 3 | export { default as StatsWithdraw } from "./StatsWithdraw" 4 | -------------------------------------------------------------------------------- /packages/app/components/tokenContract.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useReadContract, 3 | useWriteContract, 4 | } from "wagmi" 5 | import useTokenLockConfig from "./useTokenLockConfig" 6 | import { erc20Abi } from 'viem' 7 | 8 | type Config = { enabled?: boolean; watch?: boolean; args?: any } 9 | 10 | export const useTokenContractRead = ( 11 | functionName: string, 12 | config: Config = {} 13 | ) => { 14 | const { tokenAddress } = useTokenLockConfig() 15 | return useReadContract({ 16 | ...config, 17 | address: tokenAddress as `0x${string}`, 18 | abi: erc20Abi, 19 | functionName: functionName as any, 20 | }) 21 | } 22 | 23 | export const useTokenContractWrite = (functionName: string, args: any) => { 24 | const { data: hash, writeContract } = useWriteContract() 25 | const { tokenAddress } = useTokenLockConfig() 26 | return writeContract({ 27 | address: tokenAddress as `0x${string}`, 28 | abi: erc20Abi, 29 | functionName: functionName as any, 30 | args, 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /packages/app/components/tokenLockContract.ts: -------------------------------------------------------------------------------- 1 | import { useChainId } from "wagmi" 2 | import { 3 | useReadContract, 4 | } from "wagmi" 5 | import { CONTRACT_ADDRESSES } from "../config" 6 | import { TOKEN_LOCK_ABI } from "../abi/tokenLock" 7 | 8 | type Config = { enabled?: boolean; watch?: boolean; args?: any } 9 | 10 | export const useTokenLockContractRead = ( 11 | functionName: string, 12 | config: Config = {} 13 | ) => { 14 | const chainId = useChainId() 15 | return useReadContract({ 16 | ...config, 17 | address: CONTRACT_ADDRESSES[chainId] as `0x${string}`, 18 | abi: TOKEN_LOCK_ABI, 19 | functionName, 20 | }) 21 | } -------------------------------------------------------------------------------- /packages/app/components/useTokenLockConfig.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { CONTRACT_ADDRESSES } from "@/config" 3 | import { createContext, useContext, useEffect, useState } from "react" 4 | import { useChainId, useReadContract } from "wagmi" 5 | import { TOKEN_LOCK_ABI } from "../abi/tokenLock" 6 | import { erc20Abi } from "viem" 7 | 8 | interface TokeLockConfig { 9 | depositDeadline: Date 10 | lockDuration: number 11 | tokenAddress: string 12 | tokenName: string 13 | tokenSymbol: string 14 | lockTokenName: string 15 | lockTokenSymbol: string 16 | decimals: number 17 | } 18 | 19 | const ConfigContext = createContext(null) 20 | 21 | export const ProvideConfig: React.FC = ({ children }) => { 22 | const [state, setState] = useState(null) 23 | const chainId = useChainId() 24 | const tokenLockContractAddress = CONTRACT_ADDRESSES[chainId] 25 | 26 | const { data: depositDeadline } = useReadContract({ 27 | address: tokenLockContractAddress, 28 | abi: TOKEN_LOCK_ABI, 29 | functionName: "depositDeadline", 30 | }) 31 | 32 | const { data: lockDuration } = useReadContract({ 33 | address: tokenLockContractAddress, 34 | abi: TOKEN_LOCK_ABI, 35 | functionName: "lockDuration", 36 | }) 37 | 38 | const { data: tokenAddress } = useReadContract({ 39 | address: tokenLockContractAddress, 40 | abi: TOKEN_LOCK_ABI, 41 | functionName: "token", 42 | }) 43 | 44 | const { data: lockTokenName } = useReadContract({ 45 | address: tokenLockContractAddress, 46 | abi: TOKEN_LOCK_ABI, 47 | functionName: "name", 48 | }) 49 | 50 | const { data: lockTokenSymbol } = useReadContract({ 51 | address: tokenLockContractAddress, 52 | abi: TOKEN_LOCK_ABI, 53 | functionName: "symbol", 54 | }) 55 | 56 | const { data: decimals } = useReadContract({ 57 | address: tokenLockContractAddress, 58 | abi: TOKEN_LOCK_ABI, 59 | functionName: "decimals", 60 | }) 61 | 62 | const { data: tokenName } = useReadContract({ 63 | address: tokenAddress as `0x${string}`, 64 | abi: erc20Abi, 65 | functionName: "name", 66 | query: { enabled: !!tokenAddress }, 67 | }) 68 | 69 | const { data: tokenSymbol } = useReadContract({ 70 | address: tokenAddress as `0x${string}`, 71 | abi: erc20Abi, 72 | functionName: "symbol", 73 | query: { enabled: !!tokenAddress }, 74 | }) 75 | 76 | useEffect(() => { 77 | if ( 78 | depositDeadline && 79 | lockDuration && 80 | tokenAddress && 81 | lockTokenName && 82 | lockTokenSymbol && 83 | decimals && 84 | tokenName && 85 | tokenSymbol 86 | ) { 87 | setState({ 88 | depositDeadline: new Date(Number(depositDeadline) * 1000), 89 | lockDuration: Number(lockDuration) * 1000, 90 | tokenAddress: tokenAddress as string, 91 | tokenName: tokenName as string, 92 | tokenSymbol: tokenSymbol as string, 93 | lockTokenName: lockTokenName as string, 94 | lockTokenSymbol: lockTokenSymbol as string, 95 | decimals: Number(decimals), 96 | }) 97 | } 98 | }, [ 99 | depositDeadline, 100 | lockDuration, 101 | tokenAddress, 102 | lockTokenName, 103 | lockTokenSymbol, 104 | decimals, 105 | tokenName, 106 | tokenSymbol, 107 | ]) 108 | 109 | if (!state) { 110 | return null 111 | } 112 | 113 | return ( 114 | {children} 115 | ) 116 | } 117 | 118 | const useTokenLockConfig = () => { 119 | const config = useContext(ConfigContext) 120 | if (!config) { 121 | throw new Error("Must be wrapped in ") 122 | } 123 | return config 124 | } 125 | 126 | export default useTokenLockConfig 127 | -------------------------------------------------------------------------------- /packages/app/components/useTokenPrice.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { useEffect, useState } from "react" 3 | import { COINGECKO_TOKEN_ID } from "../config" 4 | 5 | let resolvedTokenPrice = 0 6 | const tokenPricePromise = fetch( 7 | `https://api.coingecko.com/api/v3/simple/price?ids=${COINGECKO_TOKEN_ID}&vs_currencies=usd` 8 | ) 9 | .then((response) => response.json()) 10 | .then((json) => { 11 | resolvedTokenPrice = json[COINGECKO_TOKEN_ID].usd 12 | return resolvedTokenPrice 13 | }) 14 | 15 | const useTokenPrice = () => { 16 | const [tokenPrice, setTokenPrice] = useState(resolvedTokenPrice) 17 | 18 | useEffect(() => { 19 | tokenPricePromise.then(setTokenPrice) 20 | }, []) 21 | 22 | return tokenPrice 23 | } 24 | 25 | export default useTokenPrice 26 | -------------------------------------------------------------------------------- /packages/app/components/useTotalLocked.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from "ethers" 2 | import { useReadContract } from "wagmi" 3 | import { CONTRACT_ADDRESSES } from "../config" 4 | import { erc20Abi } from "viem" 5 | 6 | interface Breakdown { 7 | mainnet?: BigNumber 8 | gnosisChain?: BigNumber 9 | staked?: BigNumber 10 | } 11 | 12 | const GNO_ON_MAINNET = "0x6810e776880c02933d47db1b9fc05908e5386b96" 13 | const GNO_ON_GNOSIS_CHAIN = "0x9C58BAcC331c9aa871AFD802DB6379a98e80CEdb" 14 | const GNO_TO_MGNO = "0x647507A70Ff598F386CB96ae5046486389368C66" 15 | 16 | const useTotalLocked = (): [BigNumber | undefined, Breakdown] => { 17 | const { data: gnoLockedOnMainnetData } = useReadContract({ 18 | address: GNO_ON_MAINNET, 19 | abi: erc20Abi, 20 | chainId: 1, 21 | functionName: "balanceOf", 22 | args: [CONTRACT_ADDRESSES[1] as `0x${string}`], 23 | }) 24 | 25 | const { data: gnoLockedOnGnosisChainData } = useReadContract({ 26 | address: GNO_ON_GNOSIS_CHAIN, 27 | abi: erc20Abi, 28 | chainId: 100, 29 | functionName: "balanceOf", 30 | args: [CONTRACT_ADDRESSES[100] as `0x${string}`], 31 | }) 32 | 33 | const { data: gnoStakedData } = useReadContract({ 34 | address: GNO_ON_GNOSIS_CHAIN, 35 | abi: erc20Abi, 36 | chainId: 100, 37 | functionName: "balanceOf", 38 | args: [GNO_TO_MGNO as `0x${string}`], 39 | }) 40 | 41 | const gnoLockedOnMainnet = 42 | gnoLockedOnMainnetData === undefined 43 | ? undefined 44 | : BigNumber.from(gnoLockedOnMainnetData) 45 | const gnoLockedOnGnosisChain = 46 | gnoLockedOnGnosisChainData === undefined 47 | ? undefined 48 | : BigNumber.from(gnoLockedOnGnosisChainData) 49 | const gnoStaked = 50 | gnoStakedData === undefined ? undefined : BigNumber.from(gnoStakedData) 51 | 52 | return [ 53 | gnoLockedOnMainnet && 54 | gnoLockedOnGnosisChain && 55 | gnoStaked && 56 | gnoLockedOnMainnet.add(gnoLockedOnGnosisChain).add(gnoStaked), 57 | { 58 | mainnet: gnoLockedOnMainnet, 59 | gnosisChain: gnoLockedOnGnosisChain, 60 | staked: gnoStaked, 61 | }, 62 | ] 63 | } 64 | 65 | export default useTotalLocked 66 | -------------------------------------------------------------------------------- /packages/app/config.ts: -------------------------------------------------------------------------------- 1 | import { Chain, mainnet, gnosis } from "wagmi/chains" 2 | 3 | export const LOCKED_TOKEN_NAME = "Gnosis" 4 | export const LOCKED_TOKEN_SYMBOL = "GNO" 5 | export const CLAIM_TOKEN_NAME = "Locked Gnosis" 6 | export const CLAIM_TOKEN_SYMBOL = "LGNO" 7 | 8 | // used for price lookup 9 | export const COINGECKO_TOKEN_ID = "gnosis" 10 | 11 | export const CHAINS = [mainnet, gnosis] as Chain[] 12 | 13 | export const CONTRACT_ADDRESSES: { [chainId: number]: `0x${string}` } = { 14 | 1: "0x4f8AD938eBA0CD19155a835f617317a6E788c868", 15 | 100: "0xd4Ca39f78Bf14BfaB75226AC833b1858dB16f9a1", 16 | 17 | // 4: "0x01FD5975E40D16838a7213e2fdfFbBBA4477c14d", // deposit period ongoing 18 | // 4: "0x88c6501d5C2475F5a0343847A12cEA0090458013", // lock period ongoing 19 | // 4: "0xF7a579Cc9c27488f13C1F16036a65810fa1Ca3CC", // lock period over 20 | } 21 | -------------------------------------------------------------------------------- /packages/app/config/index.ts: -------------------------------------------------------------------------------- 1 | import { cookieStorage, createStorage } from '@wagmi/core' 2 | import { WagmiAdapter } from '@reown/appkit-adapter-wagmi' 3 | import { mainnet, gnosis } from '@reown/appkit/networks' 4 | 5 | // Get projectId from https://cloud.reown.com 6 | export const projectId = "af31daed827da866f82e0b2141bb0bee" 7 | 8 | if (!projectId) { 9 | throw new Error('Project ID is not defined') 10 | } 11 | 12 | export const networks = [mainnet, gnosis] 13 | 14 | //Set up the Wagmi Adapter (Config) 15 | export const wagmiAdapter = new WagmiAdapter({ 16 | storage: createStorage({ 17 | storage: cookieStorage 18 | }), 19 | ssr: true, 20 | projectId, 21 | networks 22 | }) 23 | 24 | export const config = wagmiAdapter.wagmiConfig 25 | -------------------------------------------------------------------------------- /packages/app/context/index.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query" 4 | import { createAppKit } from "@reown/appkit/react" 5 | import { mainnet, gnosis } from "@reown/appkit/networks" 6 | import React, { type ReactNode } from "react" 7 | import { cookieToInitialState, WagmiProvider, type Config } from "wagmi" 8 | import { projectId, wagmiAdapter } from "../config/index" 9 | 10 | // Set up queryClient 11 | const queryClient = new QueryClient() 12 | 13 | if (!projectId) { 14 | throw new Error("Project ID is not defined") 15 | } 16 | 17 | // Set up metadata 18 | const metadata = { 19 | name: "gnosis-lock", 20 | description: "Gnosis Lock", 21 | url: "https://appkitexampleapp.com", // origin must match your domain & subdomain 22 | icons: ["https://avatars.githubusercontent.com/u/179229932"], 23 | } 24 | 25 | // Create the modal 26 | const modal = createAppKit({ 27 | adapters: [wagmiAdapter], 28 | projectId, 29 | networks: [mainnet, gnosis], 30 | defaultNetwork: mainnet, 31 | metadata: metadata, 32 | features: { 33 | analytics: true, 34 | }, 35 | }) 36 | 37 | function ContextProvider({ children }: { children: ReactNode }) { 38 | return ( 39 | 40 | {children} 41 | 42 | ) 43 | } 44 | 45 | export default ContextProvider 46 | -------------------------------------------------------------------------------- /packages/app/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /packages/app/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: 'export', 4 | distDir: 'build', 5 | reactStrictMode: true, 6 | }; 7 | 8 | module.exports = nextConfig; -------------------------------------------------------------------------------- /packages/app/old/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | // import "../styles/globals.css" 2 | // import type { AppProps } from "next/app" 3 | // import { ProvideConfig } from "../components" 4 | // import { headers } from "next/headers" 5 | // import ContextProvider from "../context" 6 | 7 | // async function MyApp({ Component, pageProps }: AppProps) { 8 | // const headersData = await headers() 9 | // const cookies = headersData.get("cookie") 10 | 11 | // return ( 12 | // 13 | // 14 | // 15 | // 16 | // 17 | // ) 18 | // } 19 | 20 | // export default MyApp 21 | -------------------------------------------------------------------------------- /packages/app/old/pages/index.tsx: -------------------------------------------------------------------------------- 1 | // import type { NextPage } from "next" 2 | // import Head from "next/head" 3 | // import styles from "../styles/Home.module.css" 4 | // import { CHAINS } from "../config" 5 | // import { 6 | // Connect, 7 | // ConnectHint, 8 | // GnosisLogo, 9 | // LockedGnoLogo, 10 | // LockedBalance, 11 | // useTokenLockConfig, 12 | // Withdraw, 13 | // DepositAndWithdraw, 14 | // StatsDeposit, 15 | // StatsLocked, 16 | // StatsWithdraw, 17 | // } from "../components" 18 | // import UseGNOBanner from "../components/UseGnoBanner" 19 | // import { useChainId } from "wagmi" 20 | 21 | // const isProd = 22 | // typeof window !== "undefined" && window.location.hostname === "lock.gnosis.io" 23 | 24 | // const Home: NextPage = () => { 25 | // const config = useTokenLockConfig() 26 | // const chainId = useChainId(); 27 | 28 | // const connected = 29 | // chainId && CHAINS.some(({ id }) => id === chainId) 30 | 31 | // const depositPeriodOngoing = config.depositDeadline.getTime() > Date.now() 32 | // const lockPeriodOngoing = 33 | // config.depositDeadline.getTime() < Date.now() && 34 | // config.depositDeadline.getTime() + config.lockDuration > Date.now() 35 | // const lockPeriodOver = 36 | // config.depositDeadline.getTime() + config.lockDuration < Date.now() 37 | 38 | // return ( 39 | //
40 | // 41 | // Lock GNO 42 | // 46 | // {!isProd && } 47 | // 48 | // 49 | 50 | //
51 | // 52 | // 53 | // 54 | //
55 | 56 | //
57 | // 58 | // {depositPeriodOngoing && ( 59 | // <> 60 | // 61 | // {connected && } 62 | // 63 | // )} 64 | 65 | // {lockPeriodOngoing && ( 66 | // <> 67 | // 68 | // {connected && } 69 | // 70 | // )} 71 | 72 | // {lockPeriodOver && ( 73 | // <> 74 | // 75 | // {connected && } 76 | // 77 | // )} 78 | 79 | // {!connected && } 80 | //
81 | 82 | // 174 | //
175 | // ) 176 | // } 177 | 178 | // export default Home 179 | -------------------------------------------------------------------------------- /packages/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "token-lock-app", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start", 8 | "lint": "next lint", 9 | "format": "prettier '(components|pages)/**/*.(ts|tsx)' -w" 10 | }, 11 | "dependencies": { 12 | "@gnosis.pm/safe-apps-provider": "^0.9.3", 13 | "@gnosis.pm/safe-apps-sdk": "^6.2.0", 14 | "@reown/appkit": "^1.6.3", 15 | "@reown/appkit-adapter-wagmi": "^1.6.3", 16 | "@tanstack/react-query": "^5.62.16", 17 | "copy-to-clipboard": "^3.3.1", 18 | "ethereum-blockies-base64": "^1.0.2", 19 | "ethers": "^5.5.3", 20 | "next": "^15.1.3", 21 | "react": "18.2.0", 22 | "react-dom": "18.2.0", 23 | "truncate-eth-address": "^1.0.2", 24 | "use-onclickoutside": "^0.4.0", 25 | "viem": "^2.22.4", 26 | "wagmi": "^2.14.6" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "17.0.8", 30 | "@types/react": "17.0.38", 31 | "@types/react-modal": "^3.13.1", 32 | "eslint": "8.6.0", 33 | "eslint-config-next": "^15.1.4", 34 | "prettier": "^2.5.1", 35 | "typescript": "^5.3.2" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/app/public/Averta-ExtraBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnosis/token-lock/a5d2ffc3a554e19d1bc184c9d6c49bfc0b08da48/packages/app/public/Averta-ExtraBold.woff2 -------------------------------------------------------------------------------- /packages/app/public/Averta-normal.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnosis/token-lock/a5d2ffc3a554e19d1bc184c9d6c49bfc0b08da48/packages/app/public/Averta-normal.woff2 -------------------------------------------------------------------------------- /packages/app/public/AvertaBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnosis/token-lock/a5d2ffc3a554e19d1bc184c9d6c49bfc0b08da48/packages/app/public/AvertaBold.woff2 -------------------------------------------------------------------------------- /packages/app/public/arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /packages/app/public/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /packages/app/public/connectors/coinbase.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/app/public/connectors/injected.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/app/public/connectors/metamask.svg: -------------------------------------------------------------------------------- 1 | 2 | 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 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /packages/app/public/connectors/walletconnect.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /packages/app/public/copy.svg: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /packages/app/public/discordicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/app/public/etherscan.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /packages/app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnosis/token-lock/a5d2ffc3a554e19d1bc184c9d6c49bfc0b08da48/packages/app/public/favicon.ico -------------------------------------------------------------------------------- /packages/app/public/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnosis/token-lock/a5d2ffc3a554e19d1bc184c9d6c49bfc0b08da48/packages/app/public/github.png -------------------------------------------------------------------------------- /packages/app/public/gno.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /packages/app/public/gnochainbg.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 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /packages/app/public/gnochainfuture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnosis/token-lock/a5d2ffc3a554e19d1bc184c9d6c49bfc0b08da48/packages/app/public/gnochainfuture.png -------------------------------------------------------------------------------- /packages/app/public/gnosisguild.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnosis/token-lock/a5d2ffc3a554e19d1bc184c9d6c49bfc0b08da48/packages/app/public/gnosisguild.png -------------------------------------------------------------------------------- /packages/app/public/google9cc8a77ba2e504cb.html: -------------------------------------------------------------------------------- 1 | google-site-verification: google9cc8a77ba2e504cb.html -------------------------------------------------------------------------------- /packages/app/public/identicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /packages/app/public/info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/app/public/lock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Lock GNO", 3 | "name": "Lock GNO", 4 | "description": "Lock GNO for 12 months to receive a $COW airdrop boost", 5 | "iconPath": "lock.svg" 6 | } 7 | -------------------------------------------------------------------------------- /packages/app/public/open.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /packages/app/public/twittericon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/app/public/unlocked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /packages/app/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | align-items: center; 3 | background-color: white; 4 | display: flex; 5 | height: var(--navHeight, 66px); 6 | justify-content: space-between; 7 | padding-left: 2em; 8 | padding-right: 2em; 9 | } 10 | @media screen and (max-width: 650px) { 11 | .header { 12 | padding-left: 1em; 13 | padding-right: 1em; 14 | } 15 | } 16 | 17 | .main { 18 | align-items: stretch; 19 | display: flex; 20 | flex-direction: column; 21 | flex: 1; 22 | gap: 2em; 23 | justify-content: flex-start; 24 | margin: auto; 25 | max-width: 500px; 26 | min-height: calc(100vh - (var(--navHeight, 66px) * 2)); 27 | padding: 2rem 1rem; 28 | } 29 | 30 | .footer { 31 | padding-left: 2rem; 32 | padding-right: 2rem; 33 | color: #5d6d74; 34 | } 35 | @media screen and (max-width: 650px) { 36 | .footer { 37 | padding-left: 1em; 38 | padding-right: 1em; 39 | } 40 | } 41 | 42 | .footerContainer { 43 | align-items: center; 44 | border-top: 1px solid #b2b5b2; 45 | font-size: 10px; 46 | display: flex; 47 | justify-content: flex-start; 48 | } 49 | .left { 50 | align-items: center; 51 | display: flex; 52 | height: var(--navHeight, 66px); 53 | justify-content: flex-start; 54 | gap: 1em; 55 | } 56 | .right { 57 | align-items: center; 58 | display: flex; 59 | height: var(--navHeight, 66px); 60 | justify-content: flex-end; 61 | flex: 1; 62 | } 63 | 64 | .right > * { 65 | align-items: center; 66 | display: flex; 67 | margin-left: 1rem; 68 | } 69 | 70 | .footer a { 71 | cursor: pointer; 72 | transition: opacity 0.12s ease-in-out; 73 | } 74 | 75 | .footer a:hover { 76 | opacity: 0.7; 77 | } 78 | 79 | .divider { 80 | background-color: #b2b5b2; 81 | height: 1.25rem; 82 | width: 1px; 83 | } 84 | 85 | .gg { 86 | color: #5d6d74; 87 | } 88 | 89 | .logo { 90 | margin-left: 0.5rem; 91 | } 92 | -------------------------------------------------------------------------------- /packages/app/styles/globals.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Averta"; 3 | font-style: normal; 4 | font-weight: 400; 5 | font-display: swap; 6 | src: local("Averta-Regular"), 7 | url(../public/Averta-normal.woff2) format("woff2"); 8 | } 9 | @font-face { 10 | font-family: "Averta"; 11 | font-style: normal; 12 | font-weight: 700; 13 | font-display: swap; 14 | src: local("Averta-Bold"), 15 | url(../public/AvertaBold.woff2) format("woff2"); 16 | } 17 | @font-face { 18 | font-family: "Averta"; 19 | font-style: normal; 20 | font-weight: 800; 21 | font-display: swap; 22 | src: local("Averta-Extrabold"), 23 | url(../public/Averta-ExtraBold.woff2) format("woff2"); 24 | } 25 | 26 | :root { 27 | --navHeight: 66px; 28 | } 29 | 30 | html, 31 | body { 32 | padding: 0; 33 | margin: 0; 34 | font-family: Averta, sans-serif; 35 | font-size: 16px; 36 | color: #001428; 37 | background-color: #e8e7e6; 38 | } 39 | 40 | a { 41 | color: inherit; 42 | text-decoration: none; 43 | color: #001428; 44 | } 45 | 46 | button { 47 | font-family: Averta, sans-serif; 48 | } 49 | 50 | * { 51 | box-sizing: border-box; 52 | } 53 | -------------------------------------------------------------------------------- /packages/app/styles/utility.module.css: -------------------------------------------------------------------------------- 1 | /* ----- Utility ----- */ 2 | 3 | /* Margin */ 4 | .mt4 { 5 | margin-top: 0.5rem; 6 | } 7 | .mt8 { 8 | margin-top: 1rem; 9 | } 10 | 11 | /* Padding */ 12 | .pb8 { 13 | padding-bottom: 1rem; 14 | } -------------------------------------------------------------------------------- /packages/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/config": [ 5 | "./config" 6 | ] 7 | }, 8 | "lib": [ 9 | "dom", 10 | "dom.iterable", 11 | "esnext" 12 | ], 13 | "allowJs": true, 14 | "skipLibCheck": true, 15 | "strict": true, 16 | "noEmit": true, 17 | "esModuleInterop": true, 18 | "module": "esnext", 19 | "moduleResolution": "bundler", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "jsx": "preserve", 23 | "incremental": true, 24 | "plugins": [ 25 | { 26 | "name": "next" 27 | } 28 | ], 29 | "target": "ES2017" 30 | }, 31 | "include": [ 32 | "**/*.ts", 33 | "**/*.tsx", 34 | "next-env.d.ts", 35 | ".next/types/**/*.ts" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /packages/app/wagmi.ts: -------------------------------------------------------------------------------- 1 | // import { createAppKit } from "@reown/appkit/react" 2 | // import { mainnet, gnosis } from "@reown/appkit/networks" 3 | // import { WagmiAdapter } from "@reown/appkit-adapter-wagmi" 4 | 5 | // export const projectId = 'af31daed827da866f82e0b2141bb0bee' 6 | 7 | // export const wagmiAdapter = new WagmiAdapter({ 8 | // networks: [mainnet, gnosis], 9 | // projectId, 10 | // }) 11 | 12 | // createAppKit({ 13 | // adapters: [wagmiAdapter], 14 | // networks: [mainnet, gnosis], 15 | // projectId, 16 | // features: { 17 | // analytics: true, 18 | // }, 19 | // }) 20 | -------------------------------------------------------------------------------- /packages/contracts/.env.template: -------------------------------------------------------------------------------- 1 | MNEMONIC= 2 | INFURA_KEY= 3 | ETHERSCAN_API_KEY= -------------------------------------------------------------------------------- /packages/contracts/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es2020: true, 5 | node: true, 6 | }, 7 | parser: "@typescript-eslint/parser", 8 | parserOptions: { 9 | ecmaVersion: 2020, 10 | }, 11 | plugins: ["@typescript-eslint", "import", "prettier"], 12 | extends: [ 13 | "eslint:recommended", 14 | "plugin:import/recommended", 15 | "plugin:import/typescript", 16 | "plugin:@typescript-eslint/recommended", 17 | "plugin:prettier/recommended", 18 | "prettier", 19 | ], 20 | ignorePatterns: ["build/", "node_modules/", "coverage/", ".openzeppelin"], 21 | } 22 | -------------------------------------------------------------------------------- /packages/contracts/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | build/ 3 | .openzeppelin/ 4 | typechain-types/ 5 | env 6 | .env 7 | coverage* 8 | -------------------------------------------------------------------------------- /packages/contracts/.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec": ["test/**/*.spec.ts"], 3 | "watchFiles": ["test/**/*.spec.ts"], 4 | "require": "ts-node/register/files", 5 | "timeout": 20000 6 | } 7 | -------------------------------------------------------------------------------- /packages/contracts/.solcover.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | skipFiles: ["test/TestToken.sol"], 3 | mocha: { 4 | grep: "@skip-on-coverage", // Find everything with this tag 5 | invert: true, // Run the grep's inverse set. 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /packages/contracts/.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "solhint:recommended", 3 | "plugins": ["prettier"], 4 | "rules": { 5 | "compiler-version": "off", 6 | "func-visibility": [ 7 | "warn", 8 | { 9 | "ignoreConstructors": true 10 | } 11 | ], 12 | "prettier/prettier": "error", 13 | "not-rely-on-time": "off", 14 | "reason-string": "off", 15 | "no-empty-blocks": "off", 16 | "avoid-low-level-calls": "off" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/contracts/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /packages/contracts/audits/GnosisTokenLockJan2022.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gnosis/token-lock/a5d2ffc3a554e19d1bc184c9d6c49bfc0b08da48/packages/contracts/audits/GnosisTokenLockJan2022.pdf -------------------------------------------------------------------------------- /packages/contracts/contracts/TokenLock.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | pragma solidity ^0.8.6; 3 | 4 | import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; 5 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 6 | import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 7 | 8 | contract TokenLock is OwnableUpgradeable, IERC20 { 9 | ERC20 public token; 10 | uint256 public depositDeadline; 11 | uint256 public lockDuration; 12 | 13 | string public name; 14 | string public symbol; 15 | uint256 public override totalSupply; 16 | mapping(address => uint256) public override balanceOf; 17 | 18 | /// Withdraw amount exceeds sender's balance of the locked token 19 | error ExceedsBalance(); 20 | /// Deposit is not possible anymore because the deposit period is over 21 | error DepositPeriodOver(); 22 | /// Withdraw is not possible because the lock period is not over yet 23 | error LockPeriodOngoing(); 24 | /// Could not transfer the designated ERC20 token 25 | error TransferFailed(); 26 | /// ERC-20 function is not supported 27 | error NotSupported(); 28 | 29 | function initialize( 30 | address _owner, 31 | address _token, 32 | uint256 _depositDeadline, 33 | uint256 _lockDuration, 34 | string memory _name, 35 | string memory _symbol 36 | ) public initializer { 37 | __Ownable_init(); 38 | transferOwnership(_owner); 39 | token = ERC20(_token); 40 | depositDeadline = _depositDeadline; 41 | lockDuration = _lockDuration; 42 | name = _name; 43 | symbol = _symbol; 44 | totalSupply = 0; 45 | } 46 | 47 | /// @dev Deposit tokens to be locked until the end of the locking period 48 | /// @param amount The amount of tokens to deposit 49 | function deposit(uint256 amount) public { 50 | if (block.timestamp > depositDeadline) { 51 | revert DepositPeriodOver(); 52 | } 53 | 54 | balanceOf[msg.sender] += amount; 55 | totalSupply += amount; 56 | 57 | if (!token.transferFrom(msg.sender, address(this), amount)) { 58 | revert TransferFailed(); 59 | } 60 | 61 | emit Transfer(msg.sender, address(this), amount); 62 | } 63 | 64 | /// @dev Withdraw tokens after the end of the locking period or during the deposit period 65 | /// @param amount The amount of tokens to withdraw 66 | function withdraw(uint256 amount) public { 67 | if ( 68 | block.timestamp > depositDeadline && 69 | block.timestamp < depositDeadline + lockDuration 70 | ) { 71 | revert LockPeriodOngoing(); 72 | } 73 | if (balanceOf[msg.sender] < amount) { 74 | revert ExceedsBalance(); 75 | } 76 | 77 | balanceOf[msg.sender] -= amount; 78 | totalSupply -= amount; 79 | 80 | if (!token.transfer(msg.sender, amount)) { 81 | revert TransferFailed(); 82 | } 83 | 84 | emit Transfer(address(this), msg.sender, amount); 85 | } 86 | 87 | /// @dev Returns the number of decimals of the locked token 88 | function decimals() public view returns (uint8) { 89 | return token.decimals(); 90 | } 91 | 92 | /// @dev Lock claim tokens are non-transferrable: ERC-20 transfer is not supported 93 | function transfer(address, uint256) external pure override returns (bool) { 94 | revert NotSupported(); 95 | } 96 | 97 | /// @dev Lock claim tokens are non-transferrable: ERC-20 allowance is not supported 98 | function allowance(address, address) 99 | external 100 | pure 101 | override 102 | returns (uint256) 103 | { 104 | revert NotSupported(); 105 | } 106 | 107 | /// @dev Lock claim tokens are non-transferrable: ERC-20 approve is not supported 108 | function approve(address, uint256) external pure override returns (bool) { 109 | revert NotSupported(); 110 | } 111 | 112 | /// @dev Lock claim tokens are non-transferrable: ERC-20 transferFrom is not supported 113 | function transferFrom( 114 | address, 115 | address, 116 | uint256 117 | ) external pure override returns (bool) { 118 | revert NotSupported(); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /packages/contracts/contracts/test/TestToken.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | pragma solidity ^0.8.0; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract TestToken is ERC20 { 7 | address public owner; 8 | 9 | uint8 internal tokenDecimals; 10 | 11 | constructor(uint8 _decimals) ERC20("Test", "T") { 12 | owner = msg.sender; 13 | tokenDecimals = _decimals; 14 | } 15 | 16 | modifier onlyOwner() { 17 | require(msg.sender == owner, "Only callable by owner"); 18 | _; 19 | } 20 | 21 | function decimals() public view override returns (uint8) { 22 | return tokenDecimals; 23 | } 24 | 25 | function mint(address to, uint256 amount) public onlyOwner { 26 | _mint(to, amount); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/contracts/contracts/test/TestTokenFailingTransfer.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | pragma solidity ^0.8.0; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract TestTokenFailingTransfer is ERC20 { 7 | address public owner; 8 | 9 | uint8 internal tokenDecimals; 10 | 11 | constructor(uint8 _decimals) ERC20("Test", "T") { 12 | owner = msg.sender; 13 | tokenDecimals = _decimals; 14 | } 15 | 16 | modifier onlyOwner() { 17 | require(msg.sender == owner, "Only callable by owner"); 18 | _; 19 | } 20 | 21 | function decimals() public view override returns (uint8) { 22 | return tokenDecimals; 23 | } 24 | 25 | function mint(address to, uint256 amount) public onlyOwner { 26 | _mint(to, amount); 27 | } 28 | 29 | function transfer(address, uint256) 30 | public 31 | pure 32 | override 33 | returns (bool success) 34 | { 35 | return false; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/contracts/contracts/test/TestTokenFailingTransferFrom.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: LGPL-3.0-only 2 | pragma solidity ^0.8.0; 3 | 4 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 5 | 6 | contract TestTokenFailingTransferFrom is ERC20 { 7 | address public owner; 8 | 9 | uint8 internal tokenDecimals; 10 | 11 | constructor(uint8 _decimals) ERC20("Test", "T") { 12 | owner = msg.sender; 13 | tokenDecimals = _decimals; 14 | } 15 | 16 | modifier onlyOwner() { 17 | require(msg.sender == owner, "Only callable by owner"); 18 | _; 19 | } 20 | 21 | function decimals() public view override returns (uint8) { 22 | return tokenDecimals; 23 | } 24 | 25 | function mint(address to, uint256 amount) public onlyOwner { 26 | _mint(to, amount); 27 | } 28 | 29 | function transferFrom( 30 | address, 31 | address, 32 | uint256 33 | ) public pure override returns (bool success) { 34 | return false; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/contracts/hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import "@typechain/hardhat" 2 | import "@nomiclabs/hardhat-ethers" 3 | import "@nomiclabs/hardhat-waffle" 4 | import "@nomiclabs/hardhat-etherscan" 5 | import "@openzeppelin/hardhat-upgrades" 6 | import "solidity-coverage" 7 | import "hardhat-deploy" 8 | import "hardhat-gas-reporter" 9 | import dotenv from "dotenv" 10 | import { HttpNetworkUserConfig } from "hardhat/types" 11 | import yargs from "yargs" 12 | 13 | const argv = yargs 14 | .option("network", { 15 | type: "string", 16 | default: "hardhat", 17 | }) 18 | .help(false) 19 | .version(false).argv as { network: string } 20 | 21 | // Load environment variables. 22 | dotenv.config() 23 | const { INFURA_KEY, MNEMONIC, ETHERSCAN_API_KEY, PK } = process.env 24 | 25 | import "./src/tasks/initialDeploy" 26 | import "./src/tasks/upgrade" 27 | import "./src/tasks/verify" 28 | import "./src/tasks/initializeImplementation" 29 | 30 | const DEFAULT_MNEMONIC = 31 | "candy maple cake sugar pudding cream honey rich smooth crumble sweet treat" 32 | 33 | const sharedNetworkConfig: HttpNetworkUserConfig = {} 34 | if (PK) { 35 | sharedNetworkConfig.accounts = [PK] 36 | } else { 37 | sharedNetworkConfig.accounts = { 38 | mnemonic: MNEMONIC || DEFAULT_MNEMONIC, 39 | } 40 | } 41 | 42 | if (["rinkeby", "mainnet"].includes(argv.network) && INFURA_KEY === undefined) { 43 | throw new Error( 44 | `Could not find Infura key in env, unable to connect to network ${argv.network}` 45 | ) 46 | } 47 | 48 | export default { 49 | paths: { 50 | artifacts: "build/artifacts", 51 | cache: "build/cache", 52 | sources: "contracts", 53 | }, 54 | solidity: { 55 | compilers: [{ version: "0.8.6" }], 56 | settings: { 57 | optimizer: { 58 | enabled: true, 59 | runs: 200, 60 | }, 61 | }, 62 | }, 63 | networks: { 64 | hardhat: { 65 | allowUnlimitedContractSize: true, 66 | }, 67 | mainnet: { 68 | ...sharedNetworkConfig, 69 | url: `https://mainnet.infura.io/v3/${INFURA_KEY}`, 70 | }, 71 | goerli: { 72 | ...sharedNetworkConfig, 73 | url: `https://goerli.infura.io/v3/${INFURA_KEY}`, 74 | }, 75 | gnosischain: { 76 | ...sharedNetworkConfig, 77 | url: "https://xdai.poanetwork.dev", 78 | }, 79 | }, 80 | namedAccounts: { 81 | deployer: 0, 82 | }, 83 | mocha: { 84 | timeout: 2000000, 85 | }, 86 | etherscan: { 87 | apiKey: ETHERSCAN_API_KEY, 88 | }, 89 | } 90 | -------------------------------------------------------------------------------- /packages/contracts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "token-lock", 3 | "version": "1.0.0", 4 | "description": "Lock ERC-20 tokens for a pre-defined amount of time", 5 | "directories": { 6 | "test": "test" 7 | }, 8 | "scripts": { 9 | "build": "hardhat compile", 10 | "test": "hardhat test", 11 | "test:watch": "mocha -w", 12 | "deploy": "hardhat initialDeploy --network", 13 | "verify": "hardhat verifyEtherscan --network", 14 | "upgrade": "hardhat upgrade --network", 15 | "coverage": "hardhat coverage", 16 | "lint": "yarn lint:sol && yarn lint:ts", 17 | "lint:sol": "solhint 'contracts/**/*.sol'", 18 | "lint:ts": "eslint --max-warnings 0 .", 19 | "fmt:sol": "prettier 'contracts/**/*.sol' -w", 20 | "fmt:ts": "prettier '(src|test)/**/*.ts' -w", 21 | "prepack": "yarn build" 22 | }, 23 | "repository": { 24 | "type": "git" 25 | }, 26 | "author": "jan-felix.schwarz@gnosis.io", 27 | "license": "LGPL-3.0+", 28 | "devDependencies": { 29 | "@gnosis.pm/zodiac": "1.0.3", 30 | "@nomiclabs/hardhat-ethers": "2.0.3", 31 | "@nomiclabs/hardhat-etherscan": "2.1.8", 32 | "@nomiclabs/hardhat-waffle": "2.0.1", 33 | "@openzeppelin/hardhat-upgrades": "1.12.0", 34 | "@typechain/ethers-v5": "8.0.5", 35 | "@typechain/hardhat": "3.1.0", 36 | "@types/chai": "4.3.0", 37 | "@types/dotenv": "8.2.0", 38 | "@types/mocha": "9.0.0", 39 | "@types/node": "17.0.8", 40 | "@types/yargs": "17.0.8", 41 | "@typescript-eslint/eslint-plugin": "5.9.0", 42 | "@typescript-eslint/parser": "5.9.0", 43 | "argv": "0.0.2", 44 | "chai": "4.3.4", 45 | "debug": "4.3.3", 46 | "dotenv": "10.0.0", 47 | "eslint": "8.6.0", 48 | "eslint-config-prettier": "8.3.0", 49 | "eslint-plugin-import": "2.25.4", 50 | "eslint-plugin-no-only-tests": "2.6.0", 51 | "eslint-plugin-prettier": "4.0.0", 52 | "ethereum-waffle": "3.4.0", 53 | "ethers": "5.5.2", 54 | "hardhat": "2.8.0", 55 | "hardhat-deploy": "0.9.24", 56 | "hardhat-gas-reporter": "1.0.6", 57 | "prettier": "2.5.1", 58 | "prettier-plugin-solidity": "1.0.0-beta.19", 59 | "solc": "0.8.11", 60 | "solhint": "3.3.6", 61 | "solhint-plugin-prettier": "0.0.5", 62 | "solidity-coverage": "0.7.17", 63 | "ts-node": "10.4.0", 64 | "typechain": "6.1.0", 65 | "typescript": "4.5.4", 66 | "yargs": "17.3.1" 67 | }, 68 | "dependencies": { 69 | "@gnosis.pm/mock-contract": "4.0.0", 70 | "@gnosis.pm/safe-contracts": "1.3.0", 71 | "@openzeppelin/contracts": "4.4.1", 72 | "@openzeppelin/contracts-upgradeable": "4.4.1" 73 | }, 74 | "resolutions": { 75 | "bitcore-lib": "8.25.0" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/contracts/src/tasks/initialDeploy.ts: -------------------------------------------------------------------------------- 1 | import "hardhat-deploy" 2 | import "@nomiclabs/hardhat-ethers" 3 | import { task, types } from "hardhat/config" 4 | 5 | task("initialDeploy", "Deploys a fresh TokenLock contract") 6 | .addParam("owner", "Address of the owner", undefined, types.string) 7 | .addParam("token", "Address of the token to lock", undefined, types.string) 8 | .addParam( 9 | "depositDeadline", 10 | "Unix timestamp (seconds) of the deposit deadline", 11 | undefined, 12 | types.int 13 | ) 14 | .addParam( 15 | "lockDuration", 16 | "Lock duration in seconds, period starts after the deposit deadline", 17 | undefined, 18 | types.int 19 | ) 20 | .addParam( 21 | "name", 22 | "Name of the token representing the claim on the locked token", 23 | undefined, 24 | types.string 25 | ) 26 | .addParam( 27 | "symbol", 28 | "Symbol of the token representing the claim on the locked token", 29 | undefined, 30 | types.string 31 | ) 32 | .setAction(async (taskArgs, hre) => { 33 | const [caller] = await hre.ethers.getSigners() 34 | console.log("Using the account:", caller.address) 35 | const TokenLock = await hre.ethers.getContractFactory("TokenLock") 36 | const tokenLock = await hre.upgrades.deployProxy(TokenLock, [ 37 | taskArgs.owner, 38 | taskArgs.token, 39 | taskArgs.depositDeadline, 40 | taskArgs.lockDuration, 41 | taskArgs.name, 42 | taskArgs.symbol, 43 | ]) 44 | 45 | console.log("TokenLock proxy deployed to:", tokenLock.address) 46 | console.log("Waiting for deploy transaction to be mined...") 47 | 48 | await tokenLock.deployed() 49 | const implementationAddress = 50 | await hre.upgrades.erc1967.getImplementationAddress(tokenLock.address) 51 | console.log( 52 | "Using the logic implementation contract deployed at:", 53 | implementationAddress 54 | ) 55 | }) 56 | 57 | export {} 58 | -------------------------------------------------------------------------------- /packages/contracts/src/tasks/initializeImplementation.ts: -------------------------------------------------------------------------------- 1 | import { task, types } from "hardhat/config" 2 | 3 | task("initializeImplementation", "Initializes the implementation contract") 4 | .addParam( 5 | "implementation", 6 | "Address of the implementation contract to initialize", 7 | undefined, 8 | types.string 9 | ) 10 | .setAction(async (taskArgs, hre) => { 11 | const [caller] = await hre.ethers.getSigners() 12 | const TokenLock = await hre.ethers.getContractFactory("TokenLock", caller) 13 | const tokenLock = TokenLock.attach(taskArgs.implementation) 14 | 15 | const DEAD_ADDRESS = "0x000000000000000000000000000000000000dead" 16 | try { 17 | const tx = await tokenLock.initialize( 18 | DEAD_ADDRESS, 19 | DEAD_ADDRESS, 20 | 0, 21 | 0, 22 | "", 23 | "" 24 | ) 25 | await tx.wait() 26 | console.log( 27 | `Contract at ${taskArgs.implementation} has been successfully initialized` 28 | ) 29 | } catch (e) { 30 | const error = e as Error & { error: Error } 31 | if ( 32 | "error" in error && 33 | "message" in error.error && 34 | error.error.message === 35 | "execution reverted: Initializable: contract is already initialized" 36 | ) { 37 | console.warn( 38 | `Contract at ${taskArgs.implementation} is already initialized` 39 | ) 40 | } else { 41 | throw e 42 | } 43 | } 44 | }) 45 | -------------------------------------------------------------------------------- /packages/contracts/src/tasks/upgrade.ts: -------------------------------------------------------------------------------- 1 | import "hardhat-deploy" 2 | import "@nomiclabs/hardhat-ethers" 3 | 4 | import { task, types } from "hardhat/config" 5 | 6 | task("upgrade", "Upgrades the logic of an existing TokenLock contract") 7 | .addParam( 8 | "proxy", 9 | "Address of the existing token lock proxy", 10 | undefined, 11 | types.string 12 | ) 13 | .setAction(async (taskArgs, hre) => { 14 | const [caller] = await hre.ethers.getSigners() 15 | console.log("Using the account:", caller.address) 16 | const TokenLock = await hre.ethers.getContractFactory("TokenLock") 17 | const tokenLock = await hre.upgrades.upgradeProxy(taskArgs.proxy, TokenLock) 18 | 19 | console.log( 20 | `Latest version of the implementation deployed to: ${tokenLock.address}` 21 | ) 22 | 23 | console.log( 24 | `Proxy at ${taskArgs.proxy} upgraded to use implementation at ${tokenLock.address}` 25 | ) 26 | }) 27 | 28 | export {} 29 | -------------------------------------------------------------------------------- /packages/contracts/src/tasks/verify.ts: -------------------------------------------------------------------------------- 1 | import { task, types } from "hardhat/config" 2 | 3 | task("verifyEtherscan", "Verifies the contract on etherscan") 4 | .addParam( 5 | "implementation", 6 | "Address of the implementation contract to verify", 7 | undefined, 8 | types.string 9 | ) 10 | .setAction(async (taskArgs, hardhatRuntime) => { 11 | await hardhatRuntime.run("verify", { 12 | address: taskArgs.implementation, 13 | constructorArgsParams: [], 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /packages/contracts/test/TokenLock.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai" 2 | import { deployments, ethers, upgrades, network } from "hardhat" 3 | import "@nomiclabs/hardhat-ethers" 4 | import { BigNumber } from "@ethersproject/bignumber" 5 | 6 | import { TokenLock as TokenLockT } from "../typechain-types" 7 | 8 | import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" 9 | 10 | describe("TokenLock", () => { 11 | let owner: SignerWithAddress 12 | let user: SignerWithAddress 13 | 14 | const ONE = BigNumber.from(10).pow(18) 15 | 16 | const now = Math.round(Date.now() / 1000) 17 | const oneWeek = 7 * 24 * 60 * 60 18 | 19 | const setupTest = deployments.createFixture(async () => { 20 | ;[owner, user] = await ethers.getSigners() 21 | 22 | await deployments.fixture() 23 | const Token = await ethers.getContractFactory("TestToken") 24 | const token = await Token.deploy(18) 25 | const TokenLock = await ethers.getContractFactory("TokenLock") 26 | 27 | await token.mint(user.address, ONE) 28 | 29 | return { Token, token, TokenLock } 30 | }) 31 | 32 | it("is upgradable", async () => { 33 | const { TokenLock, token } = await setupTest() 34 | 35 | const instance = await upgrades.deployProxy(TokenLock, [ 36 | owner.address, 37 | token.address, 38 | now + oneWeek, 39 | 2 * oneWeek, 40 | "Locked TestToken", 41 | "LTT", 42 | ]) 43 | 44 | const TokenLockV2 = await ethers.getContractFactory("TokenLock") 45 | expect(TokenLockV2).to.not.equal(TokenLock) 46 | 47 | const upgraded = (await upgrades.upgradeProxy( 48 | instance.address, 49 | TokenLockV2 50 | )) as TokenLockT 51 | expect(await upgraded.name()).to.equal("Locked TestToken") 52 | }) 53 | 54 | it("cannot be upgraded by others", async () => { 55 | const { token } = await setupTest() 56 | const TokenLock = await ethers.getContractFactory("TokenLock", { 57 | signer: owner, 58 | }) 59 | 60 | const instance = (await upgrades.deployProxy(TokenLock, [ 61 | owner.address, 62 | token.address, 63 | now + oneWeek, 64 | 2 * oneWeek, 65 | "Locked TestToken", 66 | "LTT", 67 | ])) as TokenLockT 68 | 69 | const TokenLockV2 = await ethers.getContractFactory("TokenLock", { 70 | signer: user, 71 | }) 72 | 73 | await expect( 74 | upgrades.upgradeProxy(instance.address, TokenLockV2) 75 | ).to.be.revertedWith("Ownable: caller is not the owner") 76 | }) 77 | 78 | it("should revert with NotSupported() if one the unsupported ERC-20 functions is called", async () => { 79 | const { TokenLock, token } = await setupTest() 80 | 81 | const tokenLock = (await upgrades.deployProxy(TokenLock, [ 82 | owner.address, 83 | token.address, 84 | now + oneWeek, 85 | 2 * oneWeek, 86 | "Locked TestToken", 87 | "LTT", 88 | ])) as TokenLockT 89 | 90 | await expect( 91 | tokenLock.allowance(user.address, user.address) 92 | ).to.be.revertedWith("NotSupported()") 93 | await expect(tokenLock.approve(user.address, 1000)).to.be.revertedWith( 94 | "NotSupported()" 95 | ) 96 | await expect(tokenLock.transfer(user.address, 100)).to.be.revertedWith( 97 | "NotSupported()" 98 | ) 99 | await expect( 100 | tokenLock.transferFrom(user.address, user.address, 100) 101 | ).to.be.revertedWith("NotSupported()") 102 | }) 103 | 104 | describe("initialize", () => { 105 | it("sets the owner and stores the provided arguments", async () => { 106 | const { TokenLock, token } = await setupTest() 107 | 108 | const tokenLock = (await upgrades.deployProxy(TokenLock, [ 109 | owner.address, 110 | token.address, 111 | now + oneWeek, 112 | 2 * oneWeek, 113 | "Locked TestToken", 114 | "LTT", 115 | ])) as TokenLockT 116 | 117 | expect(await tokenLock.owner()).to.equal(owner.address) 118 | expect(await tokenLock.token()).to.equal(token.address) 119 | expect(await tokenLock.depositDeadline()).to.equal(now + oneWeek) 120 | expect(await tokenLock.lockDuration()).to.equal(2 * oneWeek) 121 | expect(await tokenLock.name()).to.equal("Locked TestToken") 122 | expect(await tokenLock.symbol()).to.equal("LTT") 123 | }) 124 | }) 125 | 126 | describe("deposit()", () => { 127 | it("reverts if the sender has no sufficient balance", async () => { 128 | const { token, TokenLock } = await setupTest() 129 | const tokenLock = (await upgrades.deployProxy(TokenLock, [ 130 | owner.address, 131 | token.address, 132 | now + oneWeek, 133 | 2 * oneWeek, 134 | "Locked TestToken", 135 | "LTT", 136 | ])) as TokenLockT 137 | 138 | await expect( 139 | tokenLock.connect(user).deposit(ONE.mul(2)) 140 | ).to.be.revertedWith("ERC20: transfer amount exceeds balance") 141 | }) 142 | 143 | it("reverts if the sender has not provided sufficient allowance", async () => { 144 | const { token, TokenLock } = await setupTest() 145 | const tokenLock = (await upgrades.deployProxy(TokenLock, [ 146 | owner.address, 147 | token.address, 148 | now + oneWeek, 149 | 2 * oneWeek, 150 | "Locked TestToken", 151 | "LTT", 152 | ])) as TokenLockT 153 | 154 | await token.connect(user).approve(tokenLock.address, ONE.div(2)) 155 | 156 | await expect(tokenLock.connect(user).deposit(ONE)).to.be.revertedWith( 157 | "ERC20: transfer amount exceeds allowance" 158 | ) 159 | }) 160 | 161 | it("reverts if the deposit deadline has passed", async () => { 162 | const { token, TokenLock } = await setupTest() 163 | const tokenLock = (await upgrades.deployProxy(TokenLock, [ 164 | owner.address, 165 | token.address, 166 | now - oneWeek, 167 | 2 * oneWeek, 168 | "Locked TestToken", 169 | "LTT", 170 | ])) as TokenLockT 171 | 172 | await token.connect(user).approve(tokenLock.address, ONE) 173 | 174 | await expect(tokenLock.connect(user).deposit(ONE)).to.be.revertedWith( 175 | "DepositPeriodOver()" 176 | ) 177 | }) 178 | 179 | it("reverts if the token transfer is unsuccessful", async () => { 180 | const { TokenLock } = await setupTest() 181 | const FailingToken = await ethers.getContractFactory( 182 | "TestTokenFailingTransferFrom" 183 | ) 184 | const failingToken = await FailingToken.deploy(18) 185 | const tokenLock = (await upgrades.deployProxy(TokenLock, [ 186 | owner.address, 187 | failingToken.address, 188 | now + oneWeek, 189 | 2 * oneWeek, 190 | "Locked TestToken", 191 | "LTT", 192 | ])) as TokenLockT 193 | 194 | await failingToken.mint(user.address, ONE) 195 | await failingToken.connect(user).approve(tokenLock.address, ONE) 196 | 197 | await expect(tokenLock.connect(user).deposit(ONE)).to.be.revertedWith( 198 | "TransferFailed()" 199 | ) 200 | }) 201 | 202 | it("transfers the deposited tokens into the lock contract", async () => { 203 | const { token, TokenLock } = await setupTest() 204 | const tokenLock = (await upgrades.deployProxy(TokenLock, [ 205 | owner.address, 206 | token.address, 207 | now + oneWeek, 208 | 2 * oneWeek, 209 | "Locked TestToken", 210 | "LTT", 211 | ])) as TokenLockT 212 | 213 | await token.connect(user).approve(tokenLock.address, ONE) 214 | 215 | await expect(() => 216 | tokenLock.connect(user).deposit(ONE) 217 | ).to.changeTokenBalances(token, [user, tokenLock], [ONE.mul(-1), ONE]) 218 | }) 219 | 220 | it("mints lock claim tokens to the sender equal to the deposited amount", async () => { 221 | const { token, TokenLock } = await setupTest() 222 | const tokenLock = (await upgrades.deployProxy(TokenLock, [ 223 | owner.address, 224 | token.address, 225 | now + oneWeek, 226 | 2 * oneWeek, 227 | "Locked TestToken", 228 | "LTT", 229 | ])) as TokenLockT 230 | 231 | await token.connect(user).approve(tokenLock.address, ONE) 232 | 233 | await expect(() => 234 | tokenLock.connect(user).deposit(ONE) 235 | ).to.changeTokenBalance(tokenLock, user, ONE) 236 | 237 | expect(await tokenLock.totalSupply()).to.equal(ONE) 238 | }) 239 | 240 | it("emits a Transfer event", async () => { 241 | const { token, TokenLock } = await setupTest() 242 | const tokenLock = (await upgrades.deployProxy(TokenLock, [ 243 | owner.address, 244 | token.address, 245 | now + oneWeek, 246 | 2 * oneWeek, 247 | "Locked TestToken", 248 | "LTT", 249 | ])) as TokenLockT 250 | 251 | await token.connect(user).approve(tokenLock.address, ONE) 252 | 253 | await expect(tokenLock.connect(user).deposit(ONE)) 254 | .to.emit(tokenLock, "Transfer") 255 | .withArgs(user.address, tokenLock.address, ONE) 256 | }) 257 | }) 258 | 259 | describe("withdraw", () => { 260 | const setupWithLocked = async (weeksSinceLockEnd: number) => { 261 | const { token, TokenLock } = await setupTest() 262 | const tokenLock = (await upgrades.deployProxy(TokenLock, [ 263 | owner.address, 264 | token.address, 265 | now + oneWeek, 266 | 2 * oneWeek, 267 | "Locked TestToken", 268 | "LTT", 269 | ])) as TokenLockT 270 | 271 | await token.connect(user).approve(tokenLock.address, ONE) 272 | await tokenLock.connect(user).deposit(ONE) 273 | 274 | await network.provider.send("evm_setNextBlockTimestamp", [ 275 | now + 3 * oneWeek + weeksSinceLockEnd * oneWeek, 276 | ]) 277 | 278 | return { tokenLock, token } 279 | } 280 | 281 | it("reverts if the lock period is not over yet", async () => { 282 | const { tokenLock } = await setupWithLocked(-1) 283 | await expect(tokenLock.connect(user).withdraw(ONE)).to.be.revertedWith( 284 | "LockPeriodOngoing()" 285 | ) 286 | }) 287 | 288 | it("reverts if the claimed amount exceeds the balance", async () => { 289 | const { tokenLock } = await setupWithLocked(1) 290 | await expect( 291 | tokenLock.connect(user).withdraw(ONE.mul(2)) 292 | ).to.be.revertedWith("ExceedsBalance()") 293 | }) 294 | 295 | it("reverts if the token transfer is unsuccessful", async () => { 296 | const { TokenLock } = await setupTest() 297 | const FailingToken = await ethers.getContractFactory( 298 | "TestTokenFailingTransfer" 299 | ) 300 | const failingToken = await FailingToken.deploy(18) 301 | const tokenLock = (await upgrades.deployProxy(TokenLock, [ 302 | owner.address, 303 | failingToken.address, 304 | now + oneWeek, 305 | 2 * oneWeek, 306 | "Locked TestToken", 307 | "LTT", 308 | ])) as TokenLockT 309 | 310 | await failingToken.mint(user.address, ONE) 311 | await failingToken.connect(user).approve(tokenLock.address, ONE) 312 | await tokenLock.connect(user).deposit(ONE) 313 | 314 | await network.provider.send("evm_setNextBlockTimestamp", [ 315 | now + 4 * oneWeek, 316 | ]) 317 | 318 | await expect(tokenLock.connect(user).withdraw(ONE)).to.be.revertedWith( 319 | "TransferFailed()" 320 | ) 321 | }) 322 | 323 | it("transfers the locked token to the sender", async () => { 324 | const { tokenLock, token } = await setupWithLocked(1) 325 | 326 | await expect(() => 327 | tokenLock.connect(user).withdraw(ONE) 328 | ).to.changeTokenBalance(token, user, ONE) 329 | }) 330 | 331 | it("burns the redeemed lock tokens", async () => { 332 | const { tokenLock } = await setupWithLocked(1) 333 | 334 | await expect(() => 335 | tokenLock.connect(user).withdraw(ONE) 336 | ).to.changeTokenBalance(tokenLock, user, ONE.mul(-1)) 337 | expect(await tokenLock.totalSupply()).to.equal(0) 338 | }) 339 | 340 | it("emits a Transfer event", async () => { 341 | const { tokenLock } = await setupWithLocked(1) 342 | 343 | await expect(tokenLock.connect(user).withdraw(ONE)) 344 | .to.emit(tokenLock, "Transfer") 345 | .withArgs(tokenLock.address, user.address, ONE) 346 | }) 347 | 348 | it("allows withdrawals during the deposit period", async () => { 349 | // 2.5 weeks before the lock end, means 0.5 weeks before deposit deadline 350 | const { tokenLock } = await setupWithLocked(-2.5) 351 | 352 | await expect(tokenLock.connect(user).withdraw(ONE)) 353 | .to.emit(tokenLock, "Transfer") 354 | .withArgs(tokenLock.address, user.address, ONE) 355 | }) 356 | }) 357 | 358 | describe("decimals", () => { 359 | it("has the same value as the token that is locked", async () => { 360 | const { TokenLock, Token } = await setupTest() 361 | const tokenWith17Decimals = await Token.deploy(17) 362 | const tokenLock = (await upgrades.deployProxy(TokenLock, [ 363 | owner.address, 364 | tokenWith17Decimals.address, 365 | now + oneWeek, 366 | 2 * oneWeek, 367 | "Locked TestToken", 368 | "LTT", 369 | ])) as TokenLockT 370 | 371 | expect(await tokenLock.decimals()).to.equal(17) 372 | }) 373 | }) 374 | 375 | describe("balanceOf", () => { 376 | it("returns the correct balance for the given address", async () => { 377 | const { token, TokenLock } = await setupTest() 378 | const tokenLock = (await upgrades.deployProxy(TokenLock, [ 379 | owner.address, 380 | token.address, 381 | now + oneWeek, 382 | 2 * oneWeek, 383 | "Locked TestToken", 384 | "LTT", 385 | ])) as TokenLockT 386 | 387 | await token.connect(user).approve(tokenLock.address, ONE) 388 | await tokenLock.connect(user).deposit(ONE) 389 | 390 | expect(await tokenLock.balanceOf(user.address)).to.equal(ONE) 391 | }) 392 | }) 393 | }) 394 | -------------------------------------------------------------------------------- /packages/contracts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop": true, 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "noEmit": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "target": "es2020", 12 | "lib": ["es2016"], 13 | "typeRoots": [ 14 | "node_modules/@types", 15 | "typings", 16 | "typechain-types/hardhat.d.ts" 17 | ] 18 | }, 19 | "include": ["./src", "./test", "./typechain-types"], 20 | "files": ["./hardhat.config.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "husky@7.0.4": 6 | "integrity" "sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==" 7 | "resolved" "https://registry.npmjs.org/husky/-/husky-7.0.4.tgz" 8 | "version" "7.0.4" 9 | --------------------------------------------------------------------------------