diff --git a/.github/workflows/check-pr-title.yaml b/.github/workflows/check-pr-title.yaml deleted file mode 100644 index b741f571ec2..00000000000 --- a/.github/workflows/check-pr-title.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: "Check PR Title" - -on: - pull_request_target: - types: - - opened - - edited - - synchronize - -jobs: - check-pr-title: - name: Check PR Title - runs-on: ubuntu-latest - steps: - - uses: amannn/action-semantic-pull-request@v3.4.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/crowdin-sync.yaml b/.github/workflows/crowdin-sync.yaml deleted file mode 100644 index 213a594bcb2..00000000000 --- a/.github/workflows/crowdin-sync.yaml +++ /dev/null @@ -1,57 +0,0 @@ -name: Crowdin Download - -# hourly we sync translations from Crowdin -on: - schedule: - - cron: '0 * * * *' # every hour we download translations and update the pr from crowdin - - # manual trigger - workflow_dispatch: - -jobs: - download-translations: - name: Download translations - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up node - uses: actions/setup-node@v2 - with: - node-version: 14 - registry-url: https://registry.npmjs.org - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - - uses: actions/cache@v2 - id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Extract translations - run: "yarn i18n:extract" - - - name: Synchronize - uses: crowdin/github-action@1.1.0 - with: - upload_sources: false - download_translations: true - project_id: 458284 - token: ${{ secrets.CROWDIN_PERSONAL_TOKEN_SECRET }} - source: 'src/locales/en-US.po' - translation: 'src/locales/%locale%.po' - create_pull_request: false - localization_branch_name: main - commit_message: "chore(i18n): synchronize translations from crowdin [skip ci]" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/crowdin.yaml b/.github/workflows/crowdin.yaml deleted file mode 100644 index 558664f9e8e..00000000000 --- a/.github/workflows/crowdin.yaml +++ /dev/null @@ -1,52 +0,0 @@ -name: Crowdin Upload - -# on any push to main, we upload the translations to be translated -on: - push: - branches: - - main - -jobs: - synchronize-with-crowdin: - name: Upload sources to Crowdin - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up node - uses: actions/setup-node@v2 - with: - node-version: 14 - registry-url: https://registry.npmjs.org - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - - uses: actions/cache@v2 - id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Extract translations - run: "yarn i18n:extract" - - - name: Synchronize - uses: crowdin/github-action@1.1.0 - with: - upload_sources: true - download_translations: false - project_id: 458284 - token: ${{ secrets.CROWDIN_PERSONAL_TOKEN_SECRET }} - source: 'src/locales/en-US.po' - translation: 'src/locales/%locale%.po' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml deleted file mode 100644 index 5a02fdb51e0..00000000000 --- a/.github/workflows/integration-tests.yaml +++ /dev/null @@ -1,52 +0,0 @@ -name: Integration Tests - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - integration-tests: - name: Cypress - runs-on: ubuntu-16.04 - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up node - uses: actions/setup-node@v2 - with: - node-version: 14 - registry-url: https://registry.npmjs.org - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - - uses: actions/cache@v2 - id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - run: yarn cypress install - - run: yarn build - env: - CI: false - REACT_APP_NETWORK_URL: "https://mainnet.infura.io/v3/4bf032f2d38a4ed6bb975b80d6340847" - REACT_APP_SERVICE_WORKER: false - - - run: yarn integration-test - env: - CYPRESS_INTEGRATION_TEST_PRIVATE_KEY: ${{ secrets.CYPRESS_INTEGRATION_TEST_PRIVATE_KEY }} - CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} - - diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 5d765b9db11..00000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Lint - -on: - push: - branches: - - main - pull_request_target: - branches: - - main - -jobs: - run-linters: - name: Run linters - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up node - uses: actions/setup-node@v2 - with: - node-version: 14 - registry-url: https://registry.npmjs.org - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - - uses: actions/cache@v2 - id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Run linters - uses: wearerequired/lint-action@b98b0918aa71490373d2eca9e8e39a9bc1cc2517 - with: - github_token: ${{ secrets.github_token }} - eslint: true - eslint_extensions: js,jsx,ts,tsx,json - auto_fix: true diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml deleted file mode 100644 index 88fac4f4492..00000000000 --- a/.github/workflows/release.yaml +++ /dev/null @@ -1,107 +0,0 @@ -name: Release -on: - schedule: - - cron: '0 12 * * 1-4' # every day 12:00 UTC Monday-Thursday - - # manual trigger - workflow_dispatch: - -jobs: - bump_version: - name: Bump Version - runs-on: ubuntu-latest - outputs: - new_tag: ${{ steps.github_tag_action.outputs.new_tag }} - changelog: ${{ steps.github_tag_action.outputs.changelog }} - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Bump version and push tag - id: github_tag_action - uses: mathieudutour/github-tag-action@331898d5052eedac9b15fec867b5ba66ebf9b692 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - release_branches: .* - default_bump: false - - create_release: - name: Create Release - runs-on: ubuntu-latest - needs: bump_version - if: ${{ needs.bump_version.outputs.new_tag != null }} - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up node - uses: actions/setup-node@v2 - with: - node-version: 14 - registry-url: https://registry.npmjs.org - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Build the IPFS bundle - run: yarn build - - - name: Pin to IPFS - id: upload - uses: anantaramdas/ipfs-pinata-deploy-action@39bbda1ce1fe24c69c6f57861b8038278d53688d - with: - pin-name: Uniswap ${{ needs.bump_version.outputs.new_tag }} - path: './build' - pinata-api-key: ${{ secrets.PINATA_API_KEY }} - pinata-secret-api-key: ${{ secrets.PINATA_API_SECRET_KEY }} - - - name: Pin to Crust - uses: crustio/ipfs-crust-action@v2.0.3 - continue-on-error: true - timeout-minutes: 2 - with: - cid: ${{ steps.upload.outputs.hash }} - seeds: ${{ secrets.CRUST_SEEDS }} - - - name: Convert CIDv0 to CIDv1 - id: convert_cidv0 - uses: uniswap/convert-cidv0-cidv1@v1.0.0 - with: - cidv0: ${{ steps.upload.outputs.hash }} - - - name: Update DNS with new IPFS hash - env: - CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }} - RECORD_DOMAIN: 'uniswap.org' - RECORD_NAME: '_dnslink.app' - CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} - uses: textileio/cloudflare-update-dnslink@0fe7b7a1ffc865db3a4da9773f0f987447ad5848 - with: - cid: ${{ steps.upload.outputs.hash }} - - - name: Create GitHub Release - id: create_release - uses: actions/create-release@v1.1.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ needs.bump_version.outputs.new_tag }} - release_name: Release ${{ needs.bump_version.outputs.new_tag }} - body: | - IPFS hash of the deployment: - - CIDv0: `${{ steps.upload.outputs.hash }}` - - CIDv1: `${{ steps.convert_cidv0.outputs.cidv1 }}` - - The latest release is always accessible via our alias to the Cloudflare IPFS gateway at [app.uniswap.org](https://app.uniswap.org). - - You can also access the Uniswap Interface directly from an IPFS gateway. - **BEWARE**: The Uniswap interface uses [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) to remember your settings, such as which tokens you have imported. - **You should always use an IPFS gateway that enforces origin separation**, or our alias to the latest release at [app.uniswap.org](https://app.uniswap.org). - Your Uniswap settings are never remembered across different URLs. - - IPFS gateways: - - https://${{ steps.convert_cidv0.outputs.cidv1 }}.ipfs.dweb.link/ - - https://${{ steps.convert_cidv0.outputs.cidv1 }}.ipfs.cf-ipfs.com/ - - [ipfs://${{ steps.upload.outputs.hash }}/](ipfs://${{ steps.upload.outputs.hash }}/) - - ${{ needs.bump_version.outputs.changelog }} diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml deleted file mode 100644 index a0dbfa63c81..00000000000 --- a/.github/workflows/unit-tests.yaml +++ /dev/null @@ -1,40 +0,0 @@ -name: Unit Tests -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - unit-tests: - name: Unit tests - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Set up node - uses: actions/setup-node@v2 - with: - node-version: 14 - registry-url: https://registry.npmjs.org - - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" - - - uses: actions/cache@v2 - id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) - with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Run unit tests - run: yarn test diff --git a/package.json b/package.json index 650d47e1294..6707822579e 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@uniswap/v3-core": "1.0.0", "@uniswap/v3-periphery": "^1.1.1", "@uniswap/v3-sdk": "^3.3.0", + "@uniswap/v3-staker": "^1.0.0", "@web3-react/core": "^6.0.9", "@web3-react/fortmatic-connector": "^6.0.9", "@web3-react/injected-connector": "^6.0.7", @@ -125,7 +126,7 @@ "scripts": { "compile-contract-types": "yarn compile-external-abi-types && yarn compile-v3-contract-types", "compile-external-abi-types": "typechain --target ethers-v5 --out-dir src/abis/types './src/abis/**/*.json'", - "compile-v3-contract-types": "typechain --target ethers-v5 --out-dir src/types/v3 './node_modules/@uniswap/?(v3-core|v3-periphery)/artifacts/contracts/**/*.json'", + "compile-v3-contract-types": "typechain --target ethers-v5 --out-dir src/types/v3 './node_modules/@uniswap/?(v3-core|v3-periphery|v3-staker)/artifacts/contracts/**/*.json'", "build": "yarn compile-contract-types && yarn graphql:generate && yarn i18n:extract && react-scripts build", "i18n:extract": "lingui extract --locale en-US", "integration-test": "start-server-and-test 'serve build -l 3000' http://localhost:3000 'cypress run --record'", diff --git a/src/assets/images/earn-bg-image.png b/src/assets/images/earn-bg-image.png new file mode 100644 index 00000000000..87597f7eafd Binary files /dev/null and b/src/assets/images/earn-bg-image.png differ diff --git a/src/components/Badge/RangeBadge.tsx b/src/components/Badge/RangeBadge.tsx index 319ad2b17af..10462fb0f71 100644 --- a/src/components/Badge/RangeBadge.tsx +++ b/src/components/Badge/RangeBadge.tsx @@ -11,9 +11,9 @@ const BadgeWrapper = styled.div` justify-content: flex-end; ` -const BadgeText = styled.div` +const BadgeText = styled.div<{ small?: boolean }>` font-weight: 500; - font-size: 14px; + font-size: ${({ small }) => (small ? '12px' : '14px')}; ` const ActiveDot = styled.span` @@ -27,9 +27,11 @@ const ActiveDot = styled.span` export default function RangeBadge({ removed, inRange, + small, }: { removed: boolean | undefined inRange: boolean | undefined + small?: boolean | undefined }) { return ( @@ -38,7 +40,7 @@ export default function RangeBadge({   - + Closed @@ -53,7 +55,7 @@ export default function RangeBadge({ >   - + In range @@ -69,7 +71,7 @@ export default function RangeBadge({   - + Out of range diff --git a/src/components/Badge/index.tsx b/src/components/Badge/index.tsx index 4a071f29ec9..f17aaaa9b04 100644 --- a/src/components/Badge/index.tsx +++ b/src/components/Badge/index.tsx @@ -1,4 +1,4 @@ -import { readableColor } from 'polished' +import { readableColor, transparentize } from 'polished' import { PropsWithChildren } from 'react' import styled, { DefaultTheme } from 'styled-components/macro' import { Color } from 'theme/styled' @@ -71,3 +71,43 @@ const Badge = styled.div>` ` export default Badge + +export const GenericBadge = styled.div` + display: flex; + width: fit-content; + justify-content: center; + align-items: center; + padding: 4px 8px; + border-radius: 8px; +` + +export const GreenBadge = styled.div` + background-color: ${({ theme }) => transparentize(0.86, theme.green1)}; + padding: 6px 8px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + width: fit-content; + white-space: nowrap; +` + +export const BlueBadge = styled.div` + background-color: ${({ theme }) => transparentize(0.92, theme.blue2)}; + padding: 6px 8px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + width: fit-content; +` + +// slightly transparent +export const EmptyBadge = styled.div` + background-color: ${({ theme }) => transparentize(0.7, theme.bg3)}; + padding: 6px 8px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; +` diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 82d25f8ed82..108317f51d4 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -103,6 +103,13 @@ export const ButtonLight = styled(Base)` } ` +export const ButtonSmall = styled(ButtonPrimary)` + width: fit-content; + padding: 8px; + border-radius: 8px; + font-size: 14px; +` + export const ButtonGray = styled(Base)` background-color: ${({ theme }) => theme.bg1}; color: ${({ theme }) => theme.text2}; @@ -117,6 +124,29 @@ export const ButtonGray = styled(Base)` } ` +export const ButtonLightGray = styled(Base)` + background-color: ${({ theme }) => theme.bg2}; + color: ${({ theme }) => theme.text1}; + font-size: 16px; + font-weight: 500; + + &:hover { + background-color: ${({ theme, disabled }) => !disabled && darken(0.05, theme.bg3)}; + } + &:active { + background-color: ${({ theme, disabled }) => !disabled && darken(0.1, theme.bg3)}; + } +` + +export const ButtonGreySmall = styled(ButtonGray)` + width: fit-content; + background-color: ${({ theme }) => theme.bg2}; + color: ${({ theme }) => theme.text1}; + padding: 8px; + border-radius: 8px; + font-size: 14px; +` + export const ButtonSecondary = styled(Base)` border: 1px solid ${({ theme }) => theme.primary4}; color: ${({ theme }) => theme.primary1}; diff --git a/src/components/DoubleLogo/index.tsx b/src/components/DoubleLogo/index.tsx index c13c0925019..9877f7a4788 100644 --- a/src/components/DoubleLogo/index.tsx +++ b/src/components/DoubleLogo/index.tsx @@ -29,7 +29,7 @@ export default function DoubleCurrencyLogo({ currency1, size = 16, margin = false, -}: DoubleCurrencyLogoProps) { +}: DoubleCurrencyLogoProps & React.HTMLAttributes) { return ( {currency0 && } diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index 840bd932223..fb3a752f870 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -284,6 +284,9 @@ export default function Header() { > Pool + + Stake + {chainId && chainId === SupportedChainId.MAINNET && ( Vote diff --git a/src/components/RangeStatus/index.tsx b/src/components/RangeStatus/index.tsx new file mode 100644 index 00000000000..e29094f9ab0 --- /dev/null +++ b/src/components/RangeStatus/index.tsx @@ -0,0 +1,130 @@ +import RangeBadge from 'components/Badge/RangeBadge' +import { RowFixed } from 'components/Row' +import React, { useMemo } from 'react' +import styled from 'styled-components/macro' +import { Trans } from '@lingui/macro' +import HoverInlineText from 'components/HoverInlineText' +import { formatTickPrice } from 'utils/formatTickPrice' +import { HideSmall, SmallOnly } from 'theme' +import { PositionDetails } from 'types/position' +import { useToken } from 'hooks/Tokens' +import { unwrappedToken } from 'utils/unwrappedToken' +import { usePool } from 'hooks/usePools' +import { Position } from '@uniswap/v3-sdk' +import { getPriceOrderingFromPositionForUI } from 'components/PositionListItem' +import useIsTickAtLimit from 'hooks/useIsTickAtLimit' +import { Bound } from 'state/mint/v3/actions' + +const DataLineItem = styled.div` + font-size: 14px; +` + +const RangeLineItem = styled(DataLineItem)` + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + user-select: none; + + ${({ theme }) => theme.mediaWidth.upToSmall` + background-color: ${({ theme }) => theme.bg2}; + border-radius: 12px; + padding: 8px 0; +`}; +` + +const DoubleArrow = styled.span` + margin: 0 2px; + color: ${({ theme }) => theme.text3}; + ${({ theme }) => theme.mediaWidth.upToSmall` + margin: 4px; + padding: 20px; + `}; +` + +const RangeText = styled.span<{ small?: boolean }>` + padding: 0.25rem 0.5rem; + border-radius: 8px; + user-select: none; + font-size: ${({ small }) => (small ? '12px' : '14px')}; +` + +const ExtentsText = styled.span` + color: ${({ theme }) => theme.text3}; + font-size: 14px; + margin-right: 4px; + ${({ theme }) => theme.mediaWidth.upToLarge` + display: none; + `}; +` + +interface RangeStatusProps { + positionDetails: PositionDetails + small?: boolean // less text smaller font +} + +export default function RangeStatus({ positionDetails, small }: RangeStatusProps) { + const { + token0: token0Address, + token1: token1Address, + fee: feeAmount, + liquidity, + tickLower, + tickUpper, + } = positionDetails + + const token0 = useToken(token0Address) + const token1 = useToken(token1Address) + + const currency0 = token0 ? unwrappedToken(token0) : undefined + const currency1 = token1 ? unwrappedToken(token1) : undefined + + const [, pool] = usePool(currency0 ?? undefined, currency1 ?? undefined, feeAmount) + + const position = useMemo(() => { + if (pool) { + return new Position({ pool, liquidity: liquidity.toString(), tickLower, tickUpper }) + } + return undefined + }, [liquidity, pool, tickLower, tickUpper]) + + // meta data about position + const { priceLower, priceUpper, quote, base } = getPriceOrderingFromPositionForUI(position) + const currencyQuote = quote && unwrappedToken(quote) + const currencyBase = base && unwrappedToken(base) + const tickAtLimit = useIsTickAtLimit(feeAmount, tickLower, tickUpper) + const outOfRange: boolean = pool ? pool.tickCurrent < tickLower || pool.tickCurrent >= tickUpper : false + const removed = liquidity?.eq(0) + + return ( + + + + + + Min: + + + {formatTickPrice(priceLower, tickAtLimit, Bound.LOWER)} per{' '} + + + {' '} + + {' '} + + + {' '} + + + + Max: + + + {formatTickPrice(priceUpper, tickAtLimit, Bound.UPPER)} per{' '} + + + + + + ) +} diff --git a/src/components/Toggle/AppleToggle.tsx b/src/components/Toggle/AppleToggle.tsx new file mode 100644 index 00000000000..392ce21d67c --- /dev/null +++ b/src/components/Toggle/AppleToggle.tsx @@ -0,0 +1,46 @@ +import { darken } from 'polished' +import { ReactNode } from 'react' +import styled from 'styled-components/macro' + +const ToggleElement = styled.span<{ isActive?: boolean; isOnSwitch?: boolean }>` + padding: 11px; + border-radius: 50%; + background: ${({ theme, isActive, isOnSwitch }) => (isActive ? (isOnSwitch ? theme.white : theme.white) : 'none')}; + :hover { + user-select: ${({ isOnSwitch }) => (isOnSwitch ? 'none' : 'initial')}; + background: ${({ theme, isActive, isOnSwitch }) => + isActive ? (isOnSwitch ? darken(0.05, theme.white) : darken(0.05, theme.white)) : 'none'}; + opacity: 0.8; + } +` + +const StyledToggle = styled.button<{ isActive?: boolean; activeElement?: boolean }>` + border-radius: 20px; + border: none; + background: ${({ theme, isActive }) => (isActive ? theme.blue1 : theme.bg3)}; + display: flex; + cursor: pointer; + outline: none; + padding: 3px; + + :hover { + opacity: 0.8; + } +` + +interface ToggleProps { + id?: string + isActive: boolean + toggle: () => void + checked?: ReactNode + unchecked?: ReactNode +} + +export default function AppleToggle({ id, isActive, toggle }: ToggleProps) { + return ( + + + + + ) +} diff --git a/src/components/earn/Countdown.tsx b/src/components/earn/Countdown.tsx new file mode 100644 index 00000000000..63690c27470 --- /dev/null +++ b/src/components/earn/Countdown.tsx @@ -0,0 +1,95 @@ +import { transparentize } from 'polished' +import { useEffect, useState } from 'react' +import styled from 'styled-components/macro' +import { TYPE } from '../../theme' + +const MINUTE = 60 +const HOUR = MINUTE * 60 +const DAY = HOUR * 24 + +const MonoFront = styled(TYPE.body)<{ faded?: boolean; ended?: boolean }>` + font-variant-numeric: tabular-nums; + background-color: ${({ theme, ended }) => (ended ? theme.bg2 : transparentize(0.7, theme.bg3))}; + color: ${({ theme, ended }) => (ended ? theme.text3 : theme.text1)}; + padding: 6px 8px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 15px; + opacity: ${({ faded }) => (faded ? 0.5 : 1)}; + + > * { + text-align: center; + font-size: 15px; + } +` + +const Dot = styled.div` + width: 8px; + height: 8px; + border-radius: 50%; + background-color: ${({ theme }) => theme.white}; +` + +export default function Countdown({ exactStart, exactEnd }: { exactStart: Date; exactEnd: Date }) { + // get end/beginning times + const begin = Math.floor(exactStart.getTime() / 1000) + const end = Math.floor(exactEnd.getTime() / 1000) + + // get current time + const [time, setTime] = useState(() => Math.floor(Date.now() / 1000)) + useEffect((): (() => void) | void => { + // we only need to tick if rewards haven't ended yet + if (time <= end) { + const timeout = setTimeout(() => setTime(Math.floor(Date.now() / 1000)), 1000) + return () => { + clearTimeout(timeout) + } + } + }, [time, end]) + + const timeUntilGenesis = begin - time + const timeUntilEnd = end - time + + let timeRemaining: number + let message: string + if (timeUntilGenesis >= 0) { + message = '' + timeRemaining = timeUntilGenesis + } else { + const ongoing = timeUntilEnd >= 0 + if (ongoing) { + message = '' + timeRemaining = timeUntilEnd + } else { + message = 'Ended' + timeRemaining = Infinity + } + } + + const days = (timeRemaining - (timeRemaining % DAY)) / DAY + timeRemaining -= days * DAY + const hours = (timeRemaining - (timeRemaining % HOUR)) / HOUR + timeRemaining -= hours * HOUR + const minutes = (timeRemaining - (timeRemaining % MINUTE)) / MINUTE + timeRemaining -= minutes * MINUTE + const seconds = timeRemaining + + return ( + = 0} ended={!timeRemaining}> + {message !== '' ? message : } + {Number.isFinite(timeRemaining) && ( + + {timeUntilGenesis >= 0 + ? `${days}d ${hours.toString().padStart(2, '0')}h` + : `${days}d ${hours.toString().padStart(2, '0')}h ${minutes.toString().padStart(2, '0')}m ${seconds + .toString() + .padStart(2, '0')}`} + s + + )} + + ) +} diff --git a/src/components/earn/IncentiveInfoBar.tsx b/src/components/earn/IncentiveInfoBar.tsx new file mode 100644 index 00000000000..b318450e923 --- /dev/null +++ b/src/components/earn/IncentiveInfoBar.tsx @@ -0,0 +1,167 @@ +import Row, { RowBetween, RowFixed } from 'components/Row' +import CurrencyLogo from 'components/CurrencyLogo' +import { Incentive } from 'hooks/incentives/useAllIncentives' +import styled from 'styled-components/macro' +import { TYPE } from 'theme' +import { darken, transparentize } from 'polished' +import { useUSDCValue } from 'hooks/useUSDCPrice' +import { BIG_INT_SECONDS_IN_WEEK } from 'constants/misc' +import { formatCurrencyAmount } from 'utils/formatCurrencyAmount' +import { unwrappedToken } from 'utils/unwrappedToken' +import useTheme from 'hooks/useTheme' +import { EmptyBadge, GreenBadge } from 'components/Badge' +import useCountdownTime from 'hooks/useCountdownTime' +import { AutoColumn } from 'components/Column' +import { Trans } from '@lingui/macro' +import Countdown from './Countdown' + +const Wrapper = styled.div` + display: flex; +` + +const BadgeText = styled(TYPE.body)` + white-space: nowrap; +` + +const BarWrapper = styled.div` + width: 100%; + height: calc(100% - 8px); + border-radius: 20px; + background-color: ${({ theme }) => transparentize(0.7, theme.bg3)}; +` + +const Bar = styled.div<{ percent: number; color?: string }>` + width: ${({ percent }) => `${percent}%`}; + height: 100%; + border-radius: inherit; + background: ${({ color, theme }) => + color ? `linear-gradient(to left, ${darken(0.18, color)}, ${darken(0.01, color)});` : theme.blue1}; + display: flex; + align-items: center; + padding: 4px; +` + +const WrappedLogo = styled(CurrencyLogo)` + border: 1px solid black; +` + +const LogoSquare = styled.div` + background: rgba(243, 51, 143, 0.1); + padding: 12px; + border-radius: 12px; + display: flex; + justify-content: center; + align-items: center; + margin-right: 1rem; +` + +const TitleGrid = styled.div` + padding: 0px; + display: grid; + grid-template-columns: 3fr 168px 168px; + grid-column-gap: 24px; + align-items: center; + width: 100%; +` + +interface IncentiveInfoBarProps { + incentive: Incentive + expanded?: boolean +} + +export default function IncentiveInfoBar({ incentive, expanded }: IncentiveInfoBarProps) { + const theme = useTheme() + + const rewardToken = incentive.initialRewardAmount.currency + const rewardCurrency = unwrappedToken(rewardToken) + + const rewardTokensPerWeek = incentive.rewardRatePerSecond.multiply(BIG_INT_SECONDS_IN_WEEK) + + // may be null if no usd price for token + const usdPerWeek = useUSDCValue(rewardTokensPerWeek) + + const percentageRemaining = + (parseFloat(incentive.rewardAmountRemaining.toExact()) / parseFloat(incentive.initialRewardAmount.toExact())) * 100 + + // get countdown info if needed + const startDate = new Date(incentive.startTime * 1000) + const endDate = new Date(incentive.endTime * 1000) + const beginsInFuture = incentive.startTime > Date.now() / 1000 + const countdownTimeText = useCountdownTime(startDate, endDate) + + return ( + + + {!expanded ? null : ( + + + + + {`${rewardCurrency.symbol} Boost`} + + + + + )} + + {expanded ? null : ( + + + + )} + + + + REWARDS REMAINING + + + TOTAL DEPOSITS + + + REWARDS + + + + {beginsInFuture ? ( + + + + NEW + + + + {countdownTimeText} + + + ) : ( + + + + + + {percentageRemaining}% remaining + + + + + )} + + + $58,022 + + + + + {usdPerWeek + ? `$${formatCurrencyAmount(usdPerWeek, 2)}` + : `${formatCurrencyAmount(rewardTokensPerWeek, 3)} ${rewardToken.symbol}`}{' '} + Weekly + + + + + + + + ) +} diff --git a/src/components/earn/PositionManageCard.tsx b/src/components/earn/PositionManageCard.tsx new file mode 100644 index 00000000000..159134d485c --- /dev/null +++ b/src/components/earn/PositionManageCard.tsx @@ -0,0 +1,172 @@ +import { Trans } from '@lingui/macro' +import Badge from 'components/Badge' +import { ButtonSmall } from 'components/Button' +import { AutoColumn } from 'components/Column' +import CurrencyLogo from 'components/CurrencyLogo' +import { AutoRow, RowBetween, RowFixed } from 'components/Row' +import { BIG_INT_ZERO } from 'constants/misc' +import { Incentive } from 'hooks/incentives/useAllIncentives' +import useTheme from 'hooks/useTheme' +import { useMemo, useState } from 'react' +import { Zap } from 'react-feather' +import { Link } from 'react-router-dom' +import styled from 'styled-components/macro' +import { TYPE } from 'theme' +import { PositionDetails } from 'types/position' +import { formatCurrencyAmount } from 'utils/formatCurrencyAmount' +import StakingModal, { ClaimModal, UnstakeModal } from './StakingModal' +import RangeStatus from 'components/RangeStatus' +import { BigNumber } from 'ethers' + +const Wrapper = styled.div` + width: 100%; +` + +const PositionWrapper = styled.div<{ staked?: boolean }>` + width: 100%; + border: 1px solid ${({ theme, staked }) => (staked ? theme.blue3 : theme.bg3)}; + border-radius: 12px; + padding: 16px; +` + +interface BoostStatusRowProps { + incentive: Incentive + positionDetails: PositionDetails + unstaked?: boolean // show minimal UI on unstaked positions + isPositionPage?: boolean +} + +function BoostStatusRow({ incentive, positionDetails, unstaked, isPositionPage }: BoostStatusRowProps) { + const theme = useTheme() + + const rewardCurrency = incentive.initialRewardAmount.currency + + const availableClaim = incentive.initialRewardAmount + const weeklyRewards = incentive.initialRewardAmount + const totalUnclaimedUSD = 0 + + const [showStakingModal, setShowStakingModal] = useState(false) + const [showClaimModal, setShowClaimModal] = useState(false) + const [showUnstakeModal, setShowUnstakeModal] = useState(false) + + return ( + <> + setShowStakingModal(false)} incentive={incentive} /> + setShowClaimModal(false)} incentives={[incentive]} /> + setShowUnstakeModal(false)} incentives={[incentive]} /> + {unstaked ? ( + + + + setShowStakingModal(true)}> + Stake Position + + + + ) : ( + + + + {isPositionPage ? ( + + + + + Position is Staked + + + + Manage + + + ) : null} + {isPositionPage ? null : } + + UNCLAIMED REWARDS + + + + + + {totalUnclaimedUSD + ? '$' + totalUnclaimedUSD + : `${formatCurrencyAmount(availableClaim, 5)} ${rewardCurrency.symbol}`} + + + + + + {`~ ${formatCurrencyAmount(weeklyRewards, 5)} ${rewardCurrency.symbol} / Week `} + + + + + {availableClaim.greaterThan(BIG_INT_ZERO) ? ( + setShowClaimModal(true)}> + Claim + + ) : null} + setShowUnstakeModal(true)}> + Unstake + + + + + + + )} + + ) +} + +interface PositionManageCardProps { + positionDetails: PositionDetails + isPositionPage?: boolean +} + +export default function PositionManageCard({ positionDetails, isPositionPage }: PositionManageCardProps) { + const { stakes } = positionDetails + + // filter incentives that are staked and unstaked + const [staked, unstaked] = useMemo( + () => + stakes.slice(0, 1).reduce( + (accum: Incentive[][], stake) => { + if (stake.liquidity.gt(BigNumber.from(0))) { + accum[0].push(stake.incentive) + } else { + accum[1].push(stake.incentive) + } + return accum + }, + [[], []] + ), + [stakes] + ) + + return ( + + + {staked.map((incentive, i) => ( + + ))} + {unstaked.map((incentive, i) => { + return ( + + ) + })} + + + ) +} diff --git a/src/components/earn/ProgramCard.tsx b/src/components/earn/ProgramCard.tsx new file mode 100644 index 00000000000..593e1afa013 --- /dev/null +++ b/src/components/earn/ProgramCard.tsx @@ -0,0 +1,119 @@ +import { RowFixed } from 'components/Row' +import { TYPE } from 'theme' +import { Incentive } from '../../hooks/incentives/useAllIncentives' +import { usePoolsByAddresses } from 'hooks/usePools' +import DoubleCurrencyLogo from 'components/DoubleLogo' +import { LoadingRows } from 'pages/Pool/styleds' +import Badge, { GreenBadge, BlueBadge } from 'components/Badge' +import { formattedFeeAmount } from 'utils' +import { CardWrapper } from './styled' +import { unwrappedToken } from 'utils/unwrappedToken' +import { Trans } from '@lingui/macro' +import useTheme from 'hooks/useTheme' +import { ButtonSmall } from 'components/Button' +import { Link } from 'react-router-dom' +import { useActiveWeb3React } from 'hooks/web3' +import { useV3PositionsForPool } from 'hooks/useV3Positions' +import { useMemo } from 'react' +import { BigNumber } from 'ethers' +import { OverviewGrid } from './styled' +import { BIG_INT_SECONDS_IN_WEEK } from 'constants/misc' +import { useUSDCValue } from 'hooks/useUSDCPrice' +import { formatCurrencyAmount } from 'utils/formatCurrencyAmount' +import CurrencyLogo from 'components/CurrencyLogo' + +interface ProgramCardProps { + poolAddress: string + incentives: Incentive[] // will be set at 1 incentive while UNI incentives only + hideStake?: boolean // hide stake button on manage page +} + +// Overview all all incentive programs for a given pool +export default function ProgramCard({ poolAddress, incentives }: ProgramCardProps) { + const theme = useTheme() + const { account } = useActiveWeb3React() + const [, pool] = usePoolsByAddresses([poolAddress])[0] + + const currency0 = pool ? unwrappedToken(pool.token0) : undefined + const currency1 = pool ? unwrappedToken(pool.token1) : undefined + + const { positions } = useV3PositionsForPool(account, pool) + + const [amountBoosted, amountAvailable] = useMemo(() => { + if (!positions) { + return [0, 0] + } + // loop through all stakes - count # where liquidity is > 0 + return positions.reduce( + (accum, position) => { + position.stakes.map((stake) => { + if (incentives.includes(stake.incentive) && stake.liquidity.gt(BigNumber.from(0))) { + accum[0]++ + } else { + accum[1]++ + } + }) + return accum + }, + [0, 0] + ) + }, [incentives, positions]) + + /** + * @todo + */ + const rewardCurrency = incentives[0].initialRewardAmount.currency + const activeLiquidity = incentives[0].initialRewardAmount + const activeLiquidityUSD = useUSDCValue(activeLiquidity) + const rewardPerDay = incentives[0].rewardRatePerSecond.multiply(BIG_INT_SECONDS_IN_WEEK) + + return ( + + {!pool || !currency0 || !currency1 ? ( + +
+ + ) : ( + + + + + {`${currency0.symbol} / ${currency1.symbol}`} + + {formattedFeeAmount(pool.fee)}% + + {amountBoosted > 0 ? ( + + + {amountBoosted} Boosted + + + ) : null} + {amountAvailable > 0 ? ( + + + {amountAvailable} Available + + + ) : null} + + + + {activeLiquidityUSD + ? `$${formatCurrencyAmount(activeLiquidityUSD, 2)}` + : `${formatCurrencyAmount(activeLiquidity, 4)} ${rewardCurrency.symbol}`} + + + + {`${formatCurrencyAmount(rewardPerDay, 4)} ${ + rewardCurrency.symbol + } / day`} + + + Manage + + + )} + + ) +} diff --git a/src/components/earn/StakingModal.tsx b/src/components/earn/StakingModal.tsx index c1e2dcd0646..286c6ba59ed 100644 --- a/src/components/earn/StakingModal.tsx +++ b/src/components/earn/StakingModal.tsx @@ -1,238 +1,215 @@ -import { useState, useCallback } from 'react' -import { useV2LiquidityTokenPermit } from '../../hooks/useERC20Permit' -import useTransactionDeadline from '../../hooks/useTransactionDeadline' -import { formatCurrencyAmount } from '../../utils/formatCurrencyAmount' -import Modal from '../Modal' -import { AutoColumn } from '../Column' +import { Trans } from '@lingui/macro' +import { GreenBadge } from 'components/Badge' +import { ButtonPrimary } from 'components/Button' +import Card from 'components/Card' +import { AutoColumn } from 'components/Column' +import CurrencyLogo from 'components/CurrencyLogo' +import Modal from 'components/Modal' +import { AutoRow, RowBetween, RowFixed } from 'components/Row' +import { BIG_INT_SECONDS_IN_WEEK } from 'constants/misc' +import { Incentive } from 'hooks/incentives/useAllIncentives' +import useTheme from 'hooks/useTheme' +import { useUSDCValue } from 'hooks/useUSDCPrice' +import { AlertCircle } from 'react-feather' import styled from 'styled-components/macro' -import { RowBetween } from '../Row' -import { TYPE, CloseIcon } from '../../theme' -import { ButtonConfirmed, ButtonError } from '../Button' -import ProgressCircles from '../ProgressSteps' -import CurrencyInputPanel from '../CurrencyInputPanel' -import { Pair } from '@uniswap/v2-sdk' -import { Token, CurrencyAmount } from '@uniswap/sdk-core' -import { useActiveWeb3React } from '../../hooks/web3' -import { maxAmountSpend } from '../../utils/maxAmountSpend' -import { usePairContract, useStakingContract, useV2RouterContract } from '../../hooks/useContract' -import { useApproveCallback, ApprovalState } from '../../hooks/useApproveCallback' -import { StakingInfo, useDerivedStakeInfo } from '../../state/stake/hooks' -import { TransactionResponse } from '@ethersproject/providers' -import { useTransactionAdder } from '../../state/transactions/hooks' -import { LoadingView, SubmittedView } from '../ModalViews' -import { t, Trans } from '@lingui/macro' +import { CloseIcon, TYPE } from 'theme' +import { formatCurrencyAmount } from 'utils/formatCurrencyAmount' +import Countdown from './Countdown' -const HypotheticalRewardRate = styled.div<{ dim: boolean }>` - display: flex; - justify-content: space-between; - padding-right: 20px; - padding-left: 20px; - - opacity: ${({ dim }) => (dim ? 0.5 : 1)}; +const Wrapper = styled.div` + width: 100%; + padding: 20px; ` -const ContentWrapper = styled(AutoColumn)` - width: 100%; - padding: 1rem; +export const DarkerGreyCard = styled(Card)` + background-color: ${({ theme }) => theme.bg1}; ` interface StakingModalProps { isOpen: boolean onDismiss: () => void - stakingInfo: StakingInfo - userLiquidityUnstaked: CurrencyAmount | undefined + incentive: Incentive } -export default function StakingModal({ isOpen, onDismiss, stakingInfo, userLiquidityUnstaked }: StakingModalProps) { - const { library } = useActiveWeb3React() - - // track and parse user input - const [typedValue, setTypedValue] = useState('') - const { parsedAmount, error } = useDerivedStakeInfo( - typedValue, - stakingInfo.stakedAmount.currency, - userLiquidityUnstaked - ) - const parsedAmountWrapped = parsedAmount?.wrapped - - let hypotheticalRewardRate: CurrencyAmount = CurrencyAmount.fromRawAmount(stakingInfo.rewardRate.currency, '0') - if (parsedAmountWrapped?.greaterThan('0')) { - hypotheticalRewardRate = stakingInfo.getHypotheticalRewardRate( - stakingInfo.stakedAmount.add(parsedAmountWrapped), - stakingInfo.totalStakedAmount.add(parsedAmountWrapped), - stakingInfo.totalRewardRate - ) - } +export default function StakingModal({ isOpen, onDismiss, incentive }: StakingModalProps) { + const theme = useTheme() + const startDate = new Date(incentive.startTime * 1000) + const endDate = new Date(incentive.endTime * 1000) - // state for pending and submitted txn views - const addTransaction = useTransactionAdder() - const [attempting, setAttempting] = useState(false) - const [hash, setHash] = useState() - const wrappedOnDismiss = useCallback(() => { - setHash(undefined) - setAttempting(false) - onDismiss() - }, [onDismiss]) + const weeklyRewards = incentive.rewardRatePerSecond.multiply(BIG_INT_SECONDS_IN_WEEK) + const weeklyRewardsUSD = useUSDCValue(weeklyRewards) - // pair contract for this token to be staked - const dummyPair = new Pair( - CurrencyAmount.fromRawAmount(stakingInfo.tokens[0], '0'), - CurrencyAmount.fromRawAmount(stakingInfo.tokens[1], '0') + return ( + + + + + + Review Position Staking + + + + + + + + + {`${incentive.initialRewardAmount.currency.symbol} Boost`} + + + + + + YOUR ESTIMATED REWARDS + + {weeklyRewardsUSD ? ( + + {`$${weeklyRewardsUSD.toFixed(2)} per week`} + {`~(${formatCurrencyAmount(weeklyRewards, 4)})`} + + ) : ( + {`${formatCurrencyAmount(weeklyRewards, 4)} ${ + weeklyRewards.currency.symbol + } per week`} + )} + + + + + + Boosting liquidity deposits your liquidity in the Uniswap Liquidity mining contracts. When boosted, your + liquidity will continue to earn fees while in range. You must remove boosts to be able to claim fees or + withdraw liquidity. + + + + Join Programs + + + + ) - const pairContract = usePairContract(dummyPair.liquidityToken.address) - - // approval data for stake - const deadline = useTransactionDeadline() - const router = useV2RouterContract() - const { signatureData, gatherPermitSignature } = useV2LiquidityTokenPermit(parsedAmountWrapped, router?.address) - const [approval, approveCallback] = useApproveCallback(parsedAmount, stakingInfo.stakingRewardAddress) - - const stakingContract = useStakingContract(stakingInfo.stakingRewardAddress) - async function onStake() { - setAttempting(true) - if (stakingContract && parsedAmount && deadline) { - if (approval === ApprovalState.APPROVED) { - await stakingContract.stake(`0x${parsedAmount.quotient.toString(16)}`, { gasLimit: 350000 }) - } else if (signatureData) { - stakingContract - .stakeWithPermit( - `0x${parsedAmount.quotient.toString(16)}`, - signatureData.deadline, - signatureData.v, - signatureData.r, - signatureData.s, - { gasLimit: 350000 } - ) - .then((response: TransactionResponse) => { - addTransaction(response, { - summary: t`Deposit liquidity`, - }) - setHash(response.hash) - }) - .catch((error: any) => { - setAttempting(false) - console.log(error) - }) - } else { - setAttempting(false) - throw new Error('Attempting to stake without approval or a signature. Please contact support.') - } - } - } - - // wrapped onUserInput to clear signatures - const onUserInput = useCallback((typedValue: string) => { - setTypedValue(typedValue) - }, []) - - // used for max input button - const maxAmountInput = maxAmountSpend(userLiquidityUnstaked) - const atMaxAmount = Boolean(maxAmountInput && parsedAmount?.equalTo(maxAmountInput)) - const handleMax = useCallback(() => { - maxAmountInput && onUserInput(maxAmountInput.toExact()) - }, [maxAmountInput, onUserInput]) +} - async function onAttemptToApprove() { - if (!pairContract || !library || !deadline) throw new Error('missing dependencies') - if (!parsedAmount) throw new Error('missing liquidity amount') +interface ClaimModalProps { + incentives: Incentive[] + isOpen: boolean + onDismiss: () => void +} - if (gatherPermitSignature) { - try { - await gatherPermitSignature() - } catch (error) { - // try to approve if gatherPermitSignature failed for any reason other than the user rejecting it - if (error?.code !== 4001) { - await approveCallback() - } - } - } else { - await approveCallback() - } - } +export function ClaimModal({ incentives, isOpen, onDismiss }: ClaimModalProps) { + /** + * @TODO + * real claim amounts + */ return ( - - {!attempting && !hash && ( - + + + - - Deposit - - + + Claim Rewards + + - Available to deposit: {formatCurrencyAmount(amount, 4)}} - id="stake-liquidity-token" - /> + + + + TOTAL UNCLAIMED REWARDS + + {incentives.map((incentive, i) => ( + + + + {formatCurrencyAmount(incentive.initialRewardAmount, 5)} + + + {incentive.initialRewardAmount.currency.symbol} + + + ))} + + + + Claim + + + + + + + Claiming rewards withdraws the rewards into your wallet. Your liquidity remains staked and will + continue to earn fees when in range. + + + + + + + + ) +} - -
- - Weekly Rewards - -
+interface UnstakeModalProps { + incentives: Incentive[] + isOpen: boolean + onDismiss: () => void +} - - - {hypotheticalRewardRate - .multiply((60 * 60 * 24 * 7).toString()) - .toSignificant(4, { groupSeparator: ',' })}{' '} - UNI / week - - -
+export function UnstakeModal({ incentives, isOpen, onDismiss }: UnstakeModalProps) { + /** + * @TODO + * real claim amounts + */ + return ( + + + - - Approve - - - {error ?? Deposit} - - - -
- )} - {attempting && !hash && ( - - - - Depositing Liquidity - - - {parsedAmount?.toSignificant(4)} UNI-V2 - - - - )} - {attempting && hash && ( - - - - Transaction Submitted - - - Deposited {parsedAmount?.toSignificant(4)} UNI-V2 + + Unstake Rewards - - - )} + + + + + + + + You are unstaking your liquidty! You can now remove your position or claim regular liquidity provider + fees. + + + + + + + + TOTAL UNCLAIMED REWARDS + + {incentives.map((incentive, i) => ( + + + + {formatCurrencyAmount(incentive.initialRewardAmount, 5)} + + + {incentive.initialRewardAmount.currency.symbol} + + + ))} + + + + Unstake and Claim + + +
) } diff --git a/src/components/earn/UnstakingModal.tsx b/src/components/earn/UnstakingModal.tsx deleted file mode 100644 index 1e2d8629671..00000000000 --- a/src/components/earn/UnstakingModal.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useState } from 'react' -import Modal from '../Modal' -import { AutoColumn } from '../Column' -import styled from 'styled-components/macro' -import { RowBetween } from '../Row' -import { TYPE, CloseIcon } from '../../theme' -import { ButtonError } from '../Button' -import { StakingInfo } from '../../state/stake/hooks' -import { useStakingContract } from '../../hooks/useContract' -import { SubmittedView, LoadingView } from '../ModalViews' -import { TransactionResponse } from '@ethersproject/providers' -import { useTransactionAdder } from '../../state/transactions/hooks' -import FormattedCurrencyAmount from '../FormattedCurrencyAmount' -import { useActiveWeb3React } from '../../hooks/web3' -import { t, Trans } from '@lingui/macro' - -const ContentWrapper = styled(AutoColumn)` - width: 100%; - padding: 1rem; -` - -interface StakingModalProps { - isOpen: boolean - onDismiss: () => void - stakingInfo: StakingInfo -} - -export default function UnstakingModal({ isOpen, onDismiss, stakingInfo }: StakingModalProps) { - const { account } = useActiveWeb3React() - - // monitor call to help UI loading state - const addTransaction = useTransactionAdder() - const [hash, setHash] = useState() - const [attempting, setAttempting] = useState(false) - - function wrappedOndismiss() { - setHash(undefined) - setAttempting(false) - onDismiss() - } - - const stakingContract = useStakingContract(stakingInfo.stakingRewardAddress) - - async function onWithdraw() { - if (stakingContract && stakingInfo?.stakedAmount) { - setAttempting(true) - await stakingContract - .exit({ gasLimit: 300000 }) - .then((response: TransactionResponse) => { - addTransaction(response, { - summary: t`Withdraw deposited liquidity`, - }) - setHash(response.hash) - }) - .catch((error: any) => { - setAttempting(false) - console.log(error) - }) - } - } - - let error: string | undefined - if (!account) { - error = t`Connect a wallet` - } - if (!stakingInfo?.stakedAmount) { - error = error ?? t`Enter an amount` - } - - return ( - - {!attempting && !hash && ( - - - - Withdraw - - - - {stakingInfo?.stakedAmount && ( - - - {} - - - Deposited liquidity: - - - )} - {stakingInfo?.earnedAmount && ( - - - {} - - - Unclaimed UNI - - - )} - - When you withdraw, your UNI is claimed and your liquidity is removed from the mining pool. - - - {error ?? Withdraw & Claim} - - - )} - {attempting && !hash && ( - - - - Withdrawing {stakingInfo?.stakedAmount?.toSignificant(4)} UNI-V2 - - - Claiming {stakingInfo?.earnedAmount?.toSignificant(4)} UNI - - - - )} - {hash && ( - - - - Transaction Submitted - - - Withdrew UNI-V2! - - - Claimed UNI! - - - - )} - - ) -} diff --git a/src/components/earn/styled.ts b/src/components/earn/styled.ts index 948e7817e5d..87ed3bab079 100644 --- a/src/components/earn/styled.ts +++ b/src/components/earn/styled.ts @@ -1,28 +1,35 @@ import styled from 'styled-components/macro' import { AutoColumn } from '../Column' -import uImage from '../../assets/images/big_unicorn.png' +import uImage from '../../assets/images/earn-bg-image.png' import xlUnicorn from '../../assets/images/xl_uni.png' import noise from '../../assets/images/noise.png' +export const CardWrapper = styled.div` + padding: 16px; + background: radial-gradient(76.02% 75.41% at 1.84% 0%, rgba(30, 26, 49, 0.2) 0%, rgba(61, 81, 165, 0.2) 100%); + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.1); +` + export const DataCard = styled(AutoColumn)<{ disabled?: boolean }>` - background: radial-gradient(76.02% 75.41% at 1.84% 0%, #ff007a 0%, #2172e5 100%); - border-radius: 12px; + border-radius: 20px; width: 100%; position: relative; overflow: hidden; + background-color: ${({ theme }) => theme.blue4}; ` export const CardBGImage = styled.span<{ desaturate?: boolean }>` background: url(${uImage}); - width: 1000px; - height: 600px; + width: 800px; + height: 1200px; position: absolute; border-radius: 12px; - opacity: 0.4; - top: -100px; - left: -100px; - transform: rotate(-15deg); + opacity: 0.7; + top: -300px; + left: 40px; + transform: rotate(0deg); user-select: none; ${({ desaturate }) => desaturate && `filter: saturate(0)`} ` @@ -56,13 +63,23 @@ export const CardNoise = styled.span` ` export const CardSection = styled(AutoColumn)<{ disabled?: boolean }>` - padding: 1rem; + padding: 24px 32px; z-index: 1; opacity: ${({ disabled }) => disabled && '0.4'}; ` export const Break = styled.div` width: 100%; - background-color: rgba(255, 255, 255, 0.2); + background-image: linear-gradient(to left, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.24)); height: 1px; + border-radius: 1px; +` + +export const OverviewGrid = styled.div` + display: grid; + grid-template-columns: auto 160px 180px 80px; + grid-column-gap: 16px; + align-items: center; + justify-items: flex-end; + width: 100%; ` diff --git a/src/components/earn/ClaimRewardModal.tsx b/src/components/earn/v2/ClaimRewardModal.tsx similarity index 86% rename from src/components/earn/ClaimRewardModal.tsx rename to src/components/earn/v2/ClaimRewardModal.tsx index 45085efb46e..27d5d0e5d73 100644 --- a/src/components/earn/ClaimRewardModal.tsx +++ b/src/components/earn/v2/ClaimRewardModal.tsx @@ -1,16 +1,16 @@ import { useState } from 'react' -import Modal from '../Modal' -import { AutoColumn } from '../Column' +import Modal from '../../Modal' +import { AutoColumn } from '../../Column' import styled from 'styled-components/macro' -import { RowBetween } from '../Row' -import { TYPE, CloseIcon } from '../../theme' -import { ButtonError } from '../Button' -import { StakingInfo } from '../../state/stake/hooks' -import { useStakingContract } from '../../hooks/useContract' -import { SubmittedView, LoadingView } from '../ModalViews' +import { RowBetween } from '../../Row' +import { TYPE, CloseIcon } from '../../../theme' +import { ButtonError } from '../../Button' +import { StakingInfo } from '../../../state/stake/hooks' +import { useStakingContract } from '../../../hooks/useContract' +import { SubmittedView, LoadingView } from '../../ModalViews' import { TransactionResponse } from '@ethersproject/providers' -import { useTransactionAdder } from '../../state/transactions/hooks' -import { useActiveWeb3React } from '../../hooks/web3' +import { useTransactionAdder } from '../../../state/transactions/hooks' +import { useActiveWeb3React } from '../../../hooks/web3' import { t, Trans } from '@lingui/macro' const ContentWrapper = styled(AutoColumn)` diff --git a/src/components/earn/PoolCard.tsx b/src/components/earn/v2/PoolCard.tsx similarity index 89% rename from src/components/earn/PoolCard.tsx rename to src/components/earn/v2/PoolCard.tsx index 7cc1e2057d9..7543f5d4269 100644 --- a/src/components/earn/PoolCard.tsx +++ b/src/components/earn/v2/PoolCard.tsx @@ -1,20 +1,20 @@ -import { AutoColumn } from '../Column' -import { RowBetween } from '../Row' +import { AutoColumn } from '../../Column' +import { RowBetween } from '../../Row' import styled from 'styled-components/macro' -import { TYPE, StyledInternalLink } from '../../theme' -import DoubleCurrencyLogo from '../DoubleLogo' +import { TYPE, StyledInternalLink } from '../../../theme' +import DoubleCurrencyLogo from '../../DoubleLogo' import { CurrencyAmount, Token } from '@uniswap/sdk-core' import JSBI from 'jsbi' -import { ButtonPrimary } from '../Button' -import { StakingInfo } from '../../state/stake/hooks' -import { useColor } from '../../hooks/useColor' -import { currencyId } from '../../utils/currencyId' -import { Break, CardNoise, CardBGImage } from './styled' -import { unwrappedToken } from '../../utils/unwrappedToken' -import { useTotalSupply } from '../../hooks/useTotalSupply' -import { useV2Pair } from '../../hooks/useV2Pairs' -import useUSDCPrice from '../../hooks/useUSDCPrice' -import { BIG_INT_SECONDS_IN_WEEK } from '../../constants/misc' +import { ButtonPrimary } from '../../Button' +import { StakingInfo } from '../../../state/stake/hooks' +import { useColor } from '../../../hooks/useColor' +import { currencyId } from '../../../utils/currencyId' +import { Break, CardNoise, CardBGImage } from '../styled' +import { unwrappedToken } from '../../../utils/unwrappedToken' +import { useTotalSupply } from '../../../hooks/useTotalSupply' +import { useV2Pair } from '../../../hooks/useV2Pairs' +import useUSDCPrice from '../../../hooks/useUSDCPrice' +import { BIG_INT_SECONDS_IN_WEEK } from '../../../constants/misc' import { Trans } from '@lingui/macro' const StatContainer = styled.div` diff --git a/src/constants/addresses.ts b/src/constants/addresses.ts index 988f2170e38..19ba8edeadf 100644 --- a/src/constants/addresses.ts +++ b/src/constants/addresses.ts @@ -54,6 +54,10 @@ export const QUOTER_ADDRESSES: AddressMap = constructSameAddressMap('0xb27308f9F SupportedChainId.ARBITRUM_ONE, SupportedChainId.ARBITRUM_RINKEBY, ]) +export const V3_STAKER_ADDRESSES: AddressMap = constructSameAddressMap('0x1f98407aaB862CdDeF78Ed252D6f557aA5b0f00d', [ + SupportedChainId.ARBITRUM_ONE, + SupportedChainId.ARBITRUM_RINKEBY, +]) export const NONFUNGIBLE_POSITION_MANAGER_ADDRESSES: AddressMap = constructSameAddressMap( '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', [ diff --git a/src/hooks/incentives/incentiveKeyToIncentiveId.test.ts b/src/hooks/incentives/incentiveKeyToIncentiveId.test.ts new file mode 100644 index 00000000000..932f261d72e --- /dev/null +++ b/src/hooks/incentives/incentiveKeyToIncentiveId.test.ts @@ -0,0 +1,16 @@ +import { incentiveKeyToIncentiveId } from './incentiveKeyToIncentiveId' +import { constants } from 'ethers' + +describe(incentiveKeyToIncentiveId, () => { + it('correct for example', () => { + expect( + incentiveKeyToIncentiveId({ + pool: constants.AddressZero, + refundee: constants.AddressZero, + rewardToken: constants.AddressZero, + startTime: 0, + endTime: 100, + }) + ).toEqual('0x17b98489a2dfe2c85076f94f6ed94b2c60a48a728457375268be4e78c6a6de87') + }) +}) diff --git a/src/hooks/incentives/incentiveKeyToIncentiveId.ts b/src/hooks/incentives/incentiveKeyToIncentiveId.ts new file mode 100644 index 00000000000..5c6a59bd413 --- /dev/null +++ b/src/hooks/incentives/incentiveKeyToIncentiveId.ts @@ -0,0 +1,23 @@ +import { BigNumber } from 'ethers' +import { defaultAbiCoder, keccak256, Result } from 'ethers/lib/utils' + +export interface IncentiveKey { + rewardToken: string + pool: string + startTime: BigNumber | number + endTime: BigNumber | number + refundee: string +} + +/** + * Encodes the incentive to the ID + * @param incentiveKey the key of the incentive + */ +export function incentiveKeyToIncentiveId(incentiveKey: IncentiveKey | Result): string { + return keccak256( + defaultAbiCoder.encode( + ['address', 'address', 'uint256', 'uint256', 'address'], + [incentiveKey.rewardToken, incentiveKey.pool, incentiveKey.startTime, incentiveKey.endTime, incentiveKey.refundee] + ) + ) +} diff --git a/src/hooks/incentives/useAllIncentives.ts b/src/hooks/incentives/useAllIncentives.ts new file mode 100644 index 00000000000..e5c0932ea01 --- /dev/null +++ b/src/hooks/incentives/useAllIncentives.ts @@ -0,0 +1,196 @@ +import { CurrencyAmount, Token } from '@uniswap/sdk-core' +import { Pool } from '@uniswap/v3-sdk' +import { useMemo } from 'react' +import { useLogs } from '../../state/logs/hooks' +import { useSingleContractMultipleData } from '../../state/multicall/hooks' +import { useAllTokens } from '../Tokens' +import { useV3Staker } from '../useContract' +import { PoolState, usePoolsByAddresses } from '../usePools' +import { incentiveKeyToIncentiveId } from './incentiveKeyToIncentiveId' + +export interface Incentive { + id: string + pool: Pool + poolAddress: string + startTime: number + endTime: number + initialRewardAmount: CurrencyAmount + rewardAmountRemaining: CurrencyAmount + rewardRatePerSecond: CurrencyAmount + refundee: string +} + +export function useAllIncentives(): { + loading: boolean + incentives?: Incentive[] +} { + const staker = useV3Staker() + const filter = useMemo(() => staker?.filters?.IncentiveCreated(), [staker]) + const { logs } = useLogs(filter) + + const parsedLogs = useMemo(() => { + if (!staker) return undefined + const fragment = staker.interface.events['IncentiveCreated(address,address,uint256,uint256,address,uint256)'] + return logs?.map((logs) => staker.interface.decodeEventLog(fragment, logs.data, logs.topics)) + }, [logs, staker]) + + const incentiveIds = useMemo(() => { + return parsedLogs?.map((log) => [incentiveKeyToIncentiveId(log)]) ?? [] + }, [parsedLogs]) + + const incentiveStates = useSingleContractMultipleData(staker, 'incentives', incentiveIds) + + // returns all the token addresses for which there are incentives + // const tokenAddresses = useMemo(() => { + // return Object.keys( + // parsedLogs?.reduce<{ [tokenAddress: string]: true }>((memo, value) => { + // memo[value.rewardToken] = true + // return memo + // }, {}) ?? {} + // ) + // }, [parsedLogs]) + + const poolAddresses = useMemo(() => { + return Object.keys( + parsedLogs?.reduce<{ [poolAddress: string]: true }>((memo, value) => { + if (value.pool) memo[value.pool] = true + return memo + }, {}) ?? {} + ) + }, [parsedLogs]) + + const pools = usePoolsByAddresses(poolAddresses) + + const poolMap = useMemo(() => { + return poolAddresses.reduce<{ [poolAddress: string]: [PoolState, Pool | null] }>((memo, address, ix) => { + memo[address] = pools[ix] + return memo + }, {}) + }, [poolAddresses, pools]) + + // todo: get the tokens not in the active token lists + const allTokens = useAllTokens() + + return useMemo(() => { + if (!parsedLogs || incentiveStates.some((s) => s.loading)) return { loading: true } + + return { + loading: false, + incentives: parsedLogs + .map((result, ix): Incentive | null => { + const token = allTokens[result.rewardToken] + const state = incentiveStates[ix]?.result + // todo: currently we filter out any incentives for tokens not on the active token lists + if (!token || !state) return null + const [, pool] = poolMap[result.pool] + // todo: currently we filter out any incentives for pools not containing tokens on the active lists + if (!pool) return null + + const initialRewardAmount = CurrencyAmount.fromRawAmount(token, result.reward.toString()) + const rewardAmountRemaining = CurrencyAmount.fromRawAmount(token, state.totalRewardUnclaimed.toString()) + + const [startTime, endTime] = [result.startTime.toNumber(), result.endTime.toNumber()] + + const rewardRatePerSecond = initialRewardAmount.divide(endTime - startTime) + + const refundee = result.refundee + + const id = incentiveKeyToIncentiveId({ + rewardToken: result.rewardToken, + pool: result.pool, + startTime, + endTime, + refundee, + }) + + return { + id, + pool, + poolAddress: result.pool, + startTime, + endTime, + initialRewardAmount, + rewardAmountRemaining, + rewardRatePerSecond, + refundee, + } + }) + .filter((x): x is Incentive => x !== null), + } + }, [allTokens, incentiveStates, parsedLogs, poolMap]) +} + +/** + * Used for getting sorted list of all incentives broken down by pool + */ +export function useAllIncentivesByPool(): { + loading: boolean + incentives?: { + [poolAddress: string]: Incentive[] + } +} { + const { loading, incentives } = useAllIncentives() + + return useMemo(() => { + if (loading) { + return { + loading: true, + incentives: undefined, + } + } + if (!incentives) { + return { + loading: false, + incentives: undefined, + } + } + return { + loading: false, + incentives: incentives.reduce( + ( + accum: { + [poolAddress: string]: Incentive[] + }, + incentive + ) => { + accum[incentive.poolAddress] = [...(accum[incentive.poolAddress] ?? []), incentive] + return accum + }, + {} + ), + } + }, [incentives, loading]) +} + +export function useIncentivesForPool(poolAddress?: string): { + loading: boolean + incentives?: Incentive[] +} { + const { loading, incentives } = useAllIncentivesByPool() + + if (!poolAddress) { + return { + loading: false, + incentives: undefined, + } + } + + if (loading) { + return { + loading: true, + incentives: undefined, + } + } + + if (!incentives) { + return { + loading: false, + incentives: undefined, + } + } + + return { + loading: false, + incentives: incentives[poolAddress] ?? [], + } +} diff --git a/src/hooks/incentives/useDepositedTokenIds.ts b/src/hooks/incentives/useDepositedTokenIds.ts new file mode 100644 index 00000000000..7e975338439 --- /dev/null +++ b/src/hooks/incentives/useDepositedTokenIds.ts @@ -0,0 +1,68 @@ +import JSBI from 'jsbi' +import { useMemo } from 'react' +import { LogsState, useLogs } from '../../state/logs/hooks' +import compareLogs from '../../utils/compareLogs' +import { useV3Staker } from '../useContract' + +const VALID_STATES: LogsState[] = [LogsState.SYNCING, LogsState.SYNCED] + +export enum DepositedTokenIdsState { + INVALID, + LOADING, + LOADED, +} + +export interface DepositedTokenIdsResult { + state: DepositedTokenIdsState + tokenIds: JSBI[] | undefined +} + +export function useDepositedTokenIds(account: string | undefined | null): DepositedTokenIdsResult { + const v3Staker = useV3Staker() + const filters = useMemo(() => { + if (!v3Staker || !account) return [] + return [ + v3Staker.filters.DepositTransferred(undefined, account, undefined), + v3Staker.filters.DepositTransferred(undefined, undefined, account), + ] + }, [account, v3Staker]) + + const transferredFromLogs = useLogs(filters[0]) + const transferredToLogs = useLogs(filters[1]) + + const orderedDepositEvents = useMemo(() => { + if (!VALID_STATES.includes(transferredFromLogs.state) || !VALID_STATES.includes(transferredToLogs.state)) + return undefined + + return (transferredFromLogs.logs ?? []).concat(transferredToLogs.logs ?? []).sort(compareLogs) + }, [transferredFromLogs.logs, transferredFromLogs.state, transferredToLogs]) + + return useMemo(() => { + if (!v3Staker || !account) + return { + state: DepositedTokenIdsState.INVALID, + tokenIds: undefined, + } + + if (!orderedDepositEvents) + return { + state: DepositedTokenIdsState.LOADING, + tokenIds: undefined, + } + + const ownedTokenIdMap = orderedDepositEvents.reduce<{ [tokenId: string]: boolean }>((memo, log) => { + const parsed = v3Staker.interface.decodeEventLog('DepositTransferred', log.data, log.topics) + memo[parsed.tokenId.toString()] = parsed.newOwner === account + return memo + }, {}) + + const tokenIds = Object.entries(ownedTokenIdMap) + .filter(([, owned]) => owned) + .map(([tokenId]) => tokenId) + .map(JSBI.BigInt) + return { + state: DepositedTokenIdsState.LOADED, + tokenIds, + } + }, [account, orderedDepositEvents, v3Staker]) +} diff --git a/src/hooks/useContract.ts b/src/hooks/useContract.ts index 18f4406ec22..aa304092da5 100644 --- a/src/hooks/useContract.ts +++ b/src/hooks/useContract.ts @@ -8,6 +8,7 @@ import { abi as QuoterABI } from '@uniswap/v3-periphery/artifacts/contracts/lens import { abi as V2MigratorABI } from '@uniswap/v3-periphery/artifacts/contracts/V3Migrator.sol/V3Migrator.json' import { abi as IUniswapV2Router02ABI } from '@uniswap/v2-periphery/build/IUniswapV2Router02.json' import { abi as MulticallABI } from '@uniswap/v3-periphery/artifacts/contracts/lens/UniswapInterfaceMulticall.sol/UniswapInterfaceMulticall.json' +import { abi as V3StakerABI } from '@uniswap/v3-staker/artifacts/contracts/UniswapV3Staker.sol/UniswapV3Staker.json' import ARGENT_WALLET_DETECTOR_ABI from 'abis/argent-wallet-detector.json' import GOVERNOR_BRAVO_ABI from 'abis/governor-bravo.json' @@ -30,10 +31,11 @@ import { GOVERNANCE_ALPHA_V0_ADDRESSES, GOVERNANCE_ALPHA_V1_ADDRESSES, GOVERNANCE_BRAVO_ADDRESSES, + V3_STAKER_ADDRESSES, } from 'constants/addresses' import { abi as NFTPositionManagerABI } from '@uniswap/v3-periphery/artifacts/contracts/NonfungiblePositionManager.sol/NonfungiblePositionManager.json' import { useMemo } from 'react' -import { Quoter, NonfungiblePositionManager, UniswapInterfaceMulticall } from 'types/v3' +import { Quoter, NonfungiblePositionManager, UniswapInterfaceMulticall, UniswapV3Staker } from 'types/v3' import { V3Migrator } from 'types/v3/V3Migrator' import { getContract } from 'utils' import { Erc20, ArgentWalletDetector, EnsPublicResolver, EnsRegistrar, Weth } from '../abis/types' @@ -146,3 +148,7 @@ export function useV3NFTPositionManagerContract(withSignerIfPossible?: boolean): export function useV3Quoter() { return useContract(QUOTER_ADDRESSES, QuoterABI) } + +export function useV3Staker() { + return useContract(V3_STAKER_ADDRESSES, V3StakerABI) +} diff --git a/src/pages/Earn/Countdown.tsx b/src/hooks/useCountdownTime.ts similarity index 54% rename from src/pages/Earn/Countdown.tsx rename to src/hooks/useCountdownTime.ts index e9f16d16d17..d01c19875dd 100644 --- a/src/pages/Earn/Countdown.tsx +++ b/src/hooks/useCountdownTime.ts @@ -1,19 +1,13 @@ -import { useEffect, useMemo, useState } from 'react' -import { STAKING_GENESIS, REWARDS_DURATION_DAYS } from '../../state/stake/hooks' -import { TYPE } from '../../theme' +import { useEffect, useState } from 'react' const MINUTE = 60 const HOUR = MINUTE * 60 const DAY = HOUR * 24 -const REWARDS_DURATION = DAY * REWARDS_DURATION_DAYS -export function Countdown({ exactEnd }: { exactEnd?: Date }) { +export default function useCountdownTime(startTime: Date, endTime: Date, includeSeconds = false): string { // get end/beginning times - const end = useMemo( - () => (exactEnd ? Math.floor(exactEnd.getTime() / 1000) : STAKING_GENESIS + REWARDS_DURATION), - [exactEnd] - ) - const begin = useMemo(() => end - REWARDS_DURATION, [end]) + const begin = Math.floor(startTime.getTime() / 1000) + const end = Math.floor(endTime.getTime() / 1000) // get current time const [time, setTime] = useState(() => Math.floor(Date.now() / 1000)) @@ -31,17 +25,13 @@ export function Countdown({ exactEnd }: { exactEnd?: Date }) { const timeUntilEnd = end - time let timeRemaining: number - let message: string if (timeUntilGenesis >= 0) { - message = 'Rewards begin in' timeRemaining = timeUntilGenesis } else { const ongoing = timeUntilEnd >= 0 if (ongoing) { - message = 'Rewards end in' timeRemaining = timeUntilEnd } else { - message = 'Rewards have ended!' timeRemaining = Infinity } } @@ -54,16 +44,7 @@ export function Countdown({ exactEnd }: { exactEnd?: Date }) { timeRemaining -= minutes * MINUTE const seconds = timeRemaining - return ( - - {message}{' '} - {Number.isFinite(timeRemaining) && ( - - {`${days}:${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds - .toString() - .padStart(2, '0')}`} - - )} - - ) + return `Starts in ${days}d ${hours.toString().padStart(2, '0')}h ${minutes.toString().padStart(2, '0')}m ${ + includeSeconds ? seconds.toString().padStart(2, '0') : '' + }` } diff --git a/src/hooks/usePools.ts b/src/hooks/usePools.ts index 489f86b8e73..5b82fbcfba3 100644 --- a/src/hooks/usePools.ts +++ b/src/hooks/usePools.ts @@ -1,16 +1,20 @@ import { computePoolAddress } from '@uniswap/v3-sdk' import { V3_CORE_FACTORY_ADDRESSES } from '../constants/addresses' import { IUniswapV3PoolStateInterface } from '../types/v3/IUniswapV3PoolState' +import { IUniswapV3PoolImmutablesInterface } from '../types/v3/IUniswapV3PoolImmutables' import { Token, Currency } from '@uniswap/sdk-core' import { useMemo } from 'react' +import { useAllTokens } from './Tokens' import { useActiveWeb3React } from './web3' -import { useMultipleContractSingleData } from '../state/multicall/hooks' +import { NEVER_RELOAD, useMultipleContractSingleData } from '../state/multicall/hooks' import { Pool, FeeAmount } from '@uniswap/v3-sdk' import { abi as IUniswapV3PoolStateABI } from '@uniswap/v3-core/artifacts/contracts/interfaces/pool/IUniswapV3PoolState.sol/IUniswapV3PoolState.json' +import { abi as IUniswapV3PoolImmutablesABI } from '@uniswap/v3-core/artifacts/contracts/interfaces/pool/IUniswapV3PoolImmutables.sol/IUniswapV3PoolImmutables.json' import { Interface } from '@ethersproject/abi' const POOL_STATE_INTERFACE = new Interface(IUniswapV3PoolStateABI) as IUniswapV3PoolStateInterface +const POOL_IMMUTABLES_INTERFACE = new Interface(IUniswapV3PoolImmutablesABI) as IUniswapV3PoolImmutablesInterface export enum PoolState { LOADING, @@ -78,6 +82,64 @@ export function usePools( }, [liquidities, poolKeys, slot0s, transformed]) } +/** + * Returns all the pools with the given address, as long as both tokens are in the active token list + */ +export function usePoolsByAddresses(poolAddresses: (string | undefined)[]): [PoolState, Pool | null][] { + const slot0s = useMultipleContractSingleData(poolAddresses, POOL_STATE_INTERFACE, 'slot0') + const liquidities = useMultipleContractSingleData(poolAddresses, POOL_STATE_INTERFACE, 'liquidity') + const token0s = useMultipleContractSingleData( + poolAddresses, + POOL_IMMUTABLES_INTERFACE, + 'token0', + undefined, + NEVER_RELOAD + ) + const token1s = useMultipleContractSingleData( + poolAddresses, + POOL_IMMUTABLES_INTERFACE, + 'token1', + undefined, + NEVER_RELOAD + ) + const fees = useMultipleContractSingleData(poolAddresses, POOL_IMMUTABLES_INTERFACE, 'fee', undefined, NEVER_RELOAD) + const allTokens = useAllTokens() + + return useMemo(() => { + return poolAddresses.map((poolAddress, index) => { + if (!poolAddress) return [PoolState.INVALID, null] + const { result: token0Result, loading: token0Loading } = token0s[index] + const { result: token1Result, loading: token1Loading } = token1s[index] + const { result: feeResult, loading: feeLoading } = fees[index] + if (!token0Result || !token1Result || !feeResult) + return [token0Loading || token1Loading || feeLoading ? PoolState.LOADING : PoolState.INVALID, null] + + const { result: slot0, loading: slot0Loading, valid: slot0Valid } = slot0s[index] + const { result: liquidity, loading: liquidityLoading, valid: liquidityValid } = liquidities[index] + + if (!slot0Valid || !liquidityValid) return [PoolState.INVALID, null] + if (slot0Loading || liquidityLoading) return [PoolState.LOADING, null] + + if (!slot0 || !liquidity) return [PoolState.NOT_EXISTS, null] + + if (!slot0.sqrtPriceX96 || slot0.sqrtPriceX96.eq(0)) return [PoolState.NOT_EXISTS, null] + + const token0 = allTokens[token0Result[0]] + const token1 = allTokens[token1Result[0]] + + // todo: return pools for which token0 and token1 are not in the current set of active token lists + if (!token0 || !token1) return [PoolState.INVALID, null] + + try { + return [PoolState.EXISTS, new Pool(token0, token1, feeResult[0], slot0.sqrtPriceX96, liquidity[0], slot0.tick)] + } catch (error) { + console.error('Error when constructing the pool', error) + return [PoolState.NOT_EXISTS, null] + } + }) + }, [allTokens, fees, liquidities, poolAddresses, slot0s, token0s, token1s]) +} + export function usePool( currencyA: Currency | undefined, currencyB: Currency | undefined, diff --git a/src/hooks/useV3Positions.ts b/src/hooks/useV3Positions.ts index 8b0d8bfcbf0..0dd5893948f 100644 --- a/src/hooks/useV3Positions.ts +++ b/src/hooks/useV3Positions.ts @@ -1,50 +1,132 @@ +import { AddressZero } from '@ethersproject/constants' import { useSingleCallResult, useSingleContractMultipleData, Result } from 'state/multicall/hooks' import { useMemo } from 'react' import { PositionDetails } from 'types/position' -import { useV3NFTPositionManagerContract } from './useContract' +import { Incentive, useAllIncentives } from './incentives/useAllIncentives' +import { DepositedTokenIdsState, useDepositedTokenIds } from './incentives/useDepositedTokenIds' +import { useV3NFTPositionManagerContract, useV3Staker } from './useContract' import { BigNumber } from '@ethersproject/bignumber' +import { Pool } from '@uniswap/v3-sdk' +import { Token } from '@uniswap/sdk-core' +import Stake from 'types/stake' interface UseV3PositionsResults { loading: boolean + error: boolean positions: PositionDetails[] | undefined } +function toPoolKey(pool: Pool | { token0: string | Token; token1: string | Token; fee: number }): string { + return `${typeof pool.token0 === 'string' ? pool.token0 : pool.token0.address}-${ + typeof pool.token1 === 'string' ? pool.token1 : pool.token1.address + }-${pool.fee}` +} + function useV3PositionsFromTokenIds(tokenIds: BigNumber[] | undefined): UseV3PositionsResults { const positionManager = useV3NFTPositionManagerContract() - const inputs = useMemo(() => (tokenIds ? tokenIds.map((tokenId) => [BigNumber.from(tokenId)]) : []), [tokenIds]) - const results = useSingleContractMultipleData(positionManager, 'positions', inputs) + const staker = useV3Staker() + const inputs = useMemo(() => (tokenIds ? tokenIds.map((tokenId) => [tokenId]) : []), [tokenIds]) + const positionInfos = useSingleContractMultipleData(positionManager, 'positions', inputs) + const tokenIdOwners = useSingleContractMultipleData(positionManager, 'ownerOf', inputs) + const depositOwners = useSingleContractMultipleData(staker, 'deposits', inputs) + const { incentives, loading: incentivesLoading } = useAllIncentives() + + const [loading, error] = useMemo(() => { + const states = [positionInfos, tokenIdOwners, depositOwners] + return [ + incentivesLoading || states.some((calls) => calls.some(({ loading }) => loading)), + states.some((calls) => calls.some(({ error }) => error)), + ] + }, [depositOwners, incentivesLoading, positionInfos, tokenIdOwners]) + + const incentivesByPoolKey = useMemo(() => { + return ( + incentives?.reduce<{ [poolKey: string]: Incentive[] }>((memo, incentive) => { + const key = toPoolKey(incentive.pool) + memo[key] = memo[key] ?? [] + memo[key].push(incentive) + return memo + }, {}) ?? {} + ) + }, [incentives]) + + const stakesArgs = useMemo(() => { + if (tokenIds && incentives) { + return tokenIds.reduce((accum: (string | BigNumber)[][], tokenId, i) => { + const positionInfo = positionInfos[i].result + if (positionInfo) { + const poolKey = { + token0: positionInfo.token0, + token1: positionInfo.token1, + fee: positionInfo.fee, + } + const incentivesForTokenId = incentivesByPoolKey[toPoolKey(poolKey)] ?? [] + return accum.concat(incentivesForTokenId.map((incentive) => [tokenId, incentive.id])) + } else { + return accum + } + }, []) + } + return [] + }, [incentives, incentivesByPoolKey, positionInfos, tokenIds]) - const loading = useMemo(() => results.some(({ loading }) => loading), [results]) - const error = useMemo(() => results.some(({ error }) => error), [results]) + const stakesResult = useSingleContractMultipleData(staker, 'stakes', stakesArgs) + + const stakesByTokenId = stakesArgs.reduce((accum: { [tokenIdString: string]: Stake[] }, arg, i) => { + const [tokenId, incentiveId] = arg + const liquidity = stakesResult[i].result?.[0] + const secondsPerLiquidityInsideInitialX128 = stakesResult[i].result?.[1] + const incentive = incentives?.find((incentive) => incentive.id === incentiveId) + if (liquidity && incentive && secondsPerLiquidityInsideInitialX128) { + accum[tokenId.toString()] = (accum[tokenId.toString()] ?? []).concat([ + new Stake(incentive, liquidity, secondsPerLiquidityInsideInitialX128), + ]) + } + return accum + }, {}) const positions = useMemo(() => { if (!loading && !error && tokenIds) { - return results.map((call, i) => { - const tokenId = tokenIds[i] - const result = call.result as Result - return { - tokenId, - fee: result.fee, - feeGrowthInside0LastX128: result.feeGrowthInside0LastX128, - feeGrowthInside1LastX128: result.feeGrowthInside1LastX128, - liquidity: result.liquidity, - nonce: result.nonce, - operator: result.operator, - tickLower: result.tickLower, - tickUpper: result.tickUpper, - token0: result.token0, - token1: result.token1, - tokensOwed0: result.tokensOwed0, - tokensOwed1: result.tokensOwed1, - } - }) + return tokenIds + .map((tokenId, i): PositionDetails | null => { + const owner = tokenIdOwners[i].result?.[0] + const positionInfo = positionInfos[i].result + const depositOwner = depositOwners[i].result?.[0] + + if (!owner || !positionInfo) return null + const depositedInStaker = Boolean(depositOwner && depositOwner !== AddressZero) + const poolKey = { + token0: positionInfo.token0, + token1: positionInfo.token1, + fee: positionInfo.fee, + } + return { + ...poolKey, + tokenId, + owner: depositedInStaker ? depositOwner : owner, + depositedInStaker, + feeGrowthInside0LastX128: positionInfo.feeGrowthInside0LastX128, + feeGrowthInside1LastX128: positionInfo.feeGrowthInside1LastX128, + liquidity: positionInfo.liquidity, + nonce: positionInfo.nonce, + operator: positionInfo.operator, + tickLower: positionInfo.tickLower, + tickUpper: positionInfo.tickUpper, + tokensOwed0: positionInfo.tokensOwed0, + tokensOwed1: positionInfo.tokensOwed1, + incentives: incentivesByPoolKey[toPoolKey(poolKey)] ?? [], + stakes: stakesByTokenId[tokenId.toString()] ?? [], + } + }) + .filter((p): p is PositionDetails => Boolean(p)) } return undefined - }, [loading, error, results, tokenIds]) + }, [loading, error, tokenIds, tokenIdOwners, positionInfos, depositOwners, incentivesByPoolKey, stakesByTokenId]) return { loading, - positions: positions?.map((position, i) => ({ ...position, tokenId: inputs[i][0] })), + error, + positions, } } @@ -82,8 +164,13 @@ export function useV3Positions(account: string | null | undefined): UseV3Positio return [] }, [account, accountBalance]) + const { state: depositedTokenIdsState, tokenIds: depositedTokenIds } = useDepositedTokenIds(account) + const tokenIdResults = useSingleContractMultipleData(positionManager, 'tokenOfOwnerByIndex', tokenIdsArgs) - const someTokenIdsLoading = useMemo(() => tokenIdResults.some(({ loading }) => loading), [tokenIdResults]) + const someTokenIdsLoading = useMemo( + () => depositedTokenIdsState === DepositedTokenIdsState.LOADING || tokenIdResults.some(({ loading }) => loading), + [depositedTokenIdsState, tokenIdResults] + ) const tokenIds = useMemo(() => { if (account) { @@ -91,14 +178,58 @@ export function useV3Positions(account: string | null | undefined): UseV3Positio .map(({ result }) => result) .filter((result): result is Result => !!result) .map((result) => BigNumber.from(result[0])) + .concat(depositedTokenIds?.map((id) => BigNumber.from(id.toString())) ?? []) } return [] - }, [account, tokenIdResults]) + }, [account, depositedTokenIds, tokenIdResults]) - const { positions, loading: positionsLoading } = useV3PositionsFromTokenIds(tokenIds) + const { positions, loading: positionsLoading, error: positionsError } = useV3PositionsFromTokenIds(tokenIds) return { loading: someTokenIdsLoading || balanceLoading || positionsLoading, + error: positionsError, positions, } } + +interface PositionsForPoolResults { + loading: boolean + positions: PositionDetails[] | undefined +} + +/** + * Return the positions within certain pool + * Useful for returning positions related to a specific LM program + * @param account + * @param pool + */ +export function useV3PositionsForPool( + account: string | null | undefined, + pool: Pool | undefined | null +): PositionsForPoolResults { + const { positions, loading: positionsLoading } = useV3Positions(account) + + if (positionsLoading) { + return { + loading: true, + positions: undefined, + } + } + + if (!positions || !pool) { + console.log('error found') + return { + loading: false, + positions: undefined, + } + } + + const positionsFiltered = positions.filter((p) => + Boolean(p.token0 === pool.token0.address && p.token1 == pool.token1.address && p.fee === pool.fee) + ) + + return { + loading: false, + positions: positionsFiltered, + } +} diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 5afacbca845..2f763a023d8 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -15,8 +15,9 @@ import AddLiquidity from './AddLiquidity' import { RedirectDuplicateTokenIds } from './AddLiquidity/redirects' import { RedirectDuplicateTokenIdsV2 } from './AddLiquidityV2/redirects' import CreateProposal from './CreateProposal' -import Earn from './Earn' -import Manage from './Earn/Manage' +import Stake from './Stake' +import CreateIncentive from './Stake/CreateIncentive' +import Manage from './Stake/Manage' import MigrateV2 from './MigrateV2' import MigrateV2Pair from './MigrateV2/MigrateV2Pair' import Pool from './Pool' @@ -88,9 +89,10 @@ export default function App() { - - + + + diff --git a/src/pages/Earn/Manage.tsx b/src/pages/Earn/Manage.tsx deleted file mode 100644 index a1a99ea31ff..00000000000 --- a/src/pages/Earn/Manage.tsx +++ /dev/null @@ -1,374 +0,0 @@ -import { useCallback, useState } from 'react' -import { AutoColumn } from '../../components/Column' -import styled from 'styled-components/macro' -import { Link } from 'react-router-dom' -import JSBI from 'jsbi' -import { Token, CurrencyAmount } from '@uniswap/sdk-core' -import { RouteComponentProps } from 'react-router-dom' -import DoubleCurrencyLogo from '../../components/DoubleLogo' -import { useCurrency } from '../../hooks/Tokens' -import { useWalletModalToggle } from '../../state/application/hooks' -import { TYPE } from '../../theme' - -import { RowBetween } from '../../components/Row' -import { CardSection, DataCard, CardNoise, CardBGImage } from '../../components/earn/styled' -import { ButtonPrimary, ButtonEmpty } from '../../components/Button' -import StakingModal from '../../components/earn/StakingModal' -import { useStakingInfo } from '../../state/stake/hooks' -import UnstakingModal from '../../components/earn/UnstakingModal' -import ClaimRewardModal from '../../components/earn/ClaimRewardModal' -import { useTokenBalance } from '../../state/wallet/hooks' -import { useActiveWeb3React } from '../../hooks/web3' -import { useColor } from '../../hooks/useColor' -import { CountUp } from 'use-count-up' - -import { currencyId } from '../../utils/currencyId' -import { useTotalSupply } from '../../hooks/useTotalSupply' -import { useV2Pair } from '../../hooks/useV2Pairs' -import usePrevious from '../../hooks/usePrevious' -import useUSDCPrice from '../../hooks/useUSDCPrice' -import { BIG_INT_ZERO, BIG_INT_SECONDS_IN_WEEK } from '../../constants/misc' -import { Trans } from '@lingui/macro' - -const PageWrapper = styled(AutoColumn)` - max-width: 640px; - width: 100%; -` - -const PositionInfo = styled(AutoColumn)<{ dim: any }>` - position: relative; - max-width: 640px; - width: 100%; - opacity: ${({ dim }) => (dim ? 0.6 : 1)}; -` - -const BottomSection = styled(AutoColumn)` - border-radius: 12px; - width: 100%; - position: relative; -` - -const StyledDataCard = styled(DataCard)<{ bgColor?: any; showBackground?: any }>` - background: radial-gradient(76.02% 75.41% at 1.84% 0%, #1e1a31 0%, #3d51a5 100%); - z-index: 2; - box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.1); - background: ${({ theme, bgColor, showBackground }) => - `radial-gradient(91.85% 100% at 1.84% 0%, ${bgColor} 0%, ${showBackground ? theme.black : theme.bg5} 100%) `}; -` - -const StyledBottomCard = styled(DataCard)<{ dim: any }>` - background: ${({ theme }) => theme.bg3}; - opacity: ${({ dim }) => (dim ? 0.4 : 1)}; - margin-top: -40px; - padding: 0 1.25rem 1rem 1.25rem; - padding-top: 32px; - z-index: 1; -` - -const PoolData = styled(DataCard)` - background: none; - border: 1px solid ${({ theme }) => theme.bg4}; - padding: 1rem; - z-index: 1; -` - -const VoteCard = styled(DataCard)` - background: radial-gradient(76.02% 75.41% at 1.84% 0%, #27ae60 0%, #000000 100%); - overflow: hidden; -` - -const DataRow = styled(RowBetween)` - justify-content: center; - gap: 12px; - - ${({ theme }) => theme.mediaWidth.upToSmall` - flex-direction: column; - gap: 12px; - `}; -` - -export default function Manage({ - match: { - params: { currencyIdA, currencyIdB }, - }, -}: RouteComponentProps<{ currencyIdA: string; currencyIdB: string }>) { - const { account } = useActiveWeb3React() - - // get currencies and pair - const [currencyA, currencyB] = [useCurrency(currencyIdA), useCurrency(currencyIdB)] - const tokenA = (currencyA ?? undefined)?.wrapped - const tokenB = (currencyB ?? undefined)?.wrapped - - const [, stakingTokenPair] = useV2Pair(tokenA, tokenB) - const stakingInfo = useStakingInfo(stakingTokenPair)?.[0] - - // detect existing unstaked LP position to show add button if none found - const userLiquidityUnstaked = useTokenBalance(account ?? undefined, stakingInfo?.stakedAmount?.currency) - const showAddLiquidityButton = Boolean(stakingInfo?.stakedAmount?.equalTo('0') && userLiquidityUnstaked?.equalTo('0')) - - // toggle for staking modal and unstaking modal - const [showStakingModal, setShowStakingModal] = useState(false) - const [showUnstakingModal, setShowUnstakingModal] = useState(false) - const [showClaimRewardModal, setShowClaimRewardModal] = useState(false) - - // fade cards if nothing staked or nothing earned yet - const disableTop = !stakingInfo?.stakedAmount || stakingInfo.stakedAmount.equalTo(JSBI.BigInt(0)) - - const token = currencyA?.isNative ? tokenB : tokenA - const WETH = currencyA?.isNative ? tokenA : tokenB - const backgroundColor = useColor(token) - - // get WETH value of staked LP tokens - const totalSupplyOfStakingToken = useTotalSupply(stakingInfo?.stakedAmount?.currency) - let valueOfTotalStakedAmountInWETH: CurrencyAmount | undefined - if (totalSupplyOfStakingToken && stakingTokenPair && stakingInfo && WETH) { - // take the total amount of LP tokens staked, multiply by ETH value of all LP tokens, divide by all LP tokens - valueOfTotalStakedAmountInWETH = CurrencyAmount.fromRawAmount( - WETH, - JSBI.divide( - JSBI.multiply( - JSBI.multiply(stakingInfo.totalStakedAmount.quotient, stakingTokenPair.reserveOf(WETH).quotient), - JSBI.BigInt(2) // this is b/c the value of LP shares are ~double the value of the WETH they entitle owner to - ), - totalSupplyOfStakingToken.quotient - ) - ) - } - - const countUpAmount = stakingInfo?.earnedAmount?.toFixed(6) ?? '0' - const countUpAmountPrevious = usePrevious(countUpAmount) ?? '0' - - // get the USD value of staked WETH - const USDPrice = useUSDCPrice(WETH) - const valueOfTotalStakedAmountInUSDC = - valueOfTotalStakedAmountInWETH && USDPrice?.quote(valueOfTotalStakedAmountInWETH) - - const toggleWalletModal = useWalletModalToggle() - - const handleDepositClick = useCallback(() => { - if (account) { - setShowStakingModal(true) - } else { - toggleWalletModal() - } - }, [account, toggleWalletModal]) - - return ( - - - - - {currencyA?.symbol}-{currencyB?.symbol} Liquidity Mining - - - - - - - - - - Total deposits - - - {valueOfTotalStakedAmountInUSDC - ? `$${valueOfTotalStakedAmountInUSDC.toFixed(0, { groupSeparator: ',' })}` - : `${valueOfTotalStakedAmountInWETH?.toSignificant(4, { groupSeparator: ',' }) ?? '-'} ETH`} - - - - - - - Pool Rate - - - {stakingInfo?.active ? ( - - {stakingInfo.totalRewardRate?.multiply(BIG_INT_SECONDS_IN_WEEK)?.toFixed(0, { groupSeparator: ',' })}{' '} - UNI / week - - ) : ( - 0 UNI / week - )} - - - - - - {showAddLiquidityButton && ( - - - - - - - - Step 1. Get UNI-V2 Liquidity tokens - - - - - - UNI-V2 LP tokens are required. Once you've added liquidity to the {currencyA?.symbol}- - {currencyB?.symbol} pool you can stake your liquidity tokens on this page. - - - - - - Add {currencyA?.symbol}-{currencyB?.symbol} liquidity - - - - - - - - )} - - {stakingInfo && ( - <> - setShowStakingModal(false)} - stakingInfo={stakingInfo} - userLiquidityUnstaked={userLiquidityUnstaked} - /> - setShowUnstakingModal(false)} - stakingInfo={stakingInfo} - /> - setShowClaimRewardModal(false)} - stakingInfo={stakingInfo} - /> - - )} - - - - - - - - - - - Your liquidity deposits - - - - - {stakingInfo?.stakedAmount?.toSignificant(6) ?? '-'} - - - - UNI-V2 {currencyA?.symbol}-{currencyB?.symbol} - - - - - - - - - - - -
- - Your unclaimed UNI - -
- {stakingInfo?.earnedAmount && JSBI.notEqual(BIG_INT_ZERO, stakingInfo?.earnedAmount?.quotient) && ( - setShowClaimRewardModal(true)} - > - Claim - - )} -
- - - - - - - ⚡ - - - {stakingInfo?.active ? ( - - {stakingInfo.rewardRate?.multiply(BIG_INT_SECONDS_IN_WEEK)?.toFixed(0, { groupSeparator: ',' })}{' '} - UNI / week - - ) : ( - 0 UNI / week - )} - - -
-
-
- - - ⭐️ - - When you withdraw, the contract will automagically claim UNI on your behalf! - - - {!showAddLiquidityButton && ( - - {stakingInfo && stakingInfo.active && ( - - {stakingInfo?.stakedAmount?.greaterThan(JSBI.BigInt(0)) ? ( - Deposit - ) : ( - Deposit UNI-V2 LP Tokens - )} - - )} - - {stakingInfo?.stakedAmount?.greaterThan(JSBI.BigInt(0)) && ( - <> - setShowUnstakingModal(true)} - > - Withdraw - - - )} - - )} - {!userLiquidityUnstaked ? null : userLiquidityUnstaked.equalTo('0') ? null : !stakingInfo?.active ? null : ( - - {userLiquidityUnstaked.toSignificant(6)} UNI-V2 LP tokens available - - )} -
-
- ) -} diff --git a/src/pages/Earn/index.tsx b/src/pages/Earn/index.tsx deleted file mode 100644 index 03af543b265..00000000000 --- a/src/pages/Earn/index.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import JSBI from 'jsbi' -import { AutoColumn } from '../../components/Column' -import styled from 'styled-components/macro' -import { STAKING_REWARDS_INFO, useStakingInfo } from '../../state/stake/hooks' -import { TYPE, ExternalLink } from '../../theme' -import PoolCard from '../../components/earn/PoolCard' -import { RowBetween } from '../../components/Row' -import { CardSection, DataCard, CardNoise, CardBGImage } from '../../components/earn/styled' -import { Countdown } from './Countdown' -import Loader from '../../components/Loader' -import { useActiveWeb3React } from '../../hooks/web3' -import { BIG_INT_ZERO } from '../../constants/misc' -import { OutlineCard } from '../../components/Card' -import { Trans } from '@lingui/macro' - -const PageWrapper = styled(AutoColumn)` - max-width: 640px; - width: 100%; -` - -const TopSection = styled(AutoColumn)` - max-width: 720px; - width: 100%; -` - -const PoolSection = styled.div` - display: grid; - grid-template-columns: 1fr; - column-gap: 10px; - row-gap: 15px; - width: 100%; - justify-self: center; -` - -const DataRow = styled(RowBetween)` - ${({ theme }) => theme.mediaWidth.upToSmall` -flex-direction: column; -`}; -` - -export default function Earn() { - const { chainId } = useActiveWeb3React() - - // staking info for connected account - const stakingInfos = useStakingInfo() - - /** - * only show staking cards with balance - * @todo only account for this if rewards are inactive - */ - const stakingInfosWithBalance = stakingInfos?.filter((s) => JSBI.greaterThan(s.stakedAmount.quotient, BIG_INT_ZERO)) - - // toggle copy if rewards are inactive - const stakingRewardsExist = Boolean(typeof chainId === 'number' && (STAKING_REWARDS_INFO[chainId]?.length ?? 0) > 0) - - return ( - - - - - - - - - - Uniswap liquidity mining - - - - - - Deposit your Liquidity Provider tokens to receive UNI, the Uniswap protocol governance token. - - - {' '} - - - Read more about UNI - - - - - - - - - - - - - Participating pools - - - - - - {stakingRewardsExist && stakingInfos?.length === 0 ? ( - - ) : !stakingRewardsExist ? ( - - No active pools - - ) : stakingInfos?.length !== 0 && stakingInfosWithBalance.length === 0 ? ( - - No active pools - - ) : ( - stakingInfosWithBalance?.map((stakingInfo) => { - // need to sort by added liquidity here - return - }) - )} - - - - ) -} diff --git a/src/pages/Pool/PositionPage.tsx b/src/pages/Pool/PositionPage.tsx index ff5a6b4349d..0ae7dd78c3c 100644 --- a/src/pages/Pool/PositionPage.tsx +++ b/src/pages/Pool/PositionPage.tsx @@ -16,8 +16,8 @@ import { RowBetween, RowFixed } from 'components/Row' import DoubleCurrencyLogo from 'components/DoubleLogo' import { ExternalLink, HideExtraSmall, TYPE } from 'theme' import Badge from 'components/Badge' -import { ButtonConfirmed, ButtonPrimary, ButtonGray } from 'components/Button' -import { DarkCard, LightCard } from 'components/Card' +import { ButtonConfirmed, ButtonPrimary, ButtonGray, ButtonSmall } from 'components/Button' +import { DarkCard, DarkGreyCard, LightCard } from 'components/Card' import CurrencyLogo from 'components/CurrencyLogo' import { Trans } from '@lingui/macro' import { currencyId } from 'utils/currencyId' @@ -35,7 +35,6 @@ import { Dots } from 'components/swap/styleds' import { getPriceOrderingFromPositionForUI } from '../../components/PositionListItem' import useTheme from '../../hooks/useTheme' import RateToggle from '../../components/RateToggle' -import { useSingleCallResult } from 'state/multicall/hooks' import RangeBadge from '../../components/Badge/RangeBadge' import { SwitchLocaleLink } from '../../components/SwitchLocaleLink' import useUSDCPrice from 'hooks/useUSDCPrice' @@ -45,6 +44,9 @@ import { Bound } from 'state/mint/v3/actions' import useIsTickAtLimit from 'hooks/useIsTickAtLimit' import { formatTickPrice } from 'utils/formatTickPrice' import { SupportedChainId } from 'constants/chains' +import { useIncentivesForPool } from 'hooks/incentives/useAllIncentives' +import { AlertCircle } from 'react-feather' +import PositionManageCard from 'components/earn/PositionManageCard' const PageWrapper = styled.div` min-width: 800px; @@ -126,6 +128,11 @@ const ResponsiveButtonPrimary = styled(ButtonPrimary)` `}; ` +const DynamicSpan = styled.span<{ disabled?: boolean }>` + opacity: ${({ disabled }) => (disabled ? 0.3 : 1)}; + pointer-events: ${({ disabled }) => (disabled ? 'none' : 'inherit')}; ; +` + const NFTGrid = styled.div` display: grid; grid-template: 'overlap'; @@ -331,6 +338,8 @@ export function PositionPage({ tickLower, tickUpper, tokenId, + owner, + depositedInStaker, } = positionDetails || {} const removed = liquidity?.eq(0) @@ -348,6 +357,7 @@ export function PositionPage({ // construct Position from details returned const [poolState, pool] = usePool(token0 ?? undefined, token1 ?? undefined, feeAmount) + const position = useMemo(() => { if (pool && liquidity && typeof tickLower === 'number' && typeof tickUpper === 'number') { return new Position({ pool, liquidity: liquidity.toString(), tickLower, tickUpper }) @@ -395,6 +405,10 @@ export function PositionPage({ const price0 = useUSDCPrice(token0 ?? undefined) const price1 = useUSDCPrice(token1 ?? undefined) + // incentives for this pool + const poolAddress = pool ? Pool.getAddress(pool.token0, pool.token1, pool.fee) : undefined + const { incentives } = useIncentivesForPool(poolAddress) + const fiatValueOfFees: CurrencyAmount | null = useMemo(() => { if (!price0 || !price1 || !feeValue0 || !feeValue1) return null @@ -469,7 +483,6 @@ export function PositionPage({ }) }, [chainId, feeValue0, feeValue1, positionManager, account, tokenId, addTransaction, library]) - const owner = useSingleCallResult(!!tokenId ? positionManager : null, 'ownerOf', [tokenId]).result?.[0] const ownsNFT = owner === account || positionDetails?.operator === account const feeValueUpper = inverted ? feeValue0 : feeValue1 @@ -589,119 +602,110 @@ export function PositionPage({ ) : null} {tokenId && !removed ? ( - - Remove Liquidity - + + + Remove Liquidity + + ) : null} )} - - - {'result' in metadata ? ( - -
- -
- {typeof chainId === 'number' && owner && !ownsNFT ? ( - - Owner - - ) : null} -
- ) : ( - - - - )} - - - - - - {fiatValueOfLiquidity?.greaterThan(new Fraction(1, 100)) ? ( - - ${fiatValueOfLiquidity.toFixed(2, { groupSeparator: ',' })} - - ) : ( - - $- - - )} - - - - - - - - {inverted ? position?.amount0.toSignificant(4) : position?.amount1.toSignificant(4)} - - {typeof ratio === 'number' && !removed ? ( - - - {inverted ? ratio : 100 - ratio}% - - - ) : null} - - - - - - - {inverted ? position?.amount1.toSignificant(4) : position?.amount0.toSignificant(4)} - - {typeof ratio === 'number' && !removed ? ( - - - {inverted ? 100 - ratio : ratio}% - - - ) : null} - - - - - + {incentives && !depositedInStaker ? ( + + + + {incentives.map((incentive, i) => ( + + ))} + + Stake this position to earn UNI with liquidity mining + + + + + Stake + + + + + ) : null} + {incentives && depositedInStaker && positionDetails ? ( + + + - - - - + + + + + + While staked your liquidity is locked and cannot be removed. You will still earn fees while in + range in addition to staking rewards. To remove your liquidity, first unstake your liquidity by + clicking manage. + + + + + + ) : null} + + + + {'result' in metadata ? ( + +
+ +
+ {typeof chainId === 'number' && owner && !ownsNFT ? ( + + Owner + + ) : null} +
+ ) : ( + + + + )} + + + - {fiatValueOfFees?.greaterThan(new Fraction(1, 100)) ? ( - - ${fiatValueOfFees.toFixed(2, { groupSeparator: ',' })} + {fiatValueOfLiquidity?.greaterThan(new Fraction(1, 100)) ? ( + + ${fiatValueOfLiquidity.toFixed(2, { groupSeparator: ',' })} ) : ( @@ -709,166 +713,221 @@ export function PositionPage({ )} - {ownsNFT && (feeValue0?.greaterThan(0) || feeValue1?.greaterThan(0) || !!collectMigrationHash) ? ( - setShowConfirm(true)} - > - {!!collectMigrationHash && !isCollectPending ? ( - - Collected - - ) : isCollectPending || collecting ? ( - - {' '} - - Collecting - - - ) : ( - <> - - Collect fees + + + + + + + {inverted ? position?.amount0.toSignificant(4) : position?.amount1.toSignificant(4)} + + {typeof ratio === 'number' && !removed ? ( + + + {inverted ? ratio : 100 - ratio}% + + + ) : null} + + + + + + + {inverted ? position?.amount1.toSignificant(4) : position?.amount0.toSignificant(4)} - - )} - - ) : null} - - - - - - - - {feeValueUpper?.currency?.symbol} - - - {feeValueUpper ? formatCurrencyAmount(feeValueUpper, 4) : '-'} - - - - - - {feeValueLower?.currency?.symbol} - - - {feeValueLower ? formatCurrencyAmount(feeValueLower, 4) : '-'} - - + {typeof ratio === 'number' && !removed ? ( + + + {inverted ? 100 - ratio : ratio}% + + + ) : null} + + + + - - {showCollectAsWeth && ( - - - - Collect as WETH - - setReceiveWETH((receiveWETH) => !receiveWETH)} - /> - +
+ + + + + + + {fiatValueOfFees?.greaterThan(new Fraction(1, 100)) ? ( + + ${fiatValueOfFees.toFixed(2, { groupSeparator: ',' })} + + ) : ( + + $- + + )} + + {ownsNFT && + (feeValue0?.greaterThan(0) || feeValue1?.greaterThan(0) || !!collectMigrationHash) ? ( + setShowConfirm(true)} + > + {!!collectMigrationHash && !isCollectPending ? ( + + Collected + + ) : isCollectPending || collecting ? ( + + {' '} + + Collecting + + + ) : ( + <> + + Collect fees + + + )} + + ) : null} + + + + + + + + {feeValueUpper?.currency?.symbol} + + + {feeValueUpper ? formatCurrencyAmount(feeValueUpper, 4) : '-'} + + + + + + {feeValueLower?.currency?.symbol} + + + {feeValueLower ? formatCurrencyAmount(feeValueLower, 4) : '-'} + + + + + {showCollectAsWeth && ( + + + + Collect as WETH + + setReceiveWETH((receiveWETH) => !receiveWETH)} + /> + + + )} - )} + +
+
+ + + + + + + <> + + + + + + + {currencyBase && currencyQuote && ( + setManuallyInverted(!manuallyInverted)} + /> + )} + + + + + + + Min price + + + {formatTickPrice(priceLower, tickAtLimit, Bound.LOWER)} + + + {' '} + + {currencyQuote?.symbol} per {currencyBase?.symbol} + + + + {inRange && ( + + Your position will be 100% {currencyBase?.symbol} at this price. + + )} + + + + + + + Max price + + + {formatTickPrice(priceUpper, tickAtLimit, Bound.UPPER)} + + + {' '} + + {currencyQuote?.symbol} per {currencyBase?.symbol} + + + + {inRange && ( + + Your position will be 100% {currencyQuote?.symbol} at this price. + + )} + + + + - - - - - - - - <> - - - - - - - {currencyBase && currencyQuote && ( - setManuallyInverted(!manuallyInverted)} - /> - )} - - - - - - - - Min price - - - {formatTickPrice(priceLower, tickAtLimit, Bound.LOWER)} - - - {' '} - - {currencyQuote?.symbol} per {currencyBase?.symbol} - - - - {inRange && ( - - Your position will be 100% {currencyBase?.symbol} at this price. - - )} - - - - - - - - Max price - - - {formatTickPrice(priceUpper, tickAtLimit, Bound.UPPER)} - - - {' '} - - {currencyQuote?.symbol} per {currencyBase?.symbol} - - - - {inRange && ( - - Your position will be 100% {currencyQuote?.symbol} at this price. - - )} - - - - - - + diff --git a/src/pages/Stake/CreateIncentive.tsx b/src/pages/Stake/CreateIncentive.tsx new file mode 100644 index 00000000000..bc2cd2dfd2b --- /dev/null +++ b/src/pages/Stake/CreateIncentive.tsx @@ -0,0 +1,174 @@ +import { AutoColumn } from 'components/Column' +import CurrencyInputPanel from 'components/CurrencyInputPanel' +import { useV3Staker } from 'hooks/useContract' +import { ChangeEventHandler, useCallback, useMemo, useState } from 'react' +import { Currency } from '@uniswap/sdk-core' +import { BlueCard } from 'components/Card' +import { tryParseAmount } from 'state/swap/hooks' +import { useActiveWeb3React } from 'hooks/web3' +import { ApprovalState, useApproveCallback } from 'hooks/useApproveCallback' +import { ButtonPrimary } from 'components/Button' +import { computePoolAddress, FeeAmount, toHex } from '@uniswap/v3-sdk' +import FeeSelector from 'components/FeeSelector' +import { V3_CORE_FACTORY_ADDRESSES } from 'constants/addresses' +import { TransactionResponse } from '@ethersproject/providers' +import { useTransactionAdder } from 'state/transactions/hooks' + +function dateTimeToUnixSeconds(dateTimeString: string): number { + return Math.floor(new Date(dateTimeString).getTime() / 1000) +} + +export default function CreateIncentive() { + const { account, chainId } = useActiveWeb3React() + + const staker = useV3Staker() + + const [currencyA, setCurrencyA] = useState(undefined) + const [currencyB, setCurrencyB] = useState(undefined) + + const [refundee, setRefundee] = useState(account ?? '') + + const [currencyC, setCurrencyC] = useState(undefined) + const [rewardTyped, setRewardTyped] = useState('') + const rewardAmount = tryParseAmount(rewardTyped, currencyC) + + const [approval, approveCallback] = useApproveCallback(rewardAmount, staker?.address) + + const [feeAmount, setFeeAmount] = useState() + + const v3CoreFactoryAddress = chainId && V3_CORE_FACTORY_ADDRESSES[chainId] + + const addTransaction = useTransactionAdder() + + const poolAddress = useMemo(() => { + if (currencyA && currencyB && feeAmount && v3CoreFactoryAddress) { + return computePoolAddress({ + factoryAddress: v3CoreFactoryAddress, + tokenA: currencyA?.wrapped, + tokenB: currencyB.wrapped, + fee: feeAmount, + }) + } + return undefined + }, [currencyA, currencyB, feeAmount, v3CoreFactoryAddress]) + + const [startTime, setStartTime] = useState('') + const [endTime, setEndTime] = useState('') + + const handleCreate = async () => { + if ( + staker && + currencyA && + currencyB && + rewardAmount && + currencyC && + startTime && + endTime && + refundee && + poolAddress + ) { + staker + .createIncentive( + { + rewardToken: currencyC.wrapped.address, + pool: poolAddress, + startTime: dateTimeToUnixSeconds(startTime), + endTime: dateTimeToUnixSeconds(endTime), + refundee, + }, + toHex(rewardAmount.quotient) + ) + .then((response: TransactionResponse) => { + addTransaction(response, { + summary: 'Create incentive', + }) + }) + .catch((error: any) => { + console.log(error) + }) + } + } + + const handleChangeStartTime: ChangeEventHandler = useCallback((e) => { + setStartTime(e.target.value) + }, []) + const handleChangeEndTime: ChangeEventHandler = useCallback((e) => { + setEndTime(e.target.value) + }, []) + + return ( + +
CreateIncentive
+ +
TokenA in incentivized pool
+ null} + hideInput={true} + showMaxButton={false} + onCurrencySelect={(currency) => { + setCurrencyA(currency) + }} + id="token-select-a" + /> +
+ +
TokenB in incentivized pool
+ null} + hideInput={true} + showMaxButton={false} + onCurrencySelect={(currency) => { + setCurrencyB(currency) + }} + id="token-select-b" + /> +
+ setFeeAmount(val)} + currencyA={currencyA} + currencyB={currencyB} + /> + + +
Reward token
+ { + setRewardTyped(val) + }} + showMaxButton={false} + onCurrencySelect={(currency) => { + setCurrencyC(currency) + }} + id="token-select-b" + /> +
+ + +
Start time
+ +
+
+ + +
End time
+ +
+
+ +
Refundee Address
+ setRefundee(e.target.value)} /> +
+ {approval === ApprovalState.APPROVED ? null : Approve} + + Create + +
+ ) +} diff --git a/src/pages/Stake/Manage.tsx b/src/pages/Stake/Manage.tsx new file mode 100644 index 00000000000..b2f393b86bd --- /dev/null +++ b/src/pages/Stake/Manage.tsx @@ -0,0 +1,135 @@ +import { Trans } from '@lingui/macro' +import Badge from 'components/Badge' +import { ButtonGreySmall } from 'components/Button' +import { DarkGreyCard } from 'components/Card' +import { AutoColumn } from 'components/Column' +import DoubleCurrencyLogo from 'components/DoubleLogo' +import IncentiveInfoBar from 'components/earn/IncentiveInfoBar' +import PositionManageCard from 'components/earn/PositionManageCard' +import Loader from 'components/Loader' +import { RowBetween, RowFixed } from 'components/Row' +import { useIncentivesForPool } from 'hooks/incentives/useAllIncentives' +import { usePoolsByAddresses } from 'hooks/usePools' +import useTheme from 'hooks/useTheme' +import { useV3PositionsForPool } from 'hooks/useV3Positions' +import { useActiveWeb3React } from 'hooks/web3' +import { LoadingRows } from 'pages/Pool/styleds' +import { AlertCircle } from 'react-feather' +import { Link, RouteComponentProps } from 'react-router-dom' +import styled from 'styled-components/macro' +import { HoverText, TYPE } from 'theme' +import { formattedFeeAmount } from 'utils' +import { currencyId } from 'utils/currencyId' +import { unwrappedToken } from 'utils/unwrappedToken' + +const Wrapper = styled.div` + max-width: 840px; + width: 100%; +` + +export default function Manage({ + match: { + params: { poolAddress }, + }, +}: RouteComponentProps<{ poolAddress: string }>) { + const theme = useTheme() + const { account, chainId } = useActiveWeb3React() + + const [, pool] = usePoolsByAddresses([poolAddress])[0] + + const currency0 = pool ? unwrappedToken(pool.token0) : undefined + const currency1 = pool ? unwrappedToken(pool.token1) : undefined + + // all incentive programs for this pool + const { loading, incentives } = useIncentivesForPool(poolAddress) + + // all users positions for this pool + const { loading: loadingPositions, positions } = useV3PositionsForPool(account, pool) + + if (!pool || !currency0 || !currency1 || loading) { + return ( + + +
+
+
+ + + ) + } + + return ( + + + + + + + Stake + + + + + {` > ${currency0.symbol} / ${currency1.symbol} ${formattedFeeAmount(pool.fee)}%`} + + + + + + + {`${currency0.symbol} / ${currency1.symbol} Pool`} + + {formattedFeeAmount(pool.fee)}% + + + {chainId === 1 ? ( + + View Analytics ↗ + + ) : null} + + Add Liquidity + + + + {!incentives ? ( + No incentives on this pool yet + ) : ( + incentives.slice(0, 1).map((incentive) => ( + + + + )) + )} + + + Your Positions + + {loadingPositions ? ( + + ) : !positions ? ( + No positions on this pool + ) : ( + positions.map((p, i) => ) + )} + + + + + + + Boosting liquidity deposits your liquidity in the Uniswap Liquidity mining contracts. When boosted, your + liquidity will continue to earn fees while in range. You must remove boosts to be able to claim fees or + withdraw liquidity. + + + + + + + ) +} diff --git a/src/pages/Stake/index.tsx b/src/pages/Stake/index.tsx new file mode 100644 index 00000000000..cbbb37e8c0e --- /dev/null +++ b/src/pages/Stake/index.tsx @@ -0,0 +1,107 @@ +import { AutoColumn } from '../../components/Column' +import styled from 'styled-components/macro' +import { TYPE } from '../../theme' +import { AutoRow, RowBetween, RowFixed } from '../../components/Row' +import { CardSection, DataCard, CardBGImage, OverviewGrid } from '../../components/earn/styled' +import { DarkCard } from '../../components/Card' +import { Trans } from '@lingui/macro' +import useTheme from 'hooks/useTheme' +import { GenericBadge } from 'components/Badge' +import { Zap } from 'react-feather' +import { useAllIncentivesByPool } from '../../hooks/incentives/useAllIncentives' +import ProgramCard from '../../components/earn/ProgramCard' +import Loader from 'components/Loader' +import { ButtonGreySmall } from 'components/Button' + +const PageWrapper = styled(AutoColumn)` + max-width: 840px; + width: 100%; +` + +const TopSection = styled(AutoColumn)` + width: 100%; +` + +const ProgramSection = styled.div` + display: grid; + grid-template-columns: 1fr; + column-gap: 10px; + row-gap: 12px; + width: 100%; + justify-self: center; +` + +export default function Stake() { + const theme = useTheme() + + const { loading, incentives } = useAllIncentivesByPool() + + return ( + + + + + Boosted Pools + + + Find Program + New Program + + + + + + + + + + Liquidity Mining + + + + + Earn more with boosts + + + Learn about boosted rewards on your liquidity positions ➞ + + + + + + + + + + + Active Programs + + + 7D Active Liquidity + + + Rewards Rate + + + + {loading ? ( + + ) : !incentives ? ( + + Error loading program{' '} + + ) : ( + Object.keys(incentives).map((poolAddress) => ( + + )) + )} + + + + + ) +} diff --git a/src/theme/components.tsx b/src/theme/components.tsx index c6972f0bcc0..d1eb143bb5d 100644 --- a/src/theme/components.tsx +++ b/src/theme/components.tsx @@ -23,6 +23,17 @@ export const ButtonText = styled.button` } ` +export const HoverText = styled.div<{ color?: string }>` + text-decoration: none; + color: ${({ theme, color }) => color ?? theme.text1}; + display: flex; + align-items: center; + :hover { + cursor: pointer; + opacity: 0.8; + } +` + export const CloseIcon = styled(X)<{ onClick: () => void }>` cursor: pointer; ` diff --git a/src/theme/index.tsx b/src/theme/index.tsx index 282268a22bd..865534447f7 100644 --- a/src/theme/index.tsx +++ b/src/theme/index.tsx @@ -81,17 +81,20 @@ function colors(darkMode: boolean): Colors { red2: darkMode ? '#F82D3A' : '#DF1F38', red3: '#D60000', green1: darkMode ? '#27AE60' : '#007D35', + green2: '#00FFA3', yellow1: '#E3A507', yellow2: '#FF8F00', yellow3: '#F3B71E', blue1: darkMode ? '#2172E5' : '#0068FC', blue2: darkMode ? '#5199FF' : '#0068FC', + blue3: '#00C2FF', + blue4: darkMode ? '#153d6f70' : '#C4D9F8', + error: darkMode ? '#FD4040' : '#DF1F38', success: darkMode ? '#27AE60' : '#007D35', warning: '#FF8F00', // dont wanna forget these blue yet - blue4: darkMode ? '#153d6f70' : '#C4D9F8', // blue5: darkMode ? '#153d6f70' : '#EBF4FF', } } diff --git a/src/theme/styled.d.ts b/src/theme/styled.d.ts index 90194e7ace8..609d3e8502f 100644 --- a/src/theme/styled.d.ts +++ b/src/theme/styled.d.ts @@ -44,12 +44,13 @@ export interface Colors { red2: Color red3: Color green1: Color + green2: Color yellow1: Color yellow2: Color yellow3: Color blue1: Color blue2: Color - + blue3: Color blue4: Color error: Color diff --git a/src/types/position.d.ts b/src/types/position.d.ts index bce78b196ce..6bb16397616 100644 --- a/src/types/position.d.ts +++ b/src/types/position.d.ts @@ -1,9 +1,13 @@ import { BigNumber } from '@ethersproject/bignumber' +import { Incentive } from '../hooks/incentives/useAllIncentives' +import Stake from './stake' export interface PositionDetails { nonce: BigNumber tokenId: BigNumber operator: string + owner: string + depositedInStaker: boolean token0: string token1: string fee: number @@ -14,4 +18,6 @@ export interface PositionDetails { feeGrowthInside1LastX128: BigNumber tokensOwed0: BigNumber tokensOwed1: BigNumber + incentives: Incentive[] + stakes: Stake[] } diff --git a/src/types/stake.ts b/src/types/stake.ts new file mode 100644 index 00000000000..0fb5b2a7137 --- /dev/null +++ b/src/types/stake.ts @@ -0,0 +1,14 @@ +import { BigNumber } from 'ethers' +import { Incentive } from 'hooks/incentives/useAllIncentives' + +export default class Stake { + public readonly incentive: Incentive + public readonly liquidity: BigNumber + public readonly secondsPerLiquidityInsideInitialX128: BigNumber + + constructor(incentive: Incentive, liquidity: BigNumber, secondsPerLiquidityInsideInitialX128: BigNumber) { + this.incentive = incentive + this.liquidity = liquidity + this.secondsPerLiquidityInsideInitialX128 = secondsPerLiquidityInsideInitialX128 + } +} diff --git a/src/utils/compareLogs.test.ts b/src/utils/compareLogs.test.ts new file mode 100644 index 00000000000..7094daf4bb8 --- /dev/null +++ b/src/utils/compareLogs.test.ts @@ -0,0 +1,51 @@ +import compareLogs from './compareLogs' + +describe('#compareLogs', () => { + it('first compares my block number', () => { + expect( + compareLogs( + { + blockNumber: 1, + transactionIndex: 1, + logIndex: 1, + }, + { blockNumber: 2, transactionIndex: 0, logIndex: 0 } + ) + ).toEqual(-1) + }) + it('second compares by transaction index number', () => { + expect( + compareLogs( + { + blockNumber: 2, + transactionIndex: 2, + logIndex: 1, + }, + { blockNumber: 2, transactionIndex: 4, logIndex: 0 } + ) + ).toEqual(-2) + }) + it('third compares by log index', () => { + expect( + compareLogs( + { + blockNumber: 2, + transactionIndex: 2, + logIndex: 5, + }, + { blockNumber: 2, transactionIndex: 2, logIndex: 8 } + ) + ).toEqual(-3) + }) + + it('can be used to sort logs', () => { + const logA = { + blockNumber: 2, + transactionIndex: 2, + logIndex: 5, + } + const logB = { blockNumber: 2, transactionIndex: 2, logIndex: 8 } + expect([logA, logB].sort(compareLogs)).toEqual([logA, logB]) + expect([logB, logA].sort(compareLogs)).toEqual([logA, logB]) + }) +}) diff --git a/src/utils/compareLogs.ts b/src/utils/compareLogs.ts new file mode 100644 index 00000000000..177f86b478f --- /dev/null +++ b/src/utils/compareLogs.ts @@ -0,0 +1,16 @@ +import { Log } from '../state/logs/utils' + +type PartialLog = Pick + +/** + * Sorts logs in chronological order from earliest to latest + * @param logA one of two logs to compare + * @param logB the other of the two logs to compare + */ +export default function compareLogs(logA: PartialLog, logB: PartialLog) { + return ( + logA.blockNumber - logB.blockNumber || + logA.transactionIndex - logB.transactionIndex || + logA.logIndex - logB.logIndex + ) +} diff --git a/yarn.lock b/yarn.lock index 7d50ab5abf5..854b8396b28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4439,7 +4439,7 @@ "@uniswap/v3-core" "1.0.0" base64-sol "1.0.1" -"@uniswap/v3-periphery@^1.1.1": +"@uniswap/v3-periphery@^1.0.1", "@uniswap/v3-periphery@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@uniswap/v3-periphery/-/v3-periphery-1.1.1.tgz#be6dfca7b29318ea0d76a7baf15d3b33c3c5e90a" integrity sha512-orqD2Xy4lxVPF6pxd7ECSJY0gzEuqyeVSDHjzM86uWxOXlA4Nlh5pvI959KaS32pSOFBOVVA4XbbZywbJj+CZg== @@ -4463,6 +4463,15 @@ tiny-invariant "^1.1.0" tiny-warning "^1.0.3" +"@uniswap/v3-staker@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@uniswap/v3-staker/-/v3-staker-1.0.0.tgz#9a6915ec980852479dfc903f50baf822ff8fa66e" + integrity sha512-JV0Qc46Px5alvg6YWd+UIaGH9lDuYG/Js7ngxPit1SPaIP30AlVer1UYB7BRYeUVVxE+byUyIeN5jeQ7LLDjIw== + dependencies: + "@openzeppelin/contracts" "3.4.1-solc-0.7-2" + "@uniswap/v3-core" "1.0.0" + "@uniswap/v3-periphery" "^1.0.1" + "@walletconnect/browser-utils@^1.6.4": version "1.6.4" resolved "https://registry.yarnpkg.com/@walletconnect/browser-utils/-/browser-utils-1.6.4.tgz#9a489aa680370c7071e603b02c5c94298d1c5d41"