Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66a3475bf6 | ||
|
|
f6c393b016 | ||
|
|
15f8d34320 | ||
|
|
504e09d3dc | ||
|
|
1f755e8b0d | ||
|
|
f45a7f921b | ||
|
|
29db61ff90 | ||
|
|
8431ad9161 | ||
|
|
fd1aded517 | ||
|
|
27ad7cbd41 | ||
|
|
01e5de436a | ||
|
|
fd5aa1b51e | ||
|
|
a6e1a7e6d9 | ||
|
|
629fe2c144 | ||
|
|
d73763ce75 | ||
|
|
fe6df38997 | ||
|
|
719ee0f5b5 | ||
|
|
75bdf9a8d4 | ||
|
|
efbe3994bb | ||
|
|
93fe8e4349 | ||
|
|
6062f615a0 | ||
|
|
42e3af7b5c | ||
|
|
57274a800d | ||
|
|
5e591455b3 | ||
|
|
ec547ab100 | ||
|
|
9de76c69ae | ||
|
|
85d1b90197 | ||
|
|
38af86e1bb | ||
|
|
11a8df2a3e | ||
|
|
3726b6bb47 | ||
|
|
bfde34c774 | ||
|
|
bd8113d018 | ||
|
|
14e3ef044e | ||
|
|
4fc4bdcd55 | ||
|
|
3733570a89 | ||
|
|
7a042a5199 | ||
|
|
6d5e17a6e7 | ||
|
|
8301c5892c | ||
|
|
59b757dda0 | ||
|
|
92a6ec67b3 | ||
|
|
1d6a1e90d7 | ||
|
|
01aa3291b3 | ||
|
|
5539ebedf7 | ||
|
|
e6adddbf55 | ||
|
|
0050b1e165 | ||
|
|
5bf33ab004 | ||
|
|
a4cfeecd8c | ||
|
|
76cbfdd0b9 | ||
|
|
0db9e51e41 | ||
|
|
82e7925a17 | ||
|
|
2150347ba2 | ||
|
|
2f80646ddd | ||
|
|
55eea6a724 | ||
|
|
709a70652f | ||
|
|
5a7a041f12 | ||
|
|
b60d98fc17 | ||
|
|
38d9ab67eb | ||
|
|
5e6ef1575b | ||
|
|
4a015e9d0d | ||
|
|
c383a0a0a2 | ||
|
|
d6e92804ad | ||
|
|
f0d8f8b23b | ||
|
|
ff080aa957 | ||
|
|
04d9ff7d71 | ||
|
|
406893d99a | ||
|
|
4630720956 | ||
|
|
e73e1540ad | ||
|
|
3a82642fe6 | ||
|
|
2a50d6a17e | ||
|
|
c8a8149127 | ||
|
|
38cde648cf | ||
|
|
33c099119d | ||
|
|
40247ff7e0 | ||
|
|
30d1de8e84 | ||
|
|
0e5328bee9 | ||
|
|
d1995bc5a6 | ||
|
|
4959836c2a | ||
|
|
ef4a80852d | ||
|
|
c2a972eb75 | ||
|
|
47a2768d89 | ||
|
|
ca60caf6b0 | ||
|
|
252acef199 | ||
|
|
00ecb933ac | ||
|
|
607d0d443e | ||
|
|
ff0209a78f | ||
|
|
924e83139b | ||
|
|
4d5cc8267e | ||
|
|
6bc7cfc996 | ||
|
|
97312bb174 | ||
|
|
d0a10fcf8d | ||
|
|
7a1a476e45 | ||
|
|
b3bfc1003a | ||
|
|
3b1ef8033b | ||
|
|
803485b96a | ||
|
|
6df5d3a701 | ||
|
|
2d4eafc6b3 | ||
|
|
9b52fea58a | ||
|
|
b92b286626 | ||
|
|
a8268728d3 | ||
|
|
ab6debbf46 | ||
|
|
4c664645c6 | ||
|
|
4416a84fd7 |
13
.eslintrc.js
13
.eslintrc.js
@@ -2,9 +2,20 @@
|
||||
|
||||
require('@uniswap/eslint-config/load')
|
||||
|
||||
const rulesDirPlugin = require('eslint-plugin-rulesdir')
|
||||
rulesDirPlugin.RULES_DIR = 'eslint_rules'
|
||||
|
||||
module.exports = {
|
||||
extends: '@uniswap/eslint-config/react',
|
||||
extends: ['@uniswap/eslint-config/react'],
|
||||
plugins: ['rulesdir'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*'],
|
||||
rules: {
|
||||
'multiline-comment-style': ['error', 'separate-lines'],
|
||||
'rulesdir/enforce-retry-on-import': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Configuration/typings typically export objects/definitions that are used outside of the transpiled package
|
||||
// (eg not captured by the tsconfig). Because it's typical and not exceptional, this is turned off entirely.
|
||||
|
||||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1 +1 @@
|
||||
@uniswap/web-reviewers
|
||||
@uniswap/web-admins
|
||||
|
||||
48
.github/actions/report/action.yml
vendored
Normal file
48
.github/actions/report/action.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Report
|
||||
description: Report test failures via Slack
|
||||
inputs:
|
||||
name:
|
||||
description: The name of the failing test
|
||||
required: true
|
||||
SLACK_WEBHOOK_URL:
|
||||
description: The webhook URL to send the report to
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "${{ inputs.name }} failing on `${{ github.ref_name }}`",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*${{ inputs.name }} failing on `${{ github.ref_name }}`:* <https://github.com/${{ github.repository}}/actions/runs/${{ github.run_id }}|view failing action>"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "_This is blocking pull requests and branch promotions._\n_Please prioritize fixing the build._"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ inputs.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
# The !oncall bot requires its own message:
|
||||
- uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "!oncall web"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ inputs.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
55
.github/actions/setup/action.yml
vendored
55
.github/actions/setup/action.yml
vendored
@@ -10,11 +10,12 @@ runs:
|
||||
with:
|
||||
node-version: 14
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: 'yarn'
|
||||
|
||||
# node_modules/.cache is intentionally omitted, as this is used for build tool caches.
|
||||
- uses: actions/cache@v3
|
||||
id: install-cache
|
||||
with:
|
||||
# node_modules/.cache is intentionally omitted, as this is used for build tool caches.
|
||||
path: |
|
||||
node_modules
|
||||
!node_modules/.cache
|
||||
@@ -22,3 +23,55 @@ runs:
|
||||
- if: steps.install-cache.outputs.cache-hit != 'true'
|
||||
run: yarn install --frozen-lockfile --ignore-scripts
|
||||
shell: bash
|
||||
|
||||
# Validators compile quickly, so caching can be omitted.
|
||||
- run: yarn ajv
|
||||
shell: bash
|
||||
|
||||
# Contracts are compiled from source. If source hasn't changed, the contracts do not need to be re-compiled.
|
||||
- uses: actions/cache@v3
|
||||
id: contracts-cache
|
||||
with:
|
||||
path: |
|
||||
src/abis/types
|
||||
src/types/v3
|
||||
key: ${{ runner.os }}-contracts-${{ hashFiles('src/abis/**/*.json', 'node_modules/@uniswap/**/artifacts/contracts/**/*.json') }}
|
||||
- if: steps.contracts-cache.outputs.cache-hit != 'true'
|
||||
run: yarn contracts
|
||||
shell: bash
|
||||
|
||||
# GraphQL is generated from schema. The schema is always fetched, but if unchanged, graphql does not need to be re-generated.
|
||||
- run: yarn graphql:fetch
|
||||
shell: bash
|
||||
- uses: actions/cache@v3
|
||||
id: graphql-cache
|
||||
with:
|
||||
path: src/graphql/**/__generated__
|
||||
key: ${{ runner.os }}-graphql-${{ hashFiles('src/graphql/**/schema.graphql') }}
|
||||
- if: steps.graphql-cache.outputs.cache-hit != 'true'
|
||||
run: yarn graphql:generate
|
||||
shell: bash
|
||||
|
||||
# Messages are extracted from source.
|
||||
# A record of source file content hashes is maintained in node_modules/.cache/lingui by a custom extractor.
|
||||
# Messages are always extracted, but extraction may rely on the custom extractor's loaded cache.
|
||||
- uses: actions/cache@v3
|
||||
id: i18n-extract-cache
|
||||
with:
|
||||
path: |
|
||||
src/locales/en-US.po
|
||||
node_modules/.cache
|
||||
key: ${{ runner.os }}-i18n-extract-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-i18n-extract-
|
||||
- run: yarn i18n:extract
|
||||
shell: bash
|
||||
|
||||
# Translations are compiled from messages. If messages haven't changed, the translations do not need to be re-compiled.
|
||||
- uses: actions/cache@v3
|
||||
id: i18n-compile-cache
|
||||
with:
|
||||
path: src/locales/*.js
|
||||
key: ${{ runner.os }}-i18n-compile-${{ hashFiles('src/locales/*.po') }}
|
||||
- if: steps.i18n-compile-cache.outputs.cache-hit !='true'
|
||||
run: yarn i18n:compile
|
||||
shell: bash
|
||||
|
||||
1
.github/dependabot.yml
vendored
1
.github/dependabot.yml
vendored
@@ -8,6 +8,5 @@ updates:
|
||||
allow:
|
||||
- dependency-name: '@uniswap/default-token-list'
|
||||
- dependency-name: '@uniswap/token-lists'
|
||||
- dependency-name: '@uniswap/widgets'
|
||||
reviewers:
|
||||
- 'Uniswap/dependabot-reviewers'
|
||||
|
||||
32
.github/workflows/1-main-to-staging.yml
vendored
Normal file
32
.github/workflows/1-main-to-staging.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: 1 | Push main -> staging
|
||||
|
||||
# This CI job is responsible for pushing the current contents of the `main` branch to the
|
||||
# `releases/staging` branch, which will in turn kick off a deploy to the staging environment.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# https://stackoverflow.com/questions/57921401/push-to-origin-from-github-action
|
||||
jobs:
|
||||
push-staging:
|
||||
name: 'Push to staging branch'
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: push/staging
|
||||
steps:
|
||||
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
|
||||
ref: main
|
||||
- name: Git config
|
||||
run: |
|
||||
git config user.name "UL Service Account"
|
||||
git config user.email "hello-happy-puppy@users.noreply.github.com"
|
||||
- name: Add CODEOWNERS file
|
||||
run: |
|
||||
echo "@uniswap/web-admins" > CODEOWNERS
|
||||
git add CODEOWNERS
|
||||
git commit -m "ci: add global CODEOWNERS"
|
||||
- name: Git push
|
||||
run: |
|
||||
git push origin main:releases/staging --force
|
||||
41
.github/workflows/2-deploy-to-staging.yml
vendored
Normal file
41
.github/workflows/2-deploy-to-staging.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: 2 | Deploy staging
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'releases/staging'
|
||||
|
||||
jobs:
|
||||
deploy-to-staging:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: deploy/staging
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
- run: yarn prepare
|
||||
- run: yarn build
|
||||
env:
|
||||
REACT_APP_STAGING: 1
|
||||
- name: Setup node@16 (required by Cloudflare Pages)
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
- name: Update Cloudflare Pages deployment
|
||||
uses: cloudflare/pages-action@364c7ca09a4b57837c5967871d64a2c31adb8c0d
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: interface-staging
|
||||
directory: build
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Upload source maps to Sentry
|
||||
uses: getsentry/action-release@bd5f874fcda966ba48139b0140fb3ec0cb3aabdd
|
||||
continue-on-error: true
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
with:
|
||||
environment: staging
|
||||
sourcemaps: './build/static/js'
|
||||
url_prefix: '~/static/js'
|
||||
27
.github/workflows/3-staging-to-prod.yml
vendored
Normal file
27
.github/workflows/3-staging-to-prod.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: 3 | Push staging -> prod
|
||||
|
||||
# This CI job is responsible for force pushing the content of releases/staging to releases/prod. It
|
||||
# is restricted to web-reviewers through virtue of the GitHub environment protection rules for the
|
||||
# prod environment.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
push-prod:
|
||||
name: 'Push to prod branch'
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: push/prod
|
||||
steps:
|
||||
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
|
||||
ref: releases/staging
|
||||
- name: Git config
|
||||
run: |
|
||||
git config user.name "UL Service Account"
|
||||
git config user.email "hello-happy-puppy@users.noreply.github.com"
|
||||
- name: Git push
|
||||
run: |
|
||||
git push origin releases/staging:releases/prod --force
|
||||
91
.github/workflows/4-deploy-to-prod.yml
vendored
Normal file
91
.github/workflows/4-deploy-to-prod.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
name: 4 | Deploy prod
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'releases/prod'
|
||||
|
||||
jobs:
|
||||
deploy-to-prod:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: deploy/prod
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
- run: yarn prepare
|
||||
- run: yarn build
|
||||
- name: Bump and tag
|
||||
id: github-tag-action
|
||||
uses: mathieudutour/github-tag-action@v6.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
release_branches: releases/prod
|
||||
default_bump: patch
|
||||
|
||||
- name: Pin to IPFS
|
||||
id: pinata
|
||||
uses: anantaramdas/ipfs-pinata-deploy-action@39bbda1ce1fe24c69c6f57861b8038278d53688d
|
||||
with:
|
||||
pin-name: Uniswap ${{ steps.github-tag-action.outputs.new_tag }}
|
||||
path: './build'
|
||||
pinata-api-key: ${{ secrets.PINATA_API_KEY }}
|
||||
pinata-secret-api-key: ${{ secrets.PINATA_API_SECRET_KEY }}
|
||||
|
||||
- name: Convert CIDv0 to CIDv1
|
||||
id: convert-cidv0
|
||||
uses: uniswap/convert-cidv0-cidv1@v1.0.0
|
||||
with:
|
||||
cidv0: ${{ steps.pinata.outputs.hash }}
|
||||
|
||||
- name: Release
|
||||
uses: actions/create-release@v1.1.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ steps.github-tag-action.outputs.new_tag }}
|
||||
release_name: Release ${{ steps.github-tag-action.outputs.new_tag }}
|
||||
body: |
|
||||
IPFS hash of the deployment:
|
||||
- CIDv0: `${{ steps.pinata.outputs.hash }}`
|
||||
- CIDv1: `${{ steps.convert-cidv0.outputs.cidv1 }}`
|
||||
|
||||
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
|
||||
|
||||
You can also access the Uniswap Interface 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 hosted deployment of 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.pinata.outputs.hash }}/](ipfs://${{ steps.pinata.outputs.hash }}/)
|
||||
|
||||
${{ steps.github-tag-action.outputs.changelog }}
|
||||
|
||||
- name: Setup node@16 (required by Cloudflare Pages)
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: Update Cloudflare Pages deployment
|
||||
uses: cloudflare/pages-action@364c7ca09a4b57837c5967871d64a2c31adb8c0d
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
|
||||
directory: build
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: main
|
||||
|
||||
- name: Upload source maps to Sentry
|
||||
uses: getsentry/action-release@4744f6a65149f441c5f396d5b0877307c0db52c7
|
||||
continue-on-error: true
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
with:
|
||||
environment: production
|
||||
sourcemaps: './build/static/js'
|
||||
url_prefix: '~/static/js'
|
||||
5
.github/workflows/release.yaml
vendored
5
.github/workflows/release.yaml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Release
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 12 * * 1-4' # every day 12:00 UTC Monday-Thursday
|
||||
- cron: '0 16 * * 1-4' # every day 16:00 UTC Monday-Thursday
|
||||
# manual trigger
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -47,7 +47,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
- run: yarn prepare
|
||||
- run: yarn build
|
||||
|
||||
- name: Pin to IPFS
|
||||
@@ -114,7 +113,7 @@ jobs:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload source maps to Sentry
|
||||
uses: getsentry/action-release@bd5f874fcda966ba48139b0140fb3ec0cb3aabdd
|
||||
uses: getsentry/action-release@4744f6a65149f441c5f396d5b0877307c0db52c7
|
||||
continue-on-error: true
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
63
.github/workflows/test.yml
vendored
63
.github/workflows/test.yml
vendored
@@ -2,6 +2,7 @@ name: Test
|
||||
|
||||
# Many build steps have their own caches, so each job has its own cache to improve subsequent build times.
|
||||
# Build tools are configured to cache cache to node_modules/.cache, so this is cached independently of node_modules.
|
||||
# Caches are saved every run (by keying on github.run_id), and the most recent available cache is loaded.
|
||||
# See https://jongleberry.medium.com/speed-up-your-ci-and-dx-with-node-modules-cache-ac8df82b7bb0.
|
||||
|
||||
on:
|
||||
@@ -25,6 +26,11 @@ jobs:
|
||||
key: ${{ runner.os }}-eslint-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-eslint-${{ hashFiles('**/yarn.lock') }}-
|
||||
- run: yarn lint
|
||||
- if: failure() && github.ref_name == 'main'
|
||||
uses: ./.github/actions/report
|
||||
with:
|
||||
name: Lint
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -37,8 +43,12 @@ jobs:
|
||||
path: node_modules/.cache
|
||||
key: ${{ runner.os }}-tsc-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-tsc-${{ hashFiles('**/yarn.lock') }}-
|
||||
- run: yarn prepare
|
||||
- run: yarn typecheck
|
||||
- if: failure() && github.ref_name == 'main'
|
||||
uses: ./.github/actions/report
|
||||
with:
|
||||
name: Typecheck
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
|
||||
|
||||
deps-tests:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -46,6 +56,11 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
- run: yarn yarn-deduplicate --strategy=highest --list --fail
|
||||
- if: failure() && github.ref_name == 'main'
|
||||
uses: ./.github/actions/report
|
||||
with:
|
||||
name: Dependency checks
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
|
||||
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -58,45 +73,18 @@ jobs:
|
||||
path: node_modules/.cache
|
||||
key: ${{ runner.os }}-jest-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-jest-${{ hashFiles('**/yarn.lock') }}-
|
||||
- run: yarn prepare
|
||||
- run: yarn test --silent --maxWorkers=100%
|
||||
- run: yarn test --coverage --maxWorkers=100%
|
||||
- uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
flags: unit-tests
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
- uses: actions/cache@v3
|
||||
id: build-cache
|
||||
- if: failure() && github.ref_name == 'main'
|
||||
uses: ./.github/actions/report
|
||||
with:
|
||||
path: node_modules/.cache
|
||||
key: ${{ runner.os }}-build-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-build-${{ hashFiles('**/yarn.lock') }}-
|
||||
- run: yarn prepare
|
||||
- run: yarn build
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: build
|
||||
path: build
|
||||
if-no-files-found: error
|
||||
|
||||
size-tests:
|
||||
needs: [build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: build
|
||||
path: build
|
||||
- run: yarn test:size
|
||||
name: Unit tests
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
|
||||
|
||||
build-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -109,7 +97,6 @@ jobs:
|
||||
path: node_modules/.cache
|
||||
key: ${{ runner.os }}-build-e2e-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-build-e2e-${{ hashFiles('**/yarn.lock') }}-
|
||||
- run: yarn prepare
|
||||
- run: yarn build:e2e
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
@@ -171,9 +158,15 @@ jobs:
|
||||
|
||||
# Included as a single job to check for cypress-test-matrix success, as a matrix cannot be checked.
|
||||
cypress-tests:
|
||||
if: ${{ always() }}
|
||||
if: always()
|
||||
needs: [cypress-test-matrix]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- if: needs.cypress-test-matrix.result != 'success'
|
||||
run: exit 1
|
||||
- if: failure() && github.ref_name == 'main'
|
||||
uses: ./.github/actions/report
|
||||
with:
|
||||
name: Cypress tests
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,8 +8,10 @@
|
||||
/src/locales/**/en-US.po
|
||||
/src/locales/**/pseudo.po
|
||||
|
||||
# generated graphql types
|
||||
/src/graphql/**/__generated__
|
||||
# generated files
|
||||
/src/**/__generated__
|
||||
|
||||
# schema
|
||||
schema.graphql
|
||||
|
||||
# dependencies
|
||||
|
||||
115
craco.config.cjs
115
craco.config.cjs
@@ -1,9 +1,11 @@
|
||||
/* eslint-env node */
|
||||
const { VanillaExtractPlugin } = require('@vanilla-extract/webpack-plugin')
|
||||
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
|
||||
const { execSync } = require('child_process')
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
|
||||
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
|
||||
const { DefinePlugin, IgnorePlugin } = require('webpack')
|
||||
const path = require('path')
|
||||
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin')
|
||||
const { DefinePlugin, IgnorePlugin, ProvidePlugin } = require('webpack')
|
||||
|
||||
const commitHash = execSync('git rev-parse HEAD').toString().trim()
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
@@ -12,6 +14,11 @@ const isProduction = process.env.NODE_ENV === 'production'
|
||||
// Omit them from production builds, as they slow down the feedback loop.
|
||||
const shouldLintOrTypeCheck = !isProduction
|
||||
|
||||
function getCacheDirectory(cacheName) {
|
||||
// Include the trailing slash to denote that this is a directory.
|
||||
return `${path.join(__dirname, 'node_modules/.cache/', cacheName)}/`
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
babel: {
|
||||
plugins: [
|
||||
@@ -44,8 +51,13 @@ module.exports = {
|
||||
pluginOptions(eslintConfig) {
|
||||
return Object.assign(eslintConfig, {
|
||||
cache: true,
|
||||
cacheLocation: 'node_modules/.cache/eslint/',
|
||||
cacheLocation: getCacheDirectory('eslint'),
|
||||
ignorePath: '.gitignore',
|
||||
// Use our own eslint/plugins/config, as overrides interfere with caching.
|
||||
// This ensures that `yarn start` and `yarn lint` share one cache.
|
||||
eslintPath: require.resolve('eslint'),
|
||||
resolvePluginsRelativeTo: null,
|
||||
baseConfig: null,
|
||||
})
|
||||
},
|
||||
},
|
||||
@@ -55,11 +67,13 @@ module.exports = {
|
||||
jest: {
|
||||
configure(jestConfig) {
|
||||
return Object.assign(jestConfig, {
|
||||
transform: {
|
||||
'\\.css\\.ts$': './vanilla.transform.cjs',
|
||||
...jestConfig.transform,
|
||||
},
|
||||
cacheDirectory: 'node_modules/.cache/jest',
|
||||
cacheDirectory: getCacheDirectory('jest'),
|
||||
transform: Object.assign(jestConfig.transform, {
|
||||
// Transform vanilla-extract using its own transformer.
|
||||
// See https://sandroroth.com/blog/vanilla-extract-cra#jest-transform.
|
||||
'\\.css\\.ts$': '@vanilla-extract/jest-transform',
|
||||
}),
|
||||
// Use @uniswap/conedison's build directly, as jest does not support its exports.
|
||||
transformIgnorePatterns: ['@uniswap/conedison/format', '@uniswap/conedison/provider'],
|
||||
moduleNameMapper: {
|
||||
'@uniswap/conedison/format': '@uniswap/conedison/dist/format',
|
||||
@@ -69,8 +83,19 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
webpack: {
|
||||
plugins: [new VanillaExtractPlugin({ identifiers: 'short' })],
|
||||
plugins: [
|
||||
// Webpack 5 does not polyfill node globals, so we do so for those necessary:
|
||||
new ProvidePlugin({
|
||||
// - react-markdown requires process.cwd
|
||||
process: 'process/browser',
|
||||
}),
|
||||
// vanilla-extract has poor performance on M1 machines with 'debug' identifiers, so we use 'short' instead.
|
||||
// See https://vanilla-extract.style/documentation/integrations/webpack/#identifiers for docs.
|
||||
// See https://github.com/vanilla-extract-css/vanilla-extract/issues/771#issuecomment-1249524366.
|
||||
new VanillaExtractPlugin({ identifiers: 'short' }),
|
||||
],
|
||||
configure: (webpackConfig) => {
|
||||
// Configure webpack plugins:
|
||||
webpackConfig.plugins = webpackConfig.plugins
|
||||
.map((plugin) => {
|
||||
// Extend process.env with dynamic values (eg commit hash).
|
||||
@@ -87,10 +112,16 @@ module.exports = {
|
||||
plugin.options.ignoreOrder = true
|
||||
}
|
||||
|
||||
// Disable TypeScript's config overwrite, as it interferes with incremental build caching.
|
||||
// This ensures that `yarn start` and `yarn typecheck` share one cache.
|
||||
if (plugin.constructor.name == 'ForkTsCheckerWebpackPlugin') {
|
||||
delete plugin.options.typescript.configOverwrite
|
||||
}
|
||||
|
||||
return plugin
|
||||
})
|
||||
.filter((plugin) => {
|
||||
// Case sensitive paths are enforced by TypeScript.
|
||||
// Case sensitive paths are already enforced by TypeScript.
|
||||
// See https://www.typescriptlang.org/tsconfig#forceConsistentCasingInFileNames.
|
||||
if (plugin instanceof CaseSensitivePathsPlugin) return false
|
||||
|
||||
@@ -100,10 +131,66 @@ module.exports = {
|
||||
return true
|
||||
})
|
||||
|
||||
// We're currently on Webpack 4.x which doesn't support the `exports` field in package.json.
|
||||
// Instead, we need to manually map the import path to the correct exports path (eg dist or build folder).
|
||||
// See https://github.com/webpack/webpack/issues/9509.
|
||||
webpackConfig.resolve.alias['@uniswap/conedison'] = '@uniswap/conedison/dist'
|
||||
// Configure webpack resolution:
|
||||
webpackConfig.resolve = Object.assign(webpackConfig.resolve, {
|
||||
plugins: webpackConfig.resolve.plugins.map((plugin) => {
|
||||
// Allow vanilla-extract in production builds.
|
||||
// This is necessary because create-react-app guards against external imports.
|
||||
// See https://sandroroth.com/blog/vanilla-extract-cra#production-build.
|
||||
if (plugin instanceof ModuleScopePlugin) {
|
||||
plugin.allowedPaths.push(path.join(__dirname, 'node_modules/@vanilla-extract/webpack-plugin'))
|
||||
}
|
||||
|
||||
return plugin
|
||||
}),
|
||||
// Webpack 5 does not resolve node modules, so we do so for those necessary:
|
||||
fallback: {
|
||||
// - react-markdown requires path
|
||||
path: require.resolve('path-browserify'),
|
||||
},
|
||||
})
|
||||
|
||||
// Configure webpack transpilation (create-react-app specifies transpilation rules in a oneOf):
|
||||
webpackConfig.module.rules[1].oneOf = webpackConfig.module.rules[1].oneOf.map((rule) => {
|
||||
// The fallback rule (eg for dependencies).
|
||||
if (rule.loader && rule.loader.match(/babel-loader/) && !rule.include) {
|
||||
// Allow not-fully-specified modules so that legacy packages are still able to build.
|
||||
rule.resolve = { fullySpecified: false }
|
||||
|
||||
// The class properties transform is required for @uniswap/analytics to build.
|
||||
rule.options.plugins.push('@babel/plugin-proposal-class-properties')
|
||||
}
|
||||
return rule
|
||||
})
|
||||
|
||||
// Configure webpack optimization:
|
||||
webpackConfig.optimization = Object.assign(
|
||||
webpackConfig.optimization,
|
||||
isProduction
|
||||
? {
|
||||
splitChunks: {
|
||||
// Cap the chunk size to 5MB.
|
||||
// react-scripts suggests a chunk size under 1MB after gzip, but we can only measure maxSize before gzip.
|
||||
// react-scripts also caps cacheable chunks at 5MB, which gzips to below 1MB, so we cap chunk size there.
|
||||
// See https://github.com/facebook/create-react-app/blob/d960b9e/packages/react-scripts/config/webpack.config.js#L713-L716.
|
||||
maxSize: 5 * 1024 * 1024,
|
||||
// Optimize over all chunks, instead of async chunks (the default), so that initial chunks are also optimized.
|
||||
chunks: 'all',
|
||||
},
|
||||
}
|
||||
: {}
|
||||
)
|
||||
|
||||
// Configure webpack caching:
|
||||
webpackConfig.cache = Object.assign(webpackConfig.cache, {
|
||||
cacheDirectory: getCacheDirectory('webpack'),
|
||||
})
|
||||
|
||||
// Ignore failed source mappings to avoid spamming the console.
|
||||
// Source mappings for a package will fail if the package does not provide them, but the build will still succeed,
|
||||
// so it is unnecessary (and bothersome) to log it. This should be turned off when debugging missing sourcemaps.
|
||||
// See https://webpack.js.org/loaders/source-map-loader#ignoring-warnings.
|
||||
webpackConfig.ignoreWarnings = [/Failed to parse source map/]
|
||||
|
||||
return webpackConfig
|
||||
},
|
||||
|
||||
28
cypress/e2e/buy-crypto-modal.test.ts
Normal file
28
cypress/e2e/buy-crypto-modal.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getTestSelector } from '../utils'
|
||||
|
||||
describe('Buy Crypto Modal', () => {
|
||||
it('should open and close', () => {
|
||||
cy.visit('/')
|
||||
|
||||
// Open the fiat onramp modal
|
||||
cy.get(getTestSelector('buy-fiat-button')).click()
|
||||
cy.get(getTestSelector('fiat-onramp-modal')).should('be.visible')
|
||||
|
||||
// Click on a location that should be outside the modal, which should close it
|
||||
cy.get('body').click(0, 100)
|
||||
cy.get(getTestSelector('fiat-onramp-modal')).should('not.exist')
|
||||
})
|
||||
|
||||
it('should open and close, mobile viewport', () => {
|
||||
cy.viewport('iphone-6')
|
||||
cy.visit('/')
|
||||
|
||||
// Open the fiat onramp modal
|
||||
cy.get(getTestSelector('buy-fiat-button')).click()
|
||||
cy.get(getTestSelector('fiat-onramp-modal')).should('be.visible')
|
||||
|
||||
// Click on a location that should be outside the modal, which should close it
|
||||
cy.get('body').click(10, 10)
|
||||
cy.get(getTestSelector('fiat-onramp-modal')).should('not.exist')
|
||||
})
|
||||
})
|
||||
124
cypress/e2e/mini-portfolio/activity-history.test.ts
Normal file
124
cypress/e2e/mini-portfolio/activity-history.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { getTestSelector } from '../../utils'
|
||||
|
||||
describe('mini-portfolio activity history', () => {
|
||||
beforeEach(() => {
|
||||
cy.hardhat()
|
||||
.then((hardhat) => hardhat.wallet.getTransactionCount())
|
||||
.then((nonce) => {
|
||||
// Mock graphql response to include specific nonces.
|
||||
cy.intercept(
|
||||
{
|
||||
method: 'POST',
|
||||
url: 'https://beta.api.uniswap.org/v1/graphql',
|
||||
},
|
||||
{
|
||||
body: {
|
||||
data: {
|
||||
portfolios: [
|
||||
{
|
||||
id: 'UG9ydGZvbGlvOjB4NUNlYUI3NGU0NDZkQmQzYkY2OUUyNzcyMDBGMTI5ZDJiQzdBMzdhMQ==',
|
||||
assetActivities: [
|
||||
{
|
||||
id: 'QXNzZXRBY3Rpdml0eTpWSEpoYm5OaFkzUnBiMjQ2TUhnME5tUm1PVGs0T0RrNVl6UmtNR1kzWTJNNE9HRTVNVFEzTURBME9EWmtOVGhrTURnNFpqbG1NelkxTnpRM1l6WXdZek15WVRFNE4yWXlaRFEwWVdVNFh6QjRZV1EyWXpCa05XTmlOVEZsWWpjMU5qUTFaRGszT1RneE4yRTJZVEkxTmpreU1UbG1ZbVE1Wmw4d2VEQXpOR0UwTURjMk5EUTROV1kzWlRBNFkyRXhOak0yTm1VMU1ETTBPVEZoTm1GbU56ZzFNR1E9',
|
||||
timestamp: 1681150079,
|
||||
type: 'UNKNOWN',
|
||||
chain: 'ETHEREUM',
|
||||
transaction: {
|
||||
id: 'VHJhbnNhY3Rpb246MHg0NmRmOTk4ODk5YzRkMGY3Y2M4OGE5MTQ3MDA0ODZkNThkMDg4ZjlmMzY1NzQ3YzYwYzMyYTE4N2YyZDQ0YWU4XzB4YWQ2YzBkNWNiNTFlYjc1NjQ1ZDk3OTgxN2E2YTI1NjkyMTlmYmQ5Zl8weDAzNGE0MDc2NDQ4NWY3ZTA4Y2ExNjM2NmU1MDM0OTFhNmFmNzg1MGQ=',
|
||||
blockNumber: 17019453,
|
||||
hash: '0x46df998899c4d0f7cc88a914700486d58d088f9f365747c60c32a187f2d44ae8',
|
||||
status: 'CONFIRMED',
|
||||
to: '0x034a40764485f7e08ca16366e503491a6af7850d',
|
||||
from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
|
||||
nonce,
|
||||
__typename: 'Transaction',
|
||||
},
|
||||
assetChanges: [],
|
||||
__typename: 'AssetActivity',
|
||||
},
|
||||
{
|
||||
id: 'QXNzZXRBY3Rpdml0eTpWSEpoYm5OaFkzUnBiMjQ2TUhneE16UXpaR1ppTlROaE9XRmpNR00yWW1aaVpqUTNNRFEyWWpObFkyRXhORGN3TUdZd00yWXhOMkV3WWpnM1pqWXpPRFpsWVRnNU16QTRNVFZtWmpoaFh6QjRZMkUzTXpOalkySm1OelZoTXpnME1ERXhPR1ZpT1RjNU9EVTJOemRpTkdRMk56TTBZemMwWmw4d2VERmlOVEUxTkdGaE5HSTRaakF5TjJJNVptUXhPVE0wTVRFek1tWmpPV1JoWlRFd1pqY3pOVGs9',
|
||||
timestamp: 1681149995,
|
||||
type: 'SEND',
|
||||
chain: 'ETHEREUM',
|
||||
transaction: {
|
||||
id: 'VHJhbnNhY3Rpb246MHgxMzQzZGZiNTNhOWFjMGM2YmZiZjQ3MDQ2YjNlY2ExNDcwMGYwM2YxN2EwYjg3ZjYzODZlYTg5MzA4MTVmZjhhXzB4Y2E3MzNjY2JmNzVhMzg0MDExOGViOTc5ODU2NzdiNGQ2NzM0Yzc0Zl8weDFiNTE1NGFhNGI4ZjAyN2I5ZmQxOTM0MTEzMmZjOWRhZTEwZjczNTk=',
|
||||
blockNumber: 17019446,
|
||||
hash: '0x1343dfb53a9ac0c6bfbf47046b3eca14700f03f17a0b87f6386ea8930815ff8a',
|
||||
status: 'CONFIRMED',
|
||||
to: '0x1b5154aa4b8f027b9fd19341132fc9dae10f7359',
|
||||
from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
|
||||
nonce: nonce + 1,
|
||||
__typename: 'Transaction',
|
||||
},
|
||||
assetChanges: [
|
||||
{
|
||||
__typename: 'TokenTransfer',
|
||||
id: 'VG9rZW5UcmFuc2ZlcjoweDVjZWFiNzRlNDQ2ZGJkM2JmNjllMjc3MjAwZjEyOWQyYmM3YTM3YTFfMHhiMWRjNDlmMDY1N2FkNTA1YjUzNzUyN2RkOWE1MDk0YTM2NTkzMWMxXzB4MTM0M2RmYjUzYTlhYzBjNmJmYmY0NzA0NmIzZWNhMTQ3MDBmMDNmMTdhMGI4N2Y2Mzg2ZWE4OTMwODE1ZmY4YQ==',
|
||||
asset: {
|
||||
id: 'VG9rZW46RVRIRVJFVU1fMHgxY2MyYjA3MGNhZjAxNmE3ZGRjMzA0N2Y5MzI3MmU4Yzc3YzlkZGU5',
|
||||
name: 'USD Coin (USDC)',
|
||||
symbol: 'USDC',
|
||||
address: '0x1cc2b070caf016a7ddc3047f93272e8c77c9dde9',
|
||||
decimals: 6,
|
||||
chain: 'ETHEREUM',
|
||||
standard: null,
|
||||
project: {
|
||||
id: 'VG9rZW5Qcm9qZWN0OkVUSEVSRVVNXzB4MWNjMmIwNzBjYWYwMTZhN2RkYzMwNDdmOTMyNzJlOGM3N2M5ZGRlOQ==',
|
||||
isSpam: true,
|
||||
logo: null,
|
||||
__typename: 'TokenProject',
|
||||
},
|
||||
__typename: 'Token',
|
||||
},
|
||||
tokenStandard: 'ERC20',
|
||||
quantity: '18011.212084',
|
||||
sender: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
|
||||
recipient: '0xb1dc49f0657ad505b537527dd9a5094a365931c1',
|
||||
direction: 'OUT',
|
||||
transactedValue: null,
|
||||
},
|
||||
],
|
||||
__typename: 'AssetActivity',
|
||||
},
|
||||
],
|
||||
__typename: 'Portfolio',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
).as('graphql')
|
||||
})
|
||||
})
|
||||
|
||||
it('should deduplicate activity history by nonce', () => {
|
||||
cy.visit('/swap', { ethereum: 'hardhat' }).hardhat({ automine: false })
|
||||
|
||||
// Input swap info.
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('1')
|
||||
cy.get('#swap-currency-output .open-currency-select-button').click()
|
||||
cy.contains('USDC').click()
|
||||
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
|
||||
|
||||
// Set slippage to a high value.
|
||||
cy.get(getTestSelector('open-settings-dialog-button')).click()
|
||||
cy.get(getTestSelector('max-slippage-settings')).click()
|
||||
cy.get(getTestSelector('slippage-input')).clear().type('5')
|
||||
cy.get('body').click('topRight')
|
||||
cy.get(getTestSelector('slippage-input')).should('not.exist')
|
||||
|
||||
// Click swap button.
|
||||
cy.contains('1 USDC = ').should('exist')
|
||||
cy.get('#swap-button').should('not.be', 'disabled').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
|
||||
|
||||
// Check activity history tab.
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('mini-portfolio-nav-activity')).click()
|
||||
|
||||
// Assert that the local pending transaction is replaced by a remote transaction with the same nonce.
|
||||
cy.contains('Swapping').should('not.exist')
|
||||
})
|
||||
})
|
||||
223
cypress/e2e/permit2.test.ts
Normal file
223
cypress/e2e/permit2.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { MaxUint160, MaxUint256 } from '@uniswap/permit2-sdk'
|
||||
|
||||
import { DAI, USDC_MAINNET } from '../../src/constants/tokens'
|
||||
import { getTestSelector } from '../utils'
|
||||
|
||||
const APPROVE_BUTTON = '[data-testid="swap-approve-button"]'
|
||||
|
||||
/** Initiates a swap and confirms its success. */
|
||||
function swaps() {
|
||||
// The swap-button can be temporarily disabled following approval, & Cypress will retry clicking the disabled version.
|
||||
// This ensures that we don't click until the button is enabled.
|
||||
cy.get('#swap-button').should('not.have.attr', 'disabled')
|
||||
|
||||
// Completes the swap.
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
|
||||
|
||||
// Verifies that there is a successful swap notification.
|
||||
cy.contains('Swapped').should('exist')
|
||||
}
|
||||
|
||||
// TODO(WEB-3299): Update tests to differentiate between permit2 vs token approval button text once UI is updated to indicate approval step.
|
||||
describe('Permit2', () => {
|
||||
// The same tokens & swap-amount combination is used for all permit2 tests.
|
||||
const INPUT_TOKEN = DAI
|
||||
const OUTPUT_TOKEN = USDC_MAINNET
|
||||
const TEST_BALANCE_INCREMENT = 0.01
|
||||
|
||||
beforeEach(() => {
|
||||
// Sets up a swap between INPUT_TOKEN and OUTPUT_TOKEN.
|
||||
cy.visit(`/swap/?inputCurrency=${INPUT_TOKEN.address}&outputCurrency=${OUTPUT_TOKEN.address}`, {
|
||||
ethereum: 'hardhat',
|
||||
})
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type(TEST_BALANCE_INCREMENT.toString())
|
||||
})
|
||||
|
||||
/** Asserts permit2 has a max approval for spend of the input token on-chain. */
|
||||
function expectTokenAllowanceForPermit2ToBeMax() {
|
||||
// check token approval
|
||||
return cy
|
||||
.hardhat()
|
||||
.then(({ approval, wallet }) => approval.getTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN }))
|
||||
.should('deep.equal', MaxUint256)
|
||||
}
|
||||
|
||||
/** Asserts the universal router has a max permit2 approval for spend of the input token on-chain. */
|
||||
function expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime: number) {
|
||||
return cy
|
||||
.hardhat()
|
||||
.then((hardhat) => hardhat.approval.getPermit2Allowance({ owner: hardhat.wallet, token: INPUT_TOKEN }))
|
||||
.then((allowance) => {
|
||||
cy.wrap(MaxUint160.eq(allowance.amount)).should('eq', true)
|
||||
// Asserts that the on-chain expiration is in 30 days, within a tolerance of 40 seconds.
|
||||
const expected = Math.floor((approvalTime + 2_592_000_000) / 1000)
|
||||
cy.wrap(allowance.expiration).should('be.closeTo', expected, 40)
|
||||
})
|
||||
}
|
||||
|
||||
it('swaps when user has already approved token and permit2', () => {
|
||||
cy.hardhat()
|
||||
.then(({ approval, wallet }) => {
|
||||
approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN })
|
||||
approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN })
|
||||
})
|
||||
.then(swaps)
|
||||
})
|
||||
|
||||
it('swaps after completing full permit2 approval process', () => {
|
||||
cy.get(APPROVE_BUTTON)
|
||||
.click()
|
||||
.then(() => {
|
||||
const approvalTime = Date.now()
|
||||
cy.get(APPROVE_BUTTON).should('have.text', 'Approval pending')
|
||||
|
||||
// There should be a successful Approved notification.
|
||||
cy.contains('Approved').should('exist')
|
||||
|
||||
swaps()
|
||||
|
||||
expectTokenAllowanceForPermit2ToBeMax()
|
||||
expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime)
|
||||
})
|
||||
})
|
||||
|
||||
it('swaps after handling user rejection of approvals and signatures', () => {
|
||||
const USER_REJECTION = { code: 4001 }
|
||||
cy.hardhat().then((hardhat) => {
|
||||
const tokenApprovalStub = cy.stub(hardhat.wallet, 'sendTransaction')
|
||||
tokenApprovalStub.rejects(USER_REJECTION) // reject token approval
|
||||
|
||||
const permitApprovalStub = cy.stub(hardhat.provider, 'send')
|
||||
permitApprovalStub.withArgs('eth_signTypedData_v4').rejects(USER_REJECTION) // reject permit approval
|
||||
permitApprovalStub.callThrough() // allows non-eth_signTypedData_v4 send calls to return non-stubbed values
|
||||
|
||||
// Clicking the approve button should trigger a token approval that will be rejected by the user (tokenApprovalStub).
|
||||
cy.get(APPROVE_BUTTON).click()
|
||||
|
||||
// The swap component should prompt approval again.
|
||||
cy.get(APPROVE_BUTTON)
|
||||
.should('have.text', `Approve use of ${INPUT_TOKEN.symbol}`)
|
||||
.then(() => {
|
||||
tokenApprovalStub.restore() // allow token approval
|
||||
|
||||
// The user is now allowing approval, but the permit2 signature will be rejected by the user (permitApprovalStub).
|
||||
cy.get(APPROVE_BUTTON).click()
|
||||
cy.get(APPROVE_BUTTON)
|
||||
.should('have.text', `Approve use of ${INPUT_TOKEN.symbol}`)
|
||||
.then(() => {
|
||||
permitApprovalStub.restore() // allow permit approval
|
||||
|
||||
// The swap should now be able to proceed, as the permit2 signature will be accepted by the user.
|
||||
cy.get(APPROVE_BUTTON)
|
||||
.click()
|
||||
.then(() => {
|
||||
const approvalTime = Date.now()
|
||||
cy.get(APPROVE_BUTTON).should('have.text', 'Approval pending')
|
||||
|
||||
// There should be a successful Approved notification.
|
||||
cy.contains('Approved').should('exist')
|
||||
|
||||
swaps()
|
||||
|
||||
expectTokenAllowanceForPermit2ToBeMax()
|
||||
expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('swaps with existing token approval and missing permit approval', () => {
|
||||
cy.hardhat()
|
||||
.then(({ approval, wallet }) => approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN }))
|
||||
.then(() => {
|
||||
cy.get(APPROVE_BUTTON)
|
||||
.click()
|
||||
.then(() => {
|
||||
const approvalTime = Date.now()
|
||||
|
||||
swaps()
|
||||
|
||||
expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('swaps with existing permit approval and missing token approval', () => {
|
||||
cy.hardhat()
|
||||
.then(({ approval, wallet }) => approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN }))
|
||||
.then(() => {
|
||||
cy.get(APPROVE_BUTTON).click()
|
||||
cy.get(APPROVE_BUTTON).should('have.text', 'Approval pending')
|
||||
|
||||
// There should be a successful Approved notification.
|
||||
cy.contains('Approved').should('exist')
|
||||
|
||||
swaps()
|
||||
|
||||
expectTokenAllowanceForPermit2ToBeMax()
|
||||
})
|
||||
})
|
||||
|
||||
it('prompts signature when existing permit approval is expired', () => {
|
||||
const expiredAllowance = { expiration: Math.floor((Date.now() - 1) / 1000) }
|
||||
|
||||
cy.hardhat()
|
||||
.then(({ approval, wallet }) => {
|
||||
approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN })
|
||||
approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN }, expiredAllowance)
|
||||
})
|
||||
.then(() => {
|
||||
cy.get(APPROVE_BUTTON)
|
||||
.click()
|
||||
.then(() => {
|
||||
const approvalTime = Date.now()
|
||||
|
||||
swaps()
|
||||
|
||||
expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('prompts signature when existing permit approval amount is too low', () => {
|
||||
const smallAllowance = { amount: 1 }
|
||||
|
||||
cy.hardhat()
|
||||
.then(({ approval, wallet }) => {
|
||||
approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN })
|
||||
approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN }, smallAllowance)
|
||||
})
|
||||
.then(() => {
|
||||
cy.get(APPROVE_BUTTON)
|
||||
.click()
|
||||
.then(() => {
|
||||
const approvalTime = Date.now()
|
||||
|
||||
swaps()
|
||||
|
||||
expectPermit2AllowanceForUniversalRouterToBeMax(approvalTime)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('prompts token approval when existing approval amount is too low', () => {
|
||||
cy.hardhat()
|
||||
.then(({ approval, wallet }) => {
|
||||
approval.setPermit2Allowance({ owner: wallet, token: INPUT_TOKEN })
|
||||
approval.setTokenAllowanceForPermit2({ owner: wallet, token: INPUT_TOKEN }, 1)
|
||||
})
|
||||
.then(() => {
|
||||
cy.get(APPROVE_BUTTON).click()
|
||||
|
||||
// There should be a successful Approved notification.
|
||||
cy.contains('Approved').should('exist')
|
||||
|
||||
swaps()
|
||||
|
||||
expectTokenAllowanceForPermit2ToBeMax()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -53,8 +53,9 @@ describe('Service Worker', () => {
|
||||
it('installs a ServiceWorker', () => {
|
||||
cy.visit('/', { serviceWorker: true })
|
||||
.get('#swap-page')
|
||||
.wait('@NotInstalled', { timeout: 20000 })
|
||||
.window({ timeout: 20000 })
|
||||
// This is emitted after caching the entry file, which takes some time to load.
|
||||
.wait('@NotInstalled', { timeout: 60000 })
|
||||
.window()
|
||||
.and((win) => {
|
||||
expect(win.navigator.serviceWorker.controller?.state).to.equal('activated')
|
||||
})
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { FeatureFlag } from '../../src/featureFlags/flags/featureFlags'
|
||||
import { getClassContainsSelector, getTestSelector } from '../utils'
|
||||
|
||||
const UNI_GOERLI = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'
|
||||
const WETH_GOERLI = '0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6'
|
||||
|
||||
describe('swap widget integration tests', () => {
|
||||
const verifyInputToken = (inputText: string) => {
|
||||
cy.get(getClassContainsSelector('TokenButtonRow')).first().contains(inputText)
|
||||
}
|
||||
|
||||
const verifyOutputToken = (outputText: string) => {
|
||||
cy.get(getClassContainsSelector('TokenButtonRow')).last().contains(outputText)
|
||||
}
|
||||
|
||||
const selectOutputAndSwitch = (outputText: string) => {
|
||||
// open token selector...
|
||||
cy.contains('Select token').click()
|
||||
// select token...
|
||||
cy.contains(outputText).click({ force: true })
|
||||
|
||||
cy.get('body')
|
||||
.then(($body) => {
|
||||
if ($body.find(getTestSelector('TokenSafetyWrapper')).length) {
|
||||
return 'I understand'
|
||||
}
|
||||
|
||||
return 'You pay' // Just click on a random element as a no-op
|
||||
})
|
||||
.then((selector) => {
|
||||
cy.contains(selector).click()
|
||||
})
|
||||
|
||||
// token selector should close...
|
||||
cy.contains('Search name or paste address').should('not.exist')
|
||||
|
||||
cy.get(getClassContainsSelector('ReverseButton')).first().click()
|
||||
}
|
||||
|
||||
describe('widget on swap page', () => {
|
||||
beforeEach(() => {
|
||||
cy.viewport(1200, 800)
|
||||
})
|
||||
|
||||
it('should have the correct default input/output and token selection should work', () => {
|
||||
cy.visit('/swap', { featureFlags: [FeatureFlag.swapWidget] }).then(() => {
|
||||
cy.wait('@eth_blockNumber')
|
||||
verifyInputToken('ETH')
|
||||
verifyOutputToken('Select token')
|
||||
|
||||
selectOutputAndSwitch('WETH')
|
||||
|
||||
verifyInputToken('WETH')
|
||||
verifyOutputToken('ETH')
|
||||
})
|
||||
})
|
||||
|
||||
it('should have the correct default input from URL params ', () => {
|
||||
cy.visit(`/swap?inputCurrency=${WETH_GOERLI}`, {
|
||||
featureFlags: [FeatureFlag.swapWidget],
|
||||
}).then(() => {
|
||||
cy.wait('@eth_blockNumber')
|
||||
})
|
||||
|
||||
verifyInputToken('WETH')
|
||||
verifyOutputToken('Select token')
|
||||
|
||||
selectOutputAndSwitch('Ether')
|
||||
|
||||
verifyInputToken('ETH')
|
||||
verifyOutputToken('WETH')
|
||||
})
|
||||
|
||||
it('should have the correct default output from URL params ', () => {
|
||||
cy.visit(`/swap?outputCurrency=${WETH_GOERLI}`, {
|
||||
featureFlags: [FeatureFlag.swapWidget],
|
||||
}).then(() => {
|
||||
cy.wait('@eth_blockNumber')
|
||||
})
|
||||
|
||||
verifyInputToken('Select token')
|
||||
verifyOutputToken('WETH')
|
||||
|
||||
cy.get(getClassContainsSelector('ReverseButton')).first().click()
|
||||
verifyInputToken('WETH')
|
||||
verifyOutputToken('Select token')
|
||||
|
||||
selectOutputAndSwitch('Ether')
|
||||
|
||||
verifyInputToken('ETH')
|
||||
verifyOutputToken('WETH')
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget on Token Detail Page', () => {
|
||||
beforeEach(() => {
|
||||
cy.viewport(1200, 800)
|
||||
cy.visit(`/tokens/ethereum/${UNI_GOERLI}`, { featureFlags: [FeatureFlag.swapWidget] }).then(() => {
|
||||
cy.wait('@eth_blockNumber')
|
||||
})
|
||||
})
|
||||
|
||||
it('should have the expected output for a tokens detail page', () => {
|
||||
verifyOutputToken('UNI')
|
||||
cy.contains('Connect to Ethereum').should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,202 +0,0 @@
|
||||
import { USDC_MAINNET } from '../../src/constants/tokens'
|
||||
import { WETH_GOERLI } from '../fixtures/constants'
|
||||
import { HardhatProvider } from '../support/hardhat'
|
||||
import { getTestSelector } from '../utils'
|
||||
|
||||
describe('Swap', () => {
|
||||
let hardhat: HardhatProvider
|
||||
const verifyAmount = (field: 'input' | 'output', amountText: string | null) => {
|
||||
if (amountText === null) {
|
||||
cy.get(`#swap-currency-${field} .token-amount-input`).should('not.have.value')
|
||||
} else {
|
||||
cy.get(`#swap-currency-${field} .token-amount-input`).should('have.value', amountText)
|
||||
}
|
||||
}
|
||||
|
||||
const verifyToken = (field: 'input' | 'output', tokenSymbol: string | null) => {
|
||||
if (tokenSymbol === null) {
|
||||
cy.get(`#swap-currency-${field} .token-symbol-container`).should('contain.text', 'Select token')
|
||||
} else {
|
||||
cy.get(`#swap-currency-${field} .token-symbol-container`).should('contain.text', tokenSymbol)
|
||||
}
|
||||
}
|
||||
|
||||
const selectOutput = (tokenSymbol: string) => {
|
||||
// open token selector...
|
||||
cy.contains('Select token').click()
|
||||
// select token...
|
||||
cy.contains(tokenSymbol).click()
|
||||
|
||||
cy.get('body')
|
||||
.then(($body) => {
|
||||
if ($body.find(getTestSelector('TokenSafetyWrapper')).length) {
|
||||
return 'I understand'
|
||||
}
|
||||
|
||||
return 'no-op' // Don't click on anything, a no-op
|
||||
})
|
||||
.then((content) => {
|
||||
if (content !== 'no-op') {
|
||||
cy.contains(content).click()
|
||||
}
|
||||
})
|
||||
|
||||
// token selector should close...
|
||||
cy.contains('Search name or paste address').should('not.exist')
|
||||
}
|
||||
|
||||
before(() => {
|
||||
cy.visit('/swap', { ethereum: 'hardhat' }).then((window) => {
|
||||
hardhat = window.hardhat
|
||||
})
|
||||
})
|
||||
|
||||
it('starts with ETH selected by default', () => {
|
||||
verifyAmount('input', '')
|
||||
verifyToken('input', 'ETH')
|
||||
verifyAmount('output', null)
|
||||
verifyToken('output', null)
|
||||
})
|
||||
|
||||
it('can enter an amount into input', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.001').should('have.value', '0.001')
|
||||
})
|
||||
|
||||
it('zero swap amount', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.0').should('have.value', '0.0')
|
||||
})
|
||||
|
||||
it('invalid swap amount', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('\\').should('have.value', '')
|
||||
})
|
||||
|
||||
it('can enter an amount into output', () => {
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('0.001').should('have.value', '0.001')
|
||||
})
|
||||
|
||||
it('zero output amount', () => {
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('0.0').should('have.value', '0.0')
|
||||
})
|
||||
|
||||
it('can swap ETH for USDC', () => {
|
||||
const TOKEN_ADDRESS = USDC_MAINNET.address
|
||||
const BALANCE_INCREMENT = 1
|
||||
cy.visit('/swap', { ethereum: 'hardhat' })
|
||||
.then((window) => {
|
||||
hardhat = window.hardhat
|
||||
})
|
||||
.then(() => hardhat.utils.getBalance(hardhat.wallet.address, USDC_MAINNET))
|
||||
.then((balance) => Number(balance.toFixed(1)))
|
||||
.then((initialBalance) => {
|
||||
cy.get('#swap-currency-output .open-currency-select-button').click()
|
||||
cy.get(getTestSelector('token-search-input')).clear().type(TOKEN_ADDRESS)
|
||||
cy.contains('USDC').click()
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type(BALANCE_INCREMENT.toString())
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
|
||||
|
||||
cy.then(() => hardhat.send('hardhat_mine', ['0x1', '0xc'])).then(() => {
|
||||
// ui check
|
||||
cy.get('#swap-currency-output [data-testid="balance-text"]').should(
|
||||
'have.text',
|
||||
`Balance: ${initialBalance + BALANCE_INCREMENT}`
|
||||
)
|
||||
|
||||
// chain state check
|
||||
cy.then(() => hardhat.utils.getBalance(hardhat.wallet.address, USDC_MAINNET))
|
||||
.then((balance) => Number(balance.toFixed(1)))
|
||||
.should('eq', initialBalance + BALANCE_INCREMENT)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should have the correct default input/output and token selection should work', () => {
|
||||
cy.visit('/swap')
|
||||
verifyToken('input', 'ETH')
|
||||
verifyToken('output', null)
|
||||
|
||||
selectOutput('WETH')
|
||||
cy.get(getTestSelector('swap-currency-button')).first().click()
|
||||
|
||||
verifyToken('input', 'WETH')
|
||||
verifyToken('output', 'ETH')
|
||||
})
|
||||
|
||||
it('should have the correct default input from URL params ', () => {
|
||||
cy.visit(`/swap?inputCurrency=${WETH_GOERLI}`)
|
||||
|
||||
verifyToken('input', 'WETH')
|
||||
verifyToken('output', null)
|
||||
|
||||
selectOutput('Ether')
|
||||
cy.get(getTestSelector('swap-currency-button')).first().click()
|
||||
|
||||
verifyToken('input', 'ETH')
|
||||
verifyToken('output', 'WETH')
|
||||
})
|
||||
|
||||
it('should have the correct default output from URL params ', () => {
|
||||
cy.visit(`/swap?outputCurrency=${WETH_GOERLI}`)
|
||||
|
||||
verifyToken('input', null)
|
||||
verifyToken('output', 'WETH')
|
||||
|
||||
cy.get(getTestSelector('swap-currency-button')).first().click()
|
||||
verifyToken('input', 'WETH')
|
||||
verifyToken('output', null)
|
||||
|
||||
selectOutput('Ether')
|
||||
cy.get(getTestSelector('swap-currency-button')).first().click()
|
||||
|
||||
verifyToken('input', 'ETH')
|
||||
verifyToken('output', 'WETH')
|
||||
})
|
||||
|
||||
it('ETH to wETH is same value (wrapped swaps have no price impact)', () => {
|
||||
cy.visit('/swap')
|
||||
selectOutput('WETH')
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.01')
|
||||
cy.get('#swap-currency-output .token-amount-input').should('have.value', '0.01')
|
||||
})
|
||||
|
||||
it('should render and dismiss the wallet rejection modal', () => {
|
||||
cy.visit('/swap', { ethereum: 'hardhat' }).then((window) => {
|
||||
hardhat = window.hardhat
|
||||
cy.stub(hardhat.wallet, 'sendTransaction').rejects(new Error('user cancelled'))
|
||||
|
||||
cy.get('#swap-currency-output .open-currency-select-button').click()
|
||||
cy.get(getTestSelector('token-search-input')).clear().type(USDC_MAINNET.address)
|
||||
cy.contains('USDC').click()
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('1')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
cy.contains('Transaction rejected').should('exist')
|
||||
cy.contains('Dismiss').click()
|
||||
cy.contains('Transaction rejected').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('Opens and closes the settings menu', () => {
|
||||
cy.visit('/swap')
|
||||
cy.contains('Settings').should('not.exist')
|
||||
cy.get(getTestSelector('swap-settings-button')).click()
|
||||
cy.contains('Slippage tolerance').should('exist')
|
||||
cy.contains('Transaction deadline').should('exist')
|
||||
cy.contains('Auto Router API').should('exist')
|
||||
cy.contains('Expert Mode').should('exist')
|
||||
cy.get(getTestSelector('swap-settings-button')).click()
|
||||
cy.contains('Settings').should('not.exist')
|
||||
})
|
||||
|
||||
it('inputs reset when navigating between pages', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.01')
|
||||
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
|
||||
cy.visit('/pool')
|
||||
cy.visit('/swap')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('have.value', '')
|
||||
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
|
||||
})
|
||||
})
|
||||
89
cypress/e2e/swap/errors.test.ts
Normal file
89
cypress/e2e/swap/errors.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { BigNumber } from '@ethersproject/bignumber'
|
||||
import { parseEther } from '@ethersproject/units'
|
||||
|
||||
import { USDC_MAINNET } from '../../../src/constants/tokens'
|
||||
import { getTestSelector } from '../../utils'
|
||||
|
||||
describe('Swap', () => {
|
||||
it('should render and dismiss the wallet rejection modal', () => {
|
||||
cy.visit('/swap', { ethereum: 'hardhat' })
|
||||
.hardhat()
|
||||
.then((hardhat) => {
|
||||
cy.stub(hardhat.wallet, 'sendTransaction').log(false).rejects(new Error('user cancelled'))
|
||||
|
||||
cy.get('#swap-currency-output .open-currency-select-button').click()
|
||||
cy.get(getTestSelector('token-search-input')).clear().type(USDC_MAINNET.address)
|
||||
cy.contains('USDC').click()
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('1')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
cy.contains('Transaction rejected').should('exist')
|
||||
cy.contains('Dismiss').click()
|
||||
cy.contains('Transaction rejected').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
it.skip('should render an error for slippage failure', () => {
|
||||
cy.visit('/swap', { ethereum: 'hardhat' })
|
||||
.hardhat({ automine: false })
|
||||
.then((hardhat) => {
|
||||
cy.then(() => hardhat.provider.getBalance(hardhat.wallet.address)).then((initialBalance) => {
|
||||
// Gas estimation fails for this transaction (that would normally fail), so we stub it.
|
||||
const send = cy.stub(hardhat.provider, 'send').log(false)
|
||||
send.withArgs('eth_estimateGas').resolves(BigNumber.from(2_000_000))
|
||||
send.callThrough()
|
||||
|
||||
// Set slippage to a very low value.
|
||||
cy.get(getTestSelector('open-settings-dialog-button')).click()
|
||||
cy.get(getTestSelector('max-slippage-settings')).click()
|
||||
cy.get(getTestSelector('slippage-input')).clear().type('0.01')
|
||||
cy.get('body').click('topRight')
|
||||
cy.get(getTestSelector('slippage-input')).should('not.exist')
|
||||
|
||||
// Open the currency select modal.
|
||||
cy.get('#swap-currency-output .open-currency-select-button').click()
|
||||
|
||||
// Select UNI as output token
|
||||
cy.get(getTestSelector('token-search-input')).clear().type('Uniswap')
|
||||
cy.get(getTestSelector('currency-list-wrapper'))
|
||||
.contains(/^Uniswap$/)
|
||||
.first()
|
||||
// Our scrolling library (react-window) seems to freeze when acted on by cypress, with this element set to
|
||||
// `pointer-events: none`. This can be ignored using `{force: true}`.
|
||||
.click({ force: true })
|
||||
|
||||
// Swap 2 times.
|
||||
const AMOUNT_TO_SWAP = 400
|
||||
const NUMBER_OF_SWAPS = 2
|
||||
const INDIVIDUAL_SWAP_INPUT = AMOUNT_TO_SWAP / NUMBER_OF_SWAPS
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type(INDIVIDUAL_SWAP_INPUT.toString())
|
||||
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type(INDIVIDUAL_SWAP_INPUT.toString())
|
||||
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
|
||||
|
||||
// The pending transaction indicator should be visible.
|
||||
cy.contains('Pending').should('exist')
|
||||
|
||||
cy.then(() => hardhat.mine()).then(() => {
|
||||
// The pending transaction indicator should not be visible.
|
||||
cy.contains('Pending').should('not.exist')
|
||||
|
||||
// Check for a failed transaction notification.
|
||||
cy.contains('Swap failed').should('exist')
|
||||
|
||||
// Assert that at least one of the swaps failed due to slippage.
|
||||
cy.then(() => hardhat.provider.getBalance(hardhat.wallet.address)).then((finalBalance) => {
|
||||
expect(finalBalance.gt(initialBalance.sub(parseEther(AMOUNT_TO_SWAP.toString())))).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
168
cypress/e2e/swap/swap.test.ts
Normal file
168
cypress/e2e/swap/swap.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { SupportedChainId } from '@uniswap/sdk-core'
|
||||
|
||||
import { UNI, USDC_MAINNET } from '../../../src/constants/tokens'
|
||||
import { getTestSelector } from '../../utils'
|
||||
|
||||
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
|
||||
|
||||
describe('Swap', () => {
|
||||
describe('Swap on main page', () => {
|
||||
before(() => {
|
||||
cy.visit('/swap', { ethereum: 'hardhat' })
|
||||
})
|
||||
|
||||
it('starts with ETH selected by default', () => {
|
||||
cy.get(`#swap-currency-input .token-amount-input`).should('have.value', '')
|
||||
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'ETH')
|
||||
cy.get(`#swap-currency-output .token-amount-input`).should('not.have.value')
|
||||
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'Select token')
|
||||
})
|
||||
|
||||
it('can enter an amount into input', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.001').should('have.value', '0.001')
|
||||
})
|
||||
|
||||
it('zero swap amount', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.0').should('have.value', '0.0')
|
||||
})
|
||||
|
||||
it('invalid swap amount', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('\\').should('have.value', '')
|
||||
})
|
||||
|
||||
it('can enter an amount into output', () => {
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('0.001').should('have.value', '0.001')
|
||||
})
|
||||
|
||||
it('zero output amount', () => {
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('0.0').should('have.value', '0.0')
|
||||
})
|
||||
|
||||
it('should render an error when a transaction fails due to a passed deadline', () => {
|
||||
const DEADLINE_MINUTES = 1
|
||||
const TEN_MINUTES_MS = 1000 * 60 * DEADLINE_MINUTES * 10
|
||||
cy.visit('/swap', { ethereum: 'hardhat' })
|
||||
.hardhat({ automine: false })
|
||||
.then((hardhat) => {
|
||||
cy.then(() => hardhat.getBalance(hardhat.wallet.address, USDC_MAINNET))
|
||||
.then((balance) => Number(balance.toFixed(1)))
|
||||
.then((initialBalance) => {
|
||||
// Input swap info.
|
||||
cy.get('#swap-currency-output .open-currency-select-button').click()
|
||||
cy.contains('USDC').click()
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('1')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
|
||||
|
||||
// Set deadline to minimum. (1 minute)
|
||||
cy.get(getTestSelector('open-settings-dialog-button')).click()
|
||||
cy.get(getTestSelector('transaction-deadline-settings')).click()
|
||||
cy.get(getTestSelector('deadline-input')).clear().type(DEADLINE_MINUTES.toString())
|
||||
cy.get('body').click('topRight')
|
||||
cy.get(getTestSelector('deadline-input')).should('not.exist')
|
||||
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
|
||||
// Dismiss the modal that appears when a transaction is broadcast to the network.
|
||||
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
|
||||
|
||||
// The UI should show the transaction as pending.
|
||||
cy.contains('1 Pending').should('exist')
|
||||
|
||||
// Mine a block past the deadline.
|
||||
cy.then(() => hardhat.mine(1, TEN_MINUTES_MS)).then(() => {
|
||||
// The UI should no longer show the transaction as pending.
|
||||
cy.contains('1 Pending').should('not.exist')
|
||||
|
||||
// Check that the user is informed of the failure
|
||||
cy.contains('Swap failed').should('exist')
|
||||
|
||||
// Check that the balance is unchanged in the UI
|
||||
cy.get('#swap-currency-output [data-testid="balance-text"]').should(
|
||||
'have.text',
|
||||
`Balance: ${initialBalance}`
|
||||
)
|
||||
|
||||
// Check that the balance is unchanged on chain
|
||||
cy.then(() => hardhat.getBalance(hardhat.wallet.address, USDC_MAINNET))
|
||||
.then((balance) => Number(balance.toFixed(1)))
|
||||
.should('eq', initialBalance)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should default inputs from URL params ', () => {
|
||||
cy.visit(`/swap?inputCurrency=${UNI_MAINNET.address}`, { ethereum: 'hardhat' })
|
||||
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'UNI')
|
||||
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'Select token')
|
||||
|
||||
cy.visit(`/swap?outputCurrency=${UNI_MAINNET.address}`, { ethereum: 'hardhat' })
|
||||
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'Select token')
|
||||
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
|
||||
|
||||
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${UNI_MAINNET.address}`, { ethereum: 'hardhat' })
|
||||
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'ETH')
|
||||
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
|
||||
})
|
||||
|
||||
it('ETH to wETH is same value (wrapped swaps have no price impact)', () => {
|
||||
cy.visit('/swap')
|
||||
cy.get(`#swap-currency-output .open-currency-select-button`).click()
|
||||
cy.contains('WETH').click()
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.01')
|
||||
cy.get('#swap-currency-output .token-amount-input').should('have.value', '0.01')
|
||||
})
|
||||
|
||||
it('Opens and closes the settings menu', () => {
|
||||
cy.visit('/swap')
|
||||
cy.contains('Settings').should('not.exist')
|
||||
cy.get(getTestSelector('open-settings-dialog-button')).click()
|
||||
cy.contains('Max slippage').should('exist')
|
||||
cy.contains('Transaction deadline').should('exist')
|
||||
cy.contains('Auto Router API').should('exist')
|
||||
cy.get(getTestSelector('open-settings-dialog-button')).click()
|
||||
cy.contains('Settings').should('not.exist')
|
||||
})
|
||||
|
||||
it('inputs reset when navigating between pages', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.01')
|
||||
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
|
||||
cy.visit('/pool')
|
||||
cy.visit('/swap')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('have.value', '')
|
||||
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
|
||||
})
|
||||
|
||||
it('can swap ETH for USDC', () => {
|
||||
cy.visit('/swap', { ethereum: 'hardhat' })
|
||||
const TOKEN_ADDRESS = USDC_MAINNET.address
|
||||
const BALANCE_INCREMENT = 1
|
||||
cy.hardhat().then((hardhat) => {
|
||||
cy.then(() => hardhat.getBalance(hardhat.wallet.address, USDC_MAINNET))
|
||||
.then((balance) => Number(balance.toFixed(1)))
|
||||
.then((initialBalance) => {
|
||||
cy.get('#swap-currency-output .open-currency-select-button').click()
|
||||
cy.get(getTestSelector('token-search-input')).clear().type(TOKEN_ADDRESS)
|
||||
cy.contains('USDC').click()
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type(BALANCE_INCREMENT.toString())
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
|
||||
|
||||
// ui check
|
||||
cy.get('#swap-currency-output [data-testid="balance-text"]').should(
|
||||
'have.text',
|
||||
`Balance: ${initialBalance + BALANCE_INCREMENT}`
|
||||
)
|
||||
|
||||
// chain state check
|
||||
cy.then(() => hardhat.getBalance(hardhat.wallet.address, USDC_MAINNET))
|
||||
.then((balance) => Number(balance.toFixed(1)))
|
||||
.should('eq', initialBalance + BALANCE_INCREMENT)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
111
cypress/e2e/swap/wrap.test.ts
Normal file
111
cypress/e2e/swap/wrap.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { SupportedChainId, WETH9 } from '@uniswap/sdk-core'
|
||||
|
||||
import { getTestSelector } from '../../utils'
|
||||
|
||||
describe('Swap', () => {
|
||||
it('should be able to wrap ETH', () => {
|
||||
const BALANCE_INCREMENT = 1
|
||||
cy.visit('/swap', { ethereum: 'hardhat' })
|
||||
.hardhat()
|
||||
.then((hardhat) => {
|
||||
cy.then(() => hardhat.getBalance(hardhat.wallet.address, WETH9[SupportedChainId.MAINNET]))
|
||||
.then((balance) => Number(balance.toFixed(1)))
|
||||
.then((initialWethBalance) => {
|
||||
// Select WETH for the token output.
|
||||
cy.get('#swap-currency-output .open-currency-select-button').click()
|
||||
cy.contains('WETH').click()
|
||||
|
||||
// Enter the amount to wrap.
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type(BALANCE_INCREMENT.toString())
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
|
||||
|
||||
// Click the wrap button.
|
||||
cy.get(getTestSelector('wrap-button')).should('not.be.disabled')
|
||||
cy.get(getTestSelector('wrap-button')).click()
|
||||
|
||||
// The pending transaction indicator should be visible.
|
||||
cy.get(getTestSelector('web3-status-connected')).should('have.descendants', ':contains("1 Pending")')
|
||||
|
||||
// <automine transaction>
|
||||
|
||||
// The pending transaction indicator should be gone.
|
||||
cy.get(getTestSelector('web3-status-connected')).should('not.have.descendants', ':contains("1 Pending")')
|
||||
|
||||
// The UI balance should have increased.
|
||||
cy.get('#swap-currency-output [data-testid="balance-text"]').should(
|
||||
'have.text',
|
||||
`Balance: ${initialWethBalance + BALANCE_INCREMENT}`
|
||||
)
|
||||
|
||||
// There should be a successful wrap notification.
|
||||
cy.contains('Wrapped').should('exist')
|
||||
|
||||
// The user's WETH account balance should have increased
|
||||
cy.then(() => hardhat.getBalance(hardhat.wallet.address, WETH9[SupportedChainId.MAINNET]))
|
||||
.then((balance) => Number(balance.toFixed(1)))
|
||||
.should('eq', initialWethBalance + BALANCE_INCREMENT)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should be able to unwrap WETH', () => {
|
||||
const BALANCE_INCREMENT = 1
|
||||
cy.visit('/swap', { ethereum: 'hardhat' })
|
||||
.hardhat()
|
||||
.then((hardhat) => {
|
||||
cy.then(() => hardhat.getBalance(hardhat.wallet.address, WETH9[SupportedChainId.MAINNET])).then(
|
||||
(initialBalance) => {
|
||||
// Select WETH for the token output.
|
||||
cy.get('#swap-currency-output .open-currency-select-button').click()
|
||||
cy.contains('WETH').click()
|
||||
|
||||
// Enter the amount to wrap.
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type(BALANCE_INCREMENT.toString())
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
|
||||
|
||||
// Click the wrap button.
|
||||
cy.get(getTestSelector('wrap-button')).should('not.be.disabled')
|
||||
cy.get(getTestSelector('wrap-button')).click()
|
||||
|
||||
// <automine transaction>
|
||||
|
||||
// The pending transaction indicator should be visible.
|
||||
cy.contains('1 Pending').should('exist')
|
||||
// The user should see a notification telling them they successfully wrapped their ETH.
|
||||
cy.contains('Wrapped').should('exist')
|
||||
|
||||
// Switch to unwrapping the ETH we just wrapped.
|
||||
cy.get(getTestSelector('swap-currency-button')).click()
|
||||
cy.get(getTestSelector('wrap-button')).should('not.be.disabled')
|
||||
|
||||
// Click the Unwrap button.
|
||||
cy.get(getTestSelector('wrap-button')).click()
|
||||
|
||||
// The pending transaction indicator should be visible.
|
||||
cy.contains('1 Pending').should('exist')
|
||||
|
||||
// <automine transaction>
|
||||
|
||||
// The pending transaction indicator should be gone.
|
||||
cy.contains('1 Pending').should('not.exist')
|
||||
// The user should see a notification telling them they successfully unwrapped their ETH.
|
||||
cy.contains('Unwrapped').should('exist')
|
||||
|
||||
// The UI balance should have decreased.
|
||||
cy.get('#swap-currency-input [data-testid="balance-text"]').should(
|
||||
'have.text',
|
||||
`Balance: ${initialBalance.toFixed(0)}`
|
||||
)
|
||||
|
||||
// There should be a successful unwrap notification.
|
||||
cy.contains('Unwrapped').should('exist')
|
||||
|
||||
// The user's WETH account balance should not have changed from the initial balance
|
||||
cy.then(() => hardhat.getBalance(hardhat.wallet.address, WETH9[SupportedChainId.MAINNET]))
|
||||
.then((balance) => balance.toFixed(0))
|
||||
.should('eq', initialBalance.toFixed(0))
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,15 @@
|
||||
import { getClassContainsSelector, getTestSelector } from '../utils'
|
||||
import { SupportedChainId, WETH9 } from '@uniswap/sdk-core'
|
||||
|
||||
import { UNI } from '../../src/constants/tokens'
|
||||
import { getTestSelector } from '../utils'
|
||||
|
||||
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
|
||||
|
||||
const UNI_ADDRESS = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'
|
||||
|
||||
describe('Token details', () => {
|
||||
before(() => {
|
||||
cy.visit('/')
|
||||
beforeEach(() => {
|
||||
cy.viewport(1440, 900)
|
||||
})
|
||||
|
||||
it('Uniswap token should have all information populated', () => {
|
||||
@@ -40,9 +45,6 @@ describe('Token details', () => {
|
||||
|
||||
// Contract address should be displayed
|
||||
cy.contains(UNI_ADDRESS).should('exist')
|
||||
|
||||
// Swap widget should have this token pre-selected as the “destination” token
|
||||
cy.get(getTestSelector('token-select')).should('include.text', 'UNI')
|
||||
})
|
||||
|
||||
it('token with warning and low trading volume should have all information populated', () => {
|
||||
@@ -81,36 +83,75 @@ describe('Token details', () => {
|
||||
// Contract address should be displayed
|
||||
cy.contains('0xa71d0588EAf47f12B13cF8eC750430d21DF04974').should('exist')
|
||||
|
||||
// Swap widget should have this token pre-selected as the “destination” token
|
||||
cy.get(getTestSelector('token-select')).should('include.text', 'QOM')
|
||||
|
||||
// Warning label should show if relevant ([spec](https://www.notion.so/3f7fce6f93694be08a94a6984d50298e))
|
||||
cy.get('[data-cy="token-safety-message"]')
|
||||
.should('include.text', 'Warning')
|
||||
.and('include.text', "This token isn't traded on leading U.S. centralized exchanges")
|
||||
})
|
||||
|
||||
describe('Swap on Token Detail Page', () => {
|
||||
const verifyOutputToken = (outputText: string) => {
|
||||
cy.get(getClassContainsSelector('TokenButtonRow')).last().contains(outputText)
|
||||
}
|
||||
|
||||
describe('swapping', () => {
|
||||
beforeEach(() => {
|
||||
// On mobile widths, we just link back to /swap instead of rendering the swap component.
|
||||
cy.viewport(1200, 800)
|
||||
cy.visit(`/tokens/goerli/${UNI_ADDRESS}`).then(() => {
|
||||
cy.visit(`/tokens/ethereum/${UNI_MAINNET.address}`, {
|
||||
ethereum: 'hardhat',
|
||||
}).then(() => {
|
||||
cy.wait('@eth_blockNumber')
|
||||
cy.scrollTo('top')
|
||||
})
|
||||
})
|
||||
|
||||
it('should have the expected output for a tokens detail page', () => {
|
||||
verifyOutputToken('UNI')
|
||||
cy.get(`#swap-currency-input .token-amount-input`).should('have.value', '')
|
||||
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'Select token')
|
||||
cy.get(`#swap-currency-output .token-amount-input`).should('not.have.value')
|
||||
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
|
||||
})
|
||||
|
||||
it('should not share swap state with the main swap page', () => {
|
||||
verifyOutputToken('UNI')
|
||||
it('should automatically navigate to the new TDP', () => {
|
||||
cy.get(`#swap-currency-output .open-currency-select-button`).click()
|
||||
cy.contains('WETH').click()
|
||||
cy.url().should('include', `${WETH9[1].address}`)
|
||||
cy.url().should('not.include', `${UNI_MAINNET.address}`)
|
||||
})
|
||||
|
||||
it.only('should not share swap state with the main swap page', () => {
|
||||
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
|
||||
cy.get(`#swap-currency-input .open-currency-select-button`).click()
|
||||
cy.contains('WETH').click()
|
||||
cy.visit('/swap')
|
||||
cy.contains('UNI').should('not.exist')
|
||||
cy.contains('WETH').should('not.exist')
|
||||
})
|
||||
|
||||
it('can enter an amount into input', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.001').should('have.value', '0.001')
|
||||
})
|
||||
|
||||
it('zero swap amount', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.0').should('have.value', '0.0')
|
||||
})
|
||||
|
||||
it('invalid swap amount', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('\\').should('have.value', '')
|
||||
})
|
||||
|
||||
it('can enter an amount into output', () => {
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('0.001').should('have.value', '0.001')
|
||||
})
|
||||
|
||||
it('zero output amount', () => {
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('0.0').should('have.value', '0.0')
|
||||
})
|
||||
|
||||
it('should show a L2 token even if the user is connected to a different network', () => {
|
||||
cy.visit('/tokens', { ethereum: 'hardhat' })
|
||||
cy.get(getTestSelector('tokens-network-filter-selected')).click()
|
||||
cy.get(getTestSelector('tokens-network-filter-option-arbitrum')).click()
|
||||
cy.get(getTestSelector('tokens-network-filter-selected')).should('contain', 'Arbitrum')
|
||||
cy.get(getTestSelector('token-table-row-ARB')).click()
|
||||
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'ARB')
|
||||
cy.contains('Connect to Arbitrum').should('exist')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const WETH_GOERLI = '0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6'
|
||||
80
cypress/support/commands.ts
Normal file
80
cypress/support/commands.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'cypress-hardhat/lib/browser'
|
||||
|
||||
import { Eip1193Bridge } from '@ethersproject/experimental/lib/eip1193-bridge'
|
||||
|
||||
import { FeatureFlag } from '../../src/featureFlags/flags/featureFlags'
|
||||
import { UserState } from '../../src/state/user/reducer'
|
||||
import { CONNECTED_WALLET_USER_STATE } from '../utils/user-state'
|
||||
import { injected } from './ethereum'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Cypress {
|
||||
interface ApplicationWindow {
|
||||
ethereum: Eip1193Bridge
|
||||
}
|
||||
interface VisitOptions {
|
||||
serviceWorker?: true
|
||||
featureFlags?: Array<FeatureFlag>
|
||||
/**
|
||||
* The mock ethereum provider to inject into the page.
|
||||
* @default 'goerli'
|
||||
*/
|
||||
// TODO(INFRA-175): Migrate all usage of 'goerli' to 'hardhat'.
|
||||
ethereum?: 'goerli' | 'hardhat'
|
||||
/**
|
||||
* Initial user state.
|
||||
* @default {@type import('../utils/user-state').CONNECTED_WALLET_USER_STATE}
|
||||
*/
|
||||
userState?: Partial<UserState>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sets up the injected provider to be a mock ethereum provider with the given mnemonic/index
|
||||
// eslint-disable-next-line no-undef
|
||||
Cypress.Commands.overwrite(
|
||||
'visit',
|
||||
(original, url: string | Partial<Cypress.VisitOptions>, options?: Partial<Cypress.VisitOptions>) => {
|
||||
if (typeof url !== 'string') throw new Error('Invalid arguments. The first argument to cy.visit must be the path.')
|
||||
|
||||
// Add a hash in the URL if it is not present (to use hash-based routing correctly with queryParams).
|
||||
let hashUrl = url.startsWith('/') && url.length > 2 && !url.startsWith('/#') ? `/#${url}` : url
|
||||
if (options?.ethereum === 'goerli') hashUrl += `${url.includes('?') ? '&' : '?'}chain=goerli`
|
||||
|
||||
return cy
|
||||
.intercept('/service-worker.js', options?.serviceWorker ? undefined : { statusCode: 404 })
|
||||
.provider()
|
||||
.then((provider) =>
|
||||
original({
|
||||
...options,
|
||||
url: hashUrl,
|
||||
onBeforeLoad(win) {
|
||||
options?.onBeforeLoad?.(win)
|
||||
|
||||
// We want to test from a clean state, so we clear the local storage (which clears redux).
|
||||
win.localStorage.clear()
|
||||
|
||||
// Set initial user state.
|
||||
win.localStorage.setItem(
|
||||
'redux_localstorage_simple_user', // storage key for the user reducer using 'redux-localstorage-simple'
|
||||
JSON.stringify(options?.userState ?? CONNECTED_WALLET_USER_STATE)
|
||||
)
|
||||
|
||||
// Set feature flags, if configured.
|
||||
if (options?.featureFlags) {
|
||||
const featureFlags = options.featureFlags.reduce((flags, flag) => ({ ...flags, [flag]: 'enabled' }), {})
|
||||
win.localStorage.setItem('featureFlags', JSON.stringify(featureFlags))
|
||||
}
|
||||
|
||||
// Inject the mock ethereum provider.
|
||||
if (options?.ethereum === 'hardhat') {
|
||||
win.ethereum = provider
|
||||
} else {
|
||||
win.ethereum = injected
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -6,120 +6,17 @@
|
||||
// ***********************************************************
|
||||
|
||||
import '@cypress/code-coverage/support'
|
||||
import './commands'
|
||||
import './setupTests'
|
||||
|
||||
import { Eip1193Bridge } from '@ethersproject/experimental/lib/eip1193-bridge'
|
||||
import assert from 'assert'
|
||||
import { Network } from 'cypress-hardhat/lib/browser'
|
||||
|
||||
import { FeatureFlag } from '../../src/featureFlags/flags/featureFlags'
|
||||
import { UserState } from '../../src/state/user/reducer'
|
||||
import { CONNECTED_WALLET_USER_STATE } from '../utils/user-state'
|
||||
import { injected } from './ethereum'
|
||||
import { HardhatProvider } from './hardhat'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Cypress {
|
||||
interface ApplicationWindow {
|
||||
ethereum: Eip1193Bridge
|
||||
hardhat: HardhatProvider
|
||||
}
|
||||
interface VisitOptions {
|
||||
serviceWorker?: true
|
||||
featureFlags?: Array<FeatureFlag>
|
||||
/**
|
||||
* The mock ethereum provider to inject into the page.
|
||||
* @default 'goerli'
|
||||
*/
|
||||
// TODO(INFRA-175): Migrate all usage of 'goerli' to 'hardhat'.
|
||||
ethereum?: 'goerli' | 'hardhat'
|
||||
/**
|
||||
* Initial user state.
|
||||
* @default {@type import('../utils/user-state').CONNECTED_WALLET_USER_STATE}
|
||||
*/
|
||||
userState?: Partial<UserState>
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface Chainable<Subject> {
|
||||
task(event: 'hardhat'): Chainable<Network>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sets up the injected provider to be a mock ethereum provider with the given mnemonic/index
|
||||
// eslint-disable-next-line no-undef
|
||||
Cypress.Commands.overwrite(
|
||||
'visit',
|
||||
(original, url: string | Partial<Cypress.VisitOptions>, options?: Partial<Cypress.VisitOptions>) => {
|
||||
assert(typeof url === 'string')
|
||||
|
||||
// Add a hash in the URL if it is not present (to use hash-based routing correctly with queryParams).
|
||||
let hashUrl = url.startsWith('/') && url.length > 2 && !url.startsWith('/#') ? `/#${url}` : url
|
||||
if (options?.ethereum === 'goerli') hashUrl += `${url.includes('?') ? '&' : '?'}chain=goerli`
|
||||
|
||||
return cy
|
||||
.intercept('/service-worker.js', options?.serviceWorker ? undefined : { statusCode: 404 })
|
||||
.task('hardhat')
|
||||
.then((network) =>
|
||||
original({
|
||||
...options,
|
||||
url: hashUrl,
|
||||
onBeforeLoad(win) {
|
||||
options?.onBeforeLoad?.(win)
|
||||
|
||||
// We want to test from a clean state, so we clear the local storage (which clears redux).
|
||||
win.localStorage.clear()
|
||||
|
||||
// Set initial user state.
|
||||
win.localStorage.setItem(
|
||||
'redux_localstorage_simple_user', // storage key for the user reducer using 'redux-localstorage-simple'
|
||||
JSON.stringify(options?.userState ?? CONNECTED_WALLET_USER_STATE)
|
||||
)
|
||||
|
||||
// Set feature flags, if configured.
|
||||
if (options?.featureFlags) {
|
||||
const featureFlags = options.featureFlags.reduce((flags, flag) => ({ ...flags, [flag]: 'enabled' }), {})
|
||||
win.localStorage.setItem('featureFlags', JSON.stringify(featureFlags))
|
||||
}
|
||||
|
||||
// Inject the mock ethereum provider.
|
||||
if (options?.ethereum === 'hardhat') {
|
||||
// The provider is exposed via hardhat to allow mocking / network manipulation.
|
||||
win.hardhat = new HardhatProvider(network)
|
||||
win.ethereum = win.hardhat
|
||||
} else {
|
||||
win.ethereum = injected
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
// Infura security policies are based on Origin headers.
|
||||
// These are stripped by cypress because chromeWebSecurity === false; this adds them back in.
|
||||
cy.intercept(/infura.io/, (res) => {
|
||||
res.headers['origin'] = 'http://localhost:3000'
|
||||
res.alias = res.body.method
|
||||
res.continue()
|
||||
})
|
||||
|
||||
// Graphql security policies are based on Origin headers.
|
||||
// These are stripped by cypress because chromeWebSecurity === false; this adds them back in.
|
||||
cy.intercept('https://api.uniswap.org/v1/graphql', (res) => {
|
||||
res.headers['origin'] = 'https://app.uniswap.org'
|
||||
res.continue()
|
||||
})
|
||||
cy.intercept('https://beta.api.uniswap.org/v1/graphql', (res) => {
|
||||
res.headers['origin'] = 'https://app.uniswap.org'
|
||||
res.continue()
|
||||
})
|
||||
|
||||
cy.intercept('https://api.uniswap.org/v1/amplitude-proxy', (res) => {
|
||||
res.reply(JSON.stringify({}))
|
||||
})
|
||||
})
|
||||
// Squelch logs from fetches, as they clutter the logs so much as to make them unusable.
|
||||
// See https://docs.cypress.io/api/commands/intercept#Disabling-logs-for-a-request.
|
||||
// TODO(https://github.com/cypress-io/cypress/issues/26069): Squelch only wildcard logs once Cypress allows it.
|
||||
const log = Cypress.log
|
||||
Cypress.log = function (options, ...args) {
|
||||
if (options.displayName === 'script' || options.name === 'request') return
|
||||
return log(options, ...args)
|
||||
} as typeof log
|
||||
|
||||
Cypress.on('uncaught:exception', () => {
|
||||
// returning false here prevents Cypress from failing the test
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Eip1193Bridge } from '@ethersproject/experimental/lib/eip1193-bridge'
|
||||
import { JsonRpcProvider } from '@ethersproject/providers'
|
||||
import { Wallet } from '@ethersproject/wallet'
|
||||
import { HardhatUtils, Network } from 'cypress-hardhat/lib/browser'
|
||||
|
||||
export class HardhatProvider extends Eip1193Bridge {
|
||||
readonly utils: HardhatUtils
|
||||
readonly chainId: string
|
||||
readonly wallet: Wallet
|
||||
|
||||
isMetaMask = true
|
||||
|
||||
constructor(network: Network) {
|
||||
const utils = new HardhatUtils(network)
|
||||
const wallet = new Wallet(utils.account.privateKey, utils.provider)
|
||||
super(wallet, utils.provider)
|
||||
|
||||
this.utils = utils
|
||||
this.chainId = `0x${network.chainId.toString(16)}`
|
||||
this.wallet = wallet
|
||||
}
|
||||
|
||||
async sendAsync(...args: any[]) {
|
||||
return this.send(...args)
|
||||
}
|
||||
|
||||
async send(...args: any[]) {
|
||||
console.debug('hardhat:send', ...args)
|
||||
|
||||
// Parse callback form.
|
||||
const isCallbackForm = typeof args[0] === 'object' && typeof args[1] === 'function'
|
||||
let callback = <T>(error: Error | null, result?: { result: T }) => {
|
||||
if (error) throw error
|
||||
return result?.result
|
||||
}
|
||||
let method
|
||||
let params
|
||||
if (isCallbackForm) {
|
||||
callback = args[1]
|
||||
method = args[0].method
|
||||
params = args[0].params
|
||||
} else {
|
||||
method = args[0]
|
||||
params = args[1]
|
||||
}
|
||||
|
||||
let result
|
||||
try {
|
||||
switch (method) {
|
||||
case 'eth_requestAccounts':
|
||||
case 'eth_accounts':
|
||||
result = [this.wallet.address]
|
||||
break
|
||||
case 'eth_chainId':
|
||||
result = this.chainId
|
||||
break
|
||||
case 'eth_sendTransaction': {
|
||||
// Eip1193Bridge doesn't support .gas and .from directly, so we massage it to satisfy ethers' expectations.
|
||||
// See https://github.com/ethers-io/ethers.js/issues/1683.
|
||||
params[0].gasLimit = params[0].gas
|
||||
delete params[0].gas
|
||||
delete params[0].from
|
||||
|
||||
const req = JsonRpcProvider.hexlifyTransaction(params[0])
|
||||
req.gasLimit = req.gas
|
||||
delete req.gas
|
||||
|
||||
result = (await this.signer.sendTransaction(req)).hash
|
||||
break
|
||||
}
|
||||
default:
|
||||
result = await super.send(method, params)
|
||||
}
|
||||
console.debug('hardhat:receive', method, result)
|
||||
return callback(null, { result })
|
||||
} catch (error) {
|
||||
console.debug('hardhat:error', method, error)
|
||||
return callback(error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
41
cypress/support/setupTests.ts
Normal file
41
cypress/support/setupTests.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// @ts-ignore
|
||||
import TokenListJSON from '@uniswap/default-token-list'
|
||||
|
||||
beforeEach(() => {
|
||||
// Many API calls enforce that requests come from our app, so we must mock Origin and Referer.
|
||||
cy.intercept('*', (req) => {
|
||||
req.headers['referer'] = 'https://app.uniswap.org'
|
||||
req.headers['origin'] = 'https://app.uniswap.org'
|
||||
})
|
||||
|
||||
// Infura uses a test endpoint, which allow-lists http://localhost:3000 instead.
|
||||
cy.intercept(/infura.io/, (req) => {
|
||||
req.headers['referer'] = 'http://localhost:3000'
|
||||
req.headers['origin'] = 'http://localhost:3000'
|
||||
req.alias = req.body.method
|
||||
req.continue()
|
||||
})
|
||||
|
||||
// Mock analytics responses to avoid analytics in tests.
|
||||
cy.intercept('https://api.uniswap.org/v1/amplitude-proxy', (req) => {
|
||||
const requestBody = JSON.stringify(req.body)
|
||||
const byteSize = new Blob([requestBody]).size
|
||||
req.reply(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
server_upload_time: Date.now(),
|
||||
payload_size_bytes: byteSize,
|
||||
events_ingested: req.body.events.length,
|
||||
})
|
||||
)
|
||||
}).intercept('https://*.sentry.io', { statusCode: 200 })
|
||||
|
||||
// Mock our own token list responses to avoid the latency of IPFS.
|
||||
cy.intercept('https://gateway.ipfs.io/ipns/tokens.uniswap.org', TokenListJSON)
|
||||
.intercept('https://gateway.ipfs.io/ipns/extendedtokens.uniswap.org', { statusCode: 404 })
|
||||
.intercept('https://gateway.ipfs.io/ipns/unsupportedtokens.uniswap.org', { statusCode: 404 })
|
||||
|
||||
// Reset hardhat between tests to ensure isolation.
|
||||
// This resets the fork, as well as options like automine.
|
||||
cy.hardhat().then((hardhat) => hardhat.reset())
|
||||
})
|
||||
@@ -1,5 +1,3 @@
|
||||
export const getTestSelector = (selectorId: string) => `[data-testid=${selectorId}]`
|
||||
|
||||
export const getTestSelectorStartsWith = (selectorId: string) => `[data-testid^=${selectorId}]`
|
||||
|
||||
export const getClassContainsSelector = (selectorId: string) => `[class*=${selectorId}]`
|
||||
|
||||
36
eslint_rules/enforce-retry-on-import.js
Normal file
36
eslint_rules/enforce-retry-on-import.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/* eslint-env node */
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'enforce use of retry() for dynamic imports',
|
||||
category: 'Best Practices',
|
||||
recommended: false,
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
ImportExpression(node) {
|
||||
const grandParent = node.parent.parent
|
||||
if (
|
||||
!(
|
||||
grandParent &&
|
||||
grandParent.type === 'CallExpression' &&
|
||||
// Technically, we are only checking that a function named `retry` wraps the dynamic import.
|
||||
// We do not go as far as enforcing that it is import('utils/retry').retry
|
||||
grandParent.callee.name === 'retry' &&
|
||||
grandParent.arguments.length === 1 &&
|
||||
grandParent.arguments[0].type === 'ArrowFunctionExpression'
|
||||
)
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
message: 'Dynamic import should be wrapped in retry (see `utils/retry.ts`): `retry(() => import(...))`',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-env node */
|
||||
|
||||
const defaultConfig = require('./graphql.config')
|
||||
const defaultConfig = require('./graphql.data.config')
|
||||
|
||||
module.exports = {
|
||||
src: defaultConfig.src,
|
||||
@@ -21,10 +21,9 @@ module.exports = {
|
||||
accounts: {
|
||||
count: 1,
|
||||
},
|
||||
// Disable auto-mining so that e2e tests can explicitly test pending states.
|
||||
mining: {
|
||||
auto: false,
|
||||
interval: 0,
|
||||
auto: true, // automine to make tests easier to write.
|
||||
interval: 0, // do not interval mine so that tests remain deterministic
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,8 +1,63 @@
|
||||
import { default as babelExtractor } from '@lingui/cli/api/extractors/babel'
|
||||
import { createHash } from 'crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as pkgUp from 'pkg-up' // pkg-up is used by lingui, and is used here to match lingui's own extractors
|
||||
|
||||
/**
|
||||
* A custom caching extractor for CI.
|
||||
* Falls back to the babelExtractor in a non-CI (ie local) environment.
|
||||
* Caches a file's latest extracted content's hash, and skips re-extracting if it is already present in the cache.
|
||||
* In CI, re-extracting files takes over one minute, so this is a significant savings.
|
||||
*/
|
||||
const cachingExtractor: typeof babelExtractor = {
|
||||
match(filename: string) {
|
||||
return babelExtractor.match(filename)
|
||||
},
|
||||
extract(filename: string, code: string, ...options: unknown[]) {
|
||||
if (!process.env.CI) return babelExtractor.extract(filename, code, ...options)
|
||||
|
||||
// This runs from node_modules/@lingui/conf, so we need to back out to the root.
|
||||
const pkg = pkgUp.sync()
|
||||
if (!pkg) throw new Error('No root found')
|
||||
const root = path.dirname(pkg)
|
||||
|
||||
const filePath = path.join(root, filename)
|
||||
const file = readFileSync(filePath)
|
||||
const hash = createHash('sha256').update(file).digest('hex')
|
||||
|
||||
const cacheRoot = path.join(root, 'node_modules/.cache/lingui')
|
||||
mkdirSync(cacheRoot, { recursive: true })
|
||||
const cachePath = path.join(cacheRoot, filename.replace(/\//g, '-'))
|
||||
|
||||
// Only read from the cache if we're not performing a "clean" run, as a clean run must re-extract from all
|
||||
// files to ensure that obsolete messages are removed.
|
||||
if (!process.argv.includes('--clean')) {
|
||||
try {
|
||||
const cache = readFileSync(cachePath, 'utf8')
|
||||
if (cache === hash) return
|
||||
} catch (e) {
|
||||
// It should not be considered an error if there is no cache file.
|
||||
}
|
||||
}
|
||||
writeFileSync(cachePath, hash)
|
||||
|
||||
return babelExtractor.extract(filename, code, ...options)
|
||||
},
|
||||
}
|
||||
|
||||
const linguiConfig = {
|
||||
catalogs: [
|
||||
{
|
||||
path: '<rootDir>/src/locales/{locale}',
|
||||
include: ['<rootDir>/src'],
|
||||
include: ['<rootDir>/src/**/*.ts', '<rootDir>/src/**/*.tsx'],
|
||||
exclude: [
|
||||
'<rootDir>/src/**/*.d.ts',
|
||||
'<rootDir>/src/**/*.test.*',
|
||||
'<rootDir>/src/types/v3/**',
|
||||
'<rootDir>/src/abis/types/**',
|
||||
'<rootDir>/src/graphql/**/__generated__/**',
|
||||
],
|
||||
},
|
||||
],
|
||||
compileNamespace: 'cjs',
|
||||
@@ -53,6 +108,7 @@ const linguiConfig = {
|
||||
runtimeConfigModule: ['@lingui/core', 'i18n'],
|
||||
sourceLocale: 'en-US',
|
||||
pseudoLocale: 'pseudo',
|
||||
extractors: [cachingExtractor],
|
||||
}
|
||||
|
||||
export default linguiConfig
|
||||
|
||||
54
package.json
54
package.json
@@ -5,18 +5,20 @@
|
||||
"homepage": ".",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"ajv": "node scripts/compile-ajv-validators.js",
|
||||
"contracts:compile:abi": "typechain --target ethers-v5 --out-dir src/abis/types \"./src/abis/**/*.json\"",
|
||||
"contracts:compile:v3": "typechain --target ethers-v5 --out-dir src/types/v3 \"./node_modules/@uniswap/**/artifacts/contracts/**/*[!dbg].json\"",
|
||||
"contracts:compile": "yarn contracts:compile:abi && yarn contracts:compile:v3",
|
||||
"contracts": "yarn contracts:compile:abi && yarn contracts:compile:v3",
|
||||
"graphql:fetch": "node scripts/fetch-schema.js",
|
||||
"graphql:generate:data": "graphql-codegen --config apollo-codegen.ts",
|
||||
"graphql:generate:thegraph": "graphql-codegen --config apollo-codegen_thegraph.ts",
|
||||
"graphql:generate:data": "graphql-codegen --config graphql.data.codegen.config.ts",
|
||||
"graphql:generate:thegraph": "graphql-codegen --config graphql.thegraph.codegen.config.ts",
|
||||
"graphql:generate": "yarn graphql:generate:data && yarn graphql:generate:thegraph",
|
||||
"prei18n:extract": "node scripts/prei18n-extract.js",
|
||||
"graphql": "yarn graphql:fetch && yarn graphql:generate",
|
||||
"i18n:extract": "lingui extract --locale en-US",
|
||||
"i18n:compile": "yarn i18n:extract && lingui compile",
|
||||
"i18n:pseudo": "lingui extract --locale pseudo && lingui compile",
|
||||
"prepare": "yarn contracts:compile && yarn graphql:fetch && yarn graphql:generate && yarn i18n:compile",
|
||||
"i18n:pseudo": "lingui extract --locale pseudo",
|
||||
"i18n:compile": "lingui compile",
|
||||
"i18n": "yarn i18n:extract --clean && yarn i18n:compile",
|
||||
"prepare": "yarn ajv && yarn contracts && yarn graphql && yarn i18n",
|
||||
"start": "craco start",
|
||||
"build": "craco build",
|
||||
"build:e2e": "REACT_APP_CSP_ALLOW_UNSAFE_EVAL=true REACT_APP_ADD_COVERAGE_INSTRUMENTATION=true craco build",
|
||||
@@ -24,8 +26,7 @@
|
||||
"serve": "serve build -l 3000",
|
||||
"lint": "yarn eslint --ignore-path .gitignore --cache --cache-location node_modules/.cache/eslint/ .",
|
||||
"typecheck": "tsc",
|
||||
"test": "craco test --coverage",
|
||||
"test:size": "node scripts/test-size.js",
|
||||
"test": "craco test",
|
||||
"cypress:open": "cypress open --browser chrome --e2e",
|
||||
"cypress:run": "cypress run --browser chrome --e2e",
|
||||
"deduplicate": "yarn-deduplicate --strategy=highest"
|
||||
@@ -66,7 +67,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@craco/craco": "6.4.3",
|
||||
"@craco/craco": "^7.1.0",
|
||||
"@ethersproject/experimental": "^5.4.0",
|
||||
"@lingui/cli": "^3.9.0",
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
@@ -97,22 +98,29 @@
|
||||
"@types/ua-parser-js": "^0.7.35",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/wcag-contrast": "^3.0.0",
|
||||
"@uniswap/eslint-config": "^1.1.1",
|
||||
"@uniswap/default-token-list": "^9.4.0",
|
||||
"@uniswap/eslint-config": "^1.2.0",
|
||||
"@vanilla-extract/babel-plugin": "^1.1.7",
|
||||
"@vanilla-extract/jest-transform": "^1.1.1",
|
||||
"@vanilla-extract/webpack-plugin": "^2.1.11",
|
||||
"babel-plugin-istanbul": "^6.1.1",
|
||||
"buffer": "^6.0.3",
|
||||
"cypress": "10.3.1",
|
||||
"cypress-hardhat": "^1.0.1",
|
||||
"cypress-hardhat": "^2.3.0",
|
||||
"env-cmd": "^10.1.0",
|
||||
"eslint": "^7.11.0",
|
||||
"eslint-plugin-import": "^2.27",
|
||||
"eslint-plugin-rulesdir": "^0.2.2",
|
||||
"hardhat": "^2.14.0",
|
||||
"jest-fail-on-console": "^3.1.1",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jest-styled-components": "^7.0.8",
|
||||
"ms.macro": "^2.0.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"prettier": "^2.7.1",
|
||||
"react-scripts": "^4.0.3",
|
||||
"process": "^0.11.10",
|
||||
"react-scripts": "^5.0.1",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"serve": "^11.3.2",
|
||||
"source-map-explorer": "^2.5.3",
|
||||
"ts-transform-graphql-tag": "^0.2.1",
|
||||
@@ -132,12 +140,13 @@
|
||||
"@graphql-codegen/typescript-operations": "^2.5.8",
|
||||
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
|
||||
"@graphql-codegen/typescript-resolvers": "^2.7.8",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@lingui/core": "^3.14.0",
|
||||
"@lingui/macro": "^3.14.0",
|
||||
"@lingui/react": "^3.14.0",
|
||||
"@looksrare/sdk": "^0.10.2",
|
||||
"@metamask/jazzicon": "^2.0.0",
|
||||
"@opensea/seaport-js": "^1.0.10",
|
||||
"@opensea/seaport-js": "^1.2.0",
|
||||
"@popperjs/core": "^2.4.4",
|
||||
"@reach/dialog": "^0.10.3",
|
||||
"@reach/portal": "^0.10.3",
|
||||
@@ -157,7 +166,7 @@
|
||||
"@uniswap/router-sdk": "^1.3.0",
|
||||
"@uniswap/sdk-core": "^3.2.2",
|
||||
"@uniswap/smart-order-router": "^3.6.1",
|
||||
"@uniswap/token-lists": "^1.0.0-beta.30",
|
||||
"@uniswap/token-lists": "^1.0.0-beta.31",
|
||||
"@uniswap/universal-router-sdk": "^1.3.8",
|
||||
"@uniswap/v2-core": "1.0.0",
|
||||
"@uniswap/v2-periphery": "^1.1.0-beta.0",
|
||||
@@ -165,7 +174,6 @@
|
||||
"@uniswap/v3-core": "1.0.0",
|
||||
"@uniswap/v3-periphery": "^1.1.1",
|
||||
"@uniswap/v3-sdk": "^3.9.0",
|
||||
"@uniswap/widgets": "^2.49.0",
|
||||
"@vanilla-extract/css": "^1.7.2",
|
||||
"@vanilla-extract/css-utils": "^0.1.2",
|
||||
"@vanilla-extract/dynamic": "^2.0.2",
|
||||
@@ -188,6 +196,8 @@
|
||||
"@web3-react/types": "^8.2.0",
|
||||
"@web3-react/url": "^8.2.0",
|
||||
"@web3-react/walletconnect": "^8.2.0",
|
||||
"ajv": "^8.11.0",
|
||||
"ajv-formats": "^2.1.1",
|
||||
"array.prototype.flat": "^1.2.4",
|
||||
"array.prototype.flatmap": "^1.2.4",
|
||||
"cids": "^1.0.0",
|
||||
@@ -248,18 +258,6 @@
|
||||
"workbox-routing": "^6.1.0",
|
||||
"zustand": "^4.3.6"
|
||||
},
|
||||
"resolutions": {
|
||||
"@web3-react/coinbase-wallet": "^8.2.0",
|
||||
"@web3-react/core": "^8.2.0",
|
||||
"@web3-react/eip1193": "^8.2.0",
|
||||
"@web3-react/empty": "^8.2.0",
|
||||
"@web3-react/gnosis-safe": "^8.2.0",
|
||||
"@web3-react/metamask": "^8.2.0",
|
||||
"@web3-react/network": "^8.2.0",
|
||||
"@web3-react/types": "^8.2.0",
|
||||
"@web3-react/url": "^8.2.0",
|
||||
"@web3-react/walletconnect": "^8.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"npm": "please-use-yarn",
|
||||
"node": "14",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="200" fill="#04CD58"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M100 112C87.3026 112 77 101.708 77 89C77 76.2923 87.3026 66 100 66C112.697 66 123 76.2923 123 89C123 101.708 112.697 112 100 112ZM90 89C90 94.5251 94.4794 99 100 99C105.521 99 110 94.5251 110 89C110 83.4749 105.521 79 100 79C94.4794 79 90 83.4749 90 89Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26 89.0304L70 45H130L174 89.0304L100 163L26 89.0304ZM134 72.9998C115.305 54.2224 84.6953 54.2225 66 72.9999L50 89.0001L66 105C84.6953 123.778 115.305 123.777 134 105L150 89.0001L134 72.9998Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 731 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" fill="#2081E2"/>
|
||||
<path d="M24.6679 51.6802L24.8836 51.3411L37.8906 30.9933C38.0807 30.6954 38.5276 30.7262 38.6714 31.0498C40.8444 35.9197 42.7194 41.9763 41.841 45.7469C41.466 47.2983 40.4386 49.3993 39.2827 51.3411C39.1338 51.6237 38.9694 51.9011 38.7947 52.1682C38.7125 52.2915 38.5738 52.3634 38.4248 52.3634H25.048C24.6884 52.3634 24.4778 51.973 24.6679 51.6802Z" fill="white"/>
|
||||
<path d="M82.6444 55.461V58.6819C82.6444 58.8668 82.5314 59.0312 82.367 59.1031C81.3602 59.5346 77.9132 61.1168 76.48 63.11C72.8224 68.2008 70.0279 75.48 63.7812 75.48H37.721C28.4847 75.48 21 67.9697 21 58.7024V58.4045C21 58.1579 21.2003 57.9576 21.4469 57.9576H35.9745C36.2621 57.9576 36.4727 58.2247 36.4471 58.5072C36.3443 59.4524 36.519 60.4182 36.9659 61.2966C37.8289 63.0484 39.6166 64.1426 41.5481 64.1426H48.74V58.5278H41.6303C41.2656 58.5278 41.0499 58.1065 41.2605 57.8086C41.3375 57.6904 41.4249 57.5672 41.5173 57.4285C42.1903 56.473 43.1509 54.9884 44.1064 53.2983C44.7588 52.1579 45.3906 50.9404 45.8992 49.7178C46.002 49.4969 46.0841 49.2708 46.1663 49.0499C46.305 48.6595 46.4489 48.2948 46.5516 47.9301C46.6544 47.6218 46.7365 47.2982 46.8187 46.9951C47.0602 45.9574 47.1629 44.8581 47.1629 43.7177C47.1629 43.2708 47.1424 42.8033 47.1013 42.3564C47.0807 41.8684 47.0191 41.3803 46.9574 40.8923C46.9163 40.4608 46.8393 40.0344 46.7571 39.5875C46.6544 38.9351 46.5105 38.2879 46.3461 37.6354L46.2896 37.3889C46.1663 36.9419 46.0636 36.5156 45.9198 36.0687C45.5139 34.6662 45.0465 33.2998 44.5533 32.0207C44.3735 31.5121 44.168 31.0241 43.9625 30.5361C43.6595 29.8015 43.3512 29.1337 43.0687 28.5018C42.9249 28.2141 42.8016 27.9521 42.6783 27.685C42.5396 27.3819 42.3958 27.0788 42.2519 26.7912C42.1492 26.5703 42.031 26.3648 41.9488 26.1593L41.0704 24.536C40.9471 24.3151 41.1526 24.0531 41.394 24.1199L46.8907 25.6096H46.9061C46.9163 25.6096 46.9215 25.6148 46.9266 25.6148L47.6509 25.8151L48.4472 26.0412L48.74 26.1233V22.8562C48.74 21.2791 50.0037 20 51.5654 20C52.3462 20 53.0551 20.3185 53.5637 20.8373C54.0722 21.3562 54.3907 22.0651 54.3907 22.8562V27.7056L54.9764 27.8699C55.0226 27.8854 55.0688 27.9059 55.1099 27.9367C55.2538 28.0446 55.4592 28.2038 55.7212 28.3991C55.9267 28.5634 56.1476 28.7638 56.4147 28.9693C56.9438 29.3956 57.5757 29.9453 58.2692 30.5772C58.4541 30.7364 58.6339 30.9008 58.7983 31.0652C59.6922 31.8974 60.6939 32.8734 61.6494 33.9522C61.9165 34.2553 62.1785 34.5635 62.4456 34.8871C62.7127 35.2159 62.9953 35.5395 63.2418 35.8632C63.5655 36.2947 63.9148 36.7416 64.2179 37.2091C64.3617 37.43 64.5261 37.656 64.6648 37.8769C65.0552 38.4676 65.3994 39.079 65.7282 39.6903C65.8669 39.9728 66.0107 40.281 66.134 40.5841C66.4987 41.4009 66.7864 42.2331 66.9713 43.0653C67.0278 43.2451 67.0689 43.4403 67.0895 43.615V43.6561C67.1511 43.9026 67.1717 44.1646 67.1922 44.4317C67.2744 45.2845 67.2333 46.1372 67.0484 46.9951C66.9713 47.3599 66.8686 47.704 66.7453 48.0688C66.622 48.4181 66.4987 48.7828 66.3395 49.127C66.0313 49.841 65.6665 50.5551 65.235 51.2229C65.0963 51.4695 64.9319 51.7315 64.7675 51.9781C64.5877 52.24 64.4028 52.4866 64.2384 52.7281C64.0124 53.0363 63.771 53.3599 63.5244 53.6476C63.3035 53.9507 63.0775 54.2538 62.8309 54.5209C62.4867 54.9267 62.1579 55.312 61.8137 55.6819C61.6083 55.9233 61.3874 56.1699 61.1613 56.3908C60.9405 56.6373 60.7144 56.8582 60.5089 57.0637C60.1648 57.4079 59.8771 57.675 59.6356 57.8959L59.0706 58.4148C58.9884 58.4867 58.8805 58.5278 58.7675 58.5278H54.3907V64.1426H59.8976C61.1305 64.1426 62.3018 63.7059 63.247 62.9045C63.5706 62.622 64.9833 61.3994 66.6528 59.5552C66.7093 59.4935 66.7813 59.4473 66.8635 59.4268L82.0742 55.0295C82.3568 54.9473 82.6444 55.163 82.6444 55.461Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,20 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="28" height="28" fill="#F9F9F9"/>
|
||||
<path d="M21.94 7.92004C20.4077 6.42277 18.3116 5.5 16 5.5C11.3056 5.5 7.5 9.30559 7.5 14C7.5 18.6944 11.3056 22.5 16 22.5C18.3116 22.5 20.4077 21.5772 21.94 20.08C20.1123 22.4633 17.2356 24 14 24C8.47715 24 4 19.5229 4 14C4 8.47715 8.47715 4 14 4C17.2356 4 20.1123 5.53668 21.94 7.92004Z" fill="url(#paint0_linear_6993_17582)"/>
|
||||
<path d="M9.64795 18.864C10.8738 20.0618 12.5507 20.8 14.4 20.8C18.1555 20.8 21.2 17.7555 21.2 14C21.2 10.2445 18.1555 7.2 14.4 7.2C12.5507 7.2 10.8738 7.9382 9.64795 9.13601C11.1102 7.22934 13.4115 6 16 6C20.4183 6 24 9.58172 24 14C24 18.4183 20.4183 22 16 22C13.4115 22 11.1102 20.7707 9.64795 18.864Z" fill="url(#paint1_linear_6993_17582)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 14C20 17.3137 17.3137 20 14 20C10.6863 20 8 17.3137 8 14C8 10.6863 10.6863 8 14 8C17.3137 8 20 10.6863 20 14ZM18 14C18 16.2091 16.2091 18 14 18C11.7909 18 10 16.2091 10 14C10 11.7909 11.7909 10 14 10C16.2091 10 18 11.7909 18 14Z" fill="url(#paint2_linear_6993_17582)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_6993_17582" x1="4" y1="13.6552" x2="24" y2="13.6552" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00E0FF"/>
|
||||
<stop offset="1" stop-color="#562EC8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_6993_17582" x1="3.99998" y1="13.6552" x2="24" y2="13.6552" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00E0FF"/>
|
||||
<stop offset="1" stop-color="#562EC8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_6993_17582" x1="4" y1="13.6552" x2="24" y2="13.6552" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00E0FF"/>
|
||||
<stop offset="1" stop-color="#562EC8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
20
scripts/compile-ajv-validators.js
Normal file
20
scripts/compile-ajv-validators.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/* eslint-env node */
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const Ajv = require('ajv')
|
||||
const standaloneCode = require('ajv/dist/standalone').default
|
||||
const addFormats = require('ajv-formats')
|
||||
const schema = require('@uniswap/token-lists/dist/tokenlist.schema.json')
|
||||
|
||||
const tokenListAjv = new Ajv({ code: { source: true, esm: true } })
|
||||
addFormats(tokenListAjv)
|
||||
const validateTokenList = tokenListAjv.compile(schema)
|
||||
let tokenListModuleCode = standaloneCode(tokenListAjv, validateTokenList)
|
||||
fs.writeFileSync(path.join(__dirname, '../src/utils/__generated__/validateTokenList.js'), tokenListModuleCode)
|
||||
|
||||
const tokensAjv = new Ajv({ code: { source: true, esm: true } })
|
||||
addFormats(tokensAjv)
|
||||
const validateTokens = tokensAjv.compile({ ...schema, required: ['tokens'] })
|
||||
let tokensModuleCode = standaloneCode(tokensAjv, validateTokens)
|
||||
fs.writeFileSync(path.join(__dirname, '../src/utils/__generated__/validateTokens.js'), tokensModuleCode)
|
||||
@@ -4,8 +4,8 @@ require('dotenv').config({ path: '.env.production' })
|
||||
const child_process = require('child_process')
|
||||
const fs = require('fs/promises')
|
||||
const { promisify } = require('util')
|
||||
const dataConfig = require('../graphql.config')
|
||||
const thegraphConfig = require('../graphql_thegraph.config')
|
||||
const dataConfig = require('../graphql.data.config')
|
||||
const thegraphConfig = require('../graphql.thegraph.config')
|
||||
|
||||
const exec = promisify(child_process.exec)
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
/* eslint-env node */
|
||||
|
||||
const { exec } = require('child_process')
|
||||
const isWindows = process.platform === 'win32' || /^(msys|cygwin)$/.test(process.env.OSTYPE)
|
||||
|
||||
if (isWindows) {
|
||||
exec(`type nul > src/locales/en-US.po`)
|
||||
} else {
|
||||
exec(`touch src/locales/en-US.po`)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/* eslint-disable no-undef */
|
||||
const assert = require('assert')
|
||||
const chalk = require('chalk')
|
||||
const fs = require('fs')
|
||||
const gzipSize = require('gzip-size').sync
|
||||
const path = require('path')
|
||||
|
||||
const buildDir = path.join(__dirname, '../build')
|
||||
|
||||
let entrypoints
|
||||
try {
|
||||
entrypoints = require(path.join(buildDir, 'asset-manifest.json')).entrypoints
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow('You must build first: `yarn build`'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// The last recorded size for these assets, as reported by `yarn build`.
|
||||
const LAST_SIZE_MAIN_KB = 435
|
||||
|
||||
// This is the async-loaded js, called <number>.<hash>.js, with a matching css file.
|
||||
const LAST_SIZE_ENTRY_KB = 1442
|
||||
|
||||
const SIZE_TOLERANCE_KB = 10
|
||||
|
||||
const jsEntrypoints = entrypoints.filter((entrypoint) => entrypoint.endsWith('js'))
|
||||
assert(jsEntrypoints.length === 3)
|
||||
|
||||
let fail = false
|
||||
console.log('File sizes after gzip:\n')
|
||||
jsEntrypoints.forEach((entrypoint) => {
|
||||
const name = entrypoint.match(/\/([\w\d-]*)\./)[1]
|
||||
const size = gzipSize(fs.readFileSync(path.join(buildDir, entrypoint))) / 1024
|
||||
|
||||
let maxSize = LAST_SIZE_ENTRY_KB + SIZE_TOLERANCE_KB
|
||||
if (name === 'runtime-main') {
|
||||
return
|
||||
} else if (name === 'main') {
|
||||
maxSize = LAST_SIZE_MAIN_KB + SIZE_TOLERANCE_KB
|
||||
}
|
||||
|
||||
const report = `\t${size.toFixed(2).padEnd(8)}kB\t${chalk.dim(
|
||||
`max: ${maxSize.toFixed().padEnd(4)} kB`
|
||||
)}\t${entrypoint}`
|
||||
if (maxSize > size) {
|
||||
console.log(chalk.green(report))
|
||||
} else {
|
||||
console.log(chalk.red(report), '\tdid you import an unnecessary dependency?')
|
||||
fail = true
|
||||
}
|
||||
})
|
||||
if (fail) {
|
||||
console.log(chalk.yellow('\nOne or more of your files has grown too large.'))
|
||||
console.log(chalk.yellow('Reduce the file size or update the size limit (in scripts/test-size.js)'))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -82,15 +82,38 @@ const ActivityGroupWrapper = styled(Column)`
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
function combineActivities(localMap: ActivityMap = {}, remoteMap: ActivityMap = {}): Array<Activity> {
|
||||
/* Detects transactions from same account with the same nonce and different hash */
|
||||
function wasTxCancelled(localActivity: Activity, remoteMap: ActivityMap, account: string): boolean {
|
||||
// handles locally cached tx's that were stored before we started tracking nonces
|
||||
if (!localActivity.nonce || localActivity.status !== TransactionStatus.Pending) return false
|
||||
|
||||
return Object.values(remoteMap).some((remoteTx) => {
|
||||
if (!remoteTx) return false
|
||||
|
||||
// Cancellations are only possible when both nonce and tx.from are the same
|
||||
if (remoteTx.nonce === localActivity.nonce && remoteTx.receipt?.from.toLowerCase() === account.toLowerCase()) {
|
||||
// If the remote tx has a different hash than the local tx, the local tx was cancelled
|
||||
return remoteTx.hash.toLowerCase() !== localActivity.hash.toLowerCase()
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function combineActivities(localMap: ActivityMap = {}, remoteMap: ActivityMap = {}, account: string): Array<Activity> {
|
||||
const txHashes = [...new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])]
|
||||
|
||||
// Merges local and remote activities w/ same hash, preferring remote data
|
||||
return txHashes.reduce((acc: Array<Activity>, hash) => {
|
||||
const localActivity = localMap?.[hash] ?? {}
|
||||
const remoteActivity = remoteMap?.[hash] ?? {}
|
||||
// TODO(cartcrom): determine best logic for which fields to prefer from which sources, i.e. prefer remote exact swap output instead of local estimated output
|
||||
acc.push({ ...remoteActivity, ...localActivity } as Activity)
|
||||
const localActivity = (localMap?.[hash] ?? {}) as Activity
|
||||
const remoteActivity = (remoteMap?.[hash] ?? {}) as Activity
|
||||
|
||||
// TODO(WEB-2064): Display cancelled status in UI rather than completely hiding cancelled TXs
|
||||
if (wasTxCancelled(localActivity, remoteMap, account)) return acc
|
||||
|
||||
// TODO(cartcrom): determine best logic for which fields to prefer from which sources
|
||||
// i.e.prefer remote exact swap output instead of local estimated output
|
||||
acc.push({ ...localActivity, ...remoteActivity } as Activity)
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
@@ -122,9 +145,9 @@ export function ActivityTab({ account }: { account: string }) {
|
||||
|
||||
const activityGroups = useMemo(() => {
|
||||
const remoteMap = parseRemoteActivities(data?.portfolios?.[0].assetActivities)
|
||||
const allActivities = combineActivities(localMap, remoteMap)
|
||||
const allActivities = combineActivities(localMap, remoteMap, account)
|
||||
return createGroups(allActivities)
|
||||
}, [data?.portfolios, localMap])
|
||||
}, [data?.portfolios, localMap, account])
|
||||
|
||||
if (!data && loading)
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { SupportedChainId, Token, TradeType as MockTradeType } from '@uniswap/sd
|
||||
import { PERMIT2_ADDRESS } from '@uniswap/universal-router-sdk'
|
||||
import { DAI as MockDAI, nativeOnChain, USDC_MAINNET as MockUSDC_MAINNET } from 'constants/tokens'
|
||||
import { TransactionStatus as MockTxStatus } from 'graphql/data/__generated__/types-and-hooks'
|
||||
import { TokenAddressMap } from 'state/lists/hooks'
|
||||
import { ChainTokenMap } from 'hooks/Tokens'
|
||||
import {
|
||||
ExactInputSwapTransactionInfo,
|
||||
ExactOutputSwapTransactionInfo,
|
||||
@@ -89,15 +89,15 @@ function mockMultiStatus(info: TransactionInfo, id: string): [TransactionDetails
|
||||
]
|
||||
}
|
||||
|
||||
const mockTokenAddressMap: TokenAddressMap = {
|
||||
const mockTokenAddressMap: ChainTokenMap = {
|
||||
[mockChainId]: {
|
||||
[MockDAI.address]: { token: MockDAI },
|
||||
[MockUSDC_MAINNET.address]: { token: MockUSDC_MAINNET },
|
||||
} as TokenAddressMap[number],
|
||||
[MockDAI.address]: MockDAI,
|
||||
[MockUSDC_MAINNET.address]: MockUSDC_MAINNET,
|
||||
},
|
||||
}
|
||||
|
||||
jest.mock('../../../../state/lists/hooks', () => ({
|
||||
useCombinedActiveList: () => mockTokenAddressMap,
|
||||
jest.mock('../../../../hooks/Tokens', () => ({
|
||||
useAllTokensMultichain: () => mockTokenAddressMap,
|
||||
}))
|
||||
|
||||
jest.mock('../../../../state/transactions/hooks', () => {
|
||||
@@ -300,7 +300,7 @@ describe('parseLocalActivity', () => {
|
||||
},
|
||||
} as TransactionDetails
|
||||
const chainId = SupportedChainId.MAINNET
|
||||
const tokens = {} as TokenAddressMap
|
||||
const tokens = {} as ChainTokenMap
|
||||
expect(parseLocalActivity(details, chainId, tokens)).toMatchObject({
|
||||
chainId: 1,
|
||||
currencies: [undefined, undefined],
|
||||
|
||||
@@ -3,9 +3,9 @@ import { formatCurrencyAmount } from '@uniswap/conedison/format'
|
||||
import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
|
||||
import { nativeOnChain } from '@uniswap/smart-order-router'
|
||||
import { SupportedChainId } from 'constants/chains'
|
||||
import { TransactionPartsFragment, TransactionStatus } from 'graphql/data/__generated__/types-and-hooks'
|
||||
import { TransactionStatus } from 'graphql/data/__generated__/types-and-hooks'
|
||||
import { ChainTokenMap, useAllTokensMultichain } from 'hooks/Tokens'
|
||||
import { useMemo } from 'react'
|
||||
import { TokenAddressMap, useCombinedActiveList } from 'state/lists/hooks'
|
||||
import { useMultichainTransactions } from 'state/transactions/hooks'
|
||||
import {
|
||||
AddLiquidityV2PoolTransactionInfo,
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
import { getActivityTitle } from '../constants'
|
||||
import { Activity, ActivityMap } from './types'
|
||||
|
||||
function getCurrency(currencyId: string, chainId: SupportedChainId, tokens: TokenAddressMap): Currency | undefined {
|
||||
return currencyId === 'ETH' ? nativeOnChain(chainId) : tokens[chainId]?.[currencyId]?.token
|
||||
function getCurrency(currencyId: string, chainId: SupportedChainId, tokens: ChainTokenMap): Currency | undefined {
|
||||
return currencyId === 'ETH' ? nativeOnChain(chainId) : tokens[chainId]?.[currencyId]
|
||||
}
|
||||
|
||||
function buildCurrencyDescriptor(
|
||||
@@ -46,7 +46,7 @@ function buildCurrencyDescriptor(
|
||||
function parseSwap(
|
||||
swap: ExactInputSwapTransactionInfo | ExactOutputSwapTransactionInfo,
|
||||
chainId: SupportedChainId,
|
||||
tokens: TokenAddressMap
|
||||
tokens: ChainTokenMap
|
||||
): Partial<Activity> {
|
||||
const tokenIn = getCurrency(swap.inputCurrencyId, chainId, tokens)
|
||||
const tokenOut = getCurrency(swap.outputCurrencyId, chainId, tokens)
|
||||
@@ -76,7 +76,7 @@ function parseWrap(wrap: WrapTransactionInfo, chainId: SupportedChainId, status:
|
||||
function parseApproval(
|
||||
approval: ApproveTransactionInfo,
|
||||
chainId: SupportedChainId,
|
||||
tokens: TokenAddressMap
|
||||
tokens: ChainTokenMap
|
||||
): Partial<Activity> {
|
||||
// TODO: Add 'amount' approved to ApproveTransactionInfo so we can distinguish between revoke and approve
|
||||
const currency = getCurrency(approval.tokenAddress, chainId, tokens)
|
||||
@@ -91,7 +91,7 @@ type GenericLPInfo = Omit<
|
||||
AddLiquidityV3PoolTransactionInfo | RemoveLiquidityV3TransactionInfo | AddLiquidityV2PoolTransactionInfo,
|
||||
'type'
|
||||
>
|
||||
function parseLP(lp: GenericLPInfo, chainId: SupportedChainId, tokens: TokenAddressMap): Partial<Activity> {
|
||||
function parseLP(lp: GenericLPInfo, chainId: SupportedChainId, tokens: ChainTokenMap): Partial<Activity> {
|
||||
const baseCurrency = getCurrency(lp.baseCurrencyId, chainId, tokens)
|
||||
const quoteCurrency = getCurrency(lp.quoteCurrencyId, chainId, tokens)
|
||||
const [baseRaw, quoteRaw] = [lp.expectedAmountBaseRaw, lp.expectedAmountQuoteRaw]
|
||||
@@ -103,7 +103,7 @@ function parseLP(lp: GenericLPInfo, chainId: SupportedChainId, tokens: TokenAddr
|
||||
function parseCollectFees(
|
||||
collect: CollectFeesTransactionInfo,
|
||||
chainId: SupportedChainId,
|
||||
tokens: TokenAddressMap
|
||||
tokens: ChainTokenMap
|
||||
): Partial<Activity> {
|
||||
// Adapts CollectFeesTransactionInfo to generic LP type
|
||||
const {
|
||||
@@ -118,7 +118,7 @@ function parseCollectFees(
|
||||
function parseMigrateCreateV3(
|
||||
lp: MigrateV2LiquidityToV3TransactionInfo | CreateV3PoolTransactionInfo,
|
||||
chainId: SupportedChainId,
|
||||
tokens: TokenAddressMap
|
||||
tokens: ChainTokenMap
|
||||
): Partial<Activity> {
|
||||
const baseCurrency = getCurrency(lp.baseCurrencyId, chainId, tokens)
|
||||
const baseSymbol = baseCurrency?.symbol ?? t`Unknown`
|
||||
@@ -132,7 +132,7 @@ function parseMigrateCreateV3(
|
||||
export function parseLocalActivity(
|
||||
details: TransactionDetails,
|
||||
chainId: SupportedChainId,
|
||||
tokens: TokenAddressMap
|
||||
tokens: ChainTokenMap
|
||||
): Activity | undefined {
|
||||
try {
|
||||
const status = !details.receipt
|
||||
@@ -141,7 +141,7 @@ export function parseLocalActivity(
|
||||
? TransactionStatus.Confirmed
|
||||
: TransactionStatus.Failed
|
||||
|
||||
const receipt: TransactionPartsFragment | undefined = details.receipt
|
||||
const receipt = details.receipt
|
||||
? {
|
||||
id: details.receipt.transactionHash,
|
||||
...details.receipt,
|
||||
@@ -157,6 +157,7 @@ export function parseLocalActivity(
|
||||
status,
|
||||
timestamp: (details.confirmedTime ?? details.addedTime) / 1000,
|
||||
receipt,
|
||||
nonce: details.nonce,
|
||||
}
|
||||
|
||||
let additionalFields: Partial<Activity> = {}
|
||||
@@ -188,7 +189,7 @@ export function parseLocalActivity(
|
||||
|
||||
export function useLocalActivities(account: string): ActivityMap {
|
||||
const allTransactions = useMultichainTransactions()
|
||||
const tokens = useCombinedActiveList()
|
||||
const tokens = useAllTokensMultichain()
|
||||
|
||||
return useMemo(() => {
|
||||
const activityByHash: ActivityMap = {}
|
||||
|
||||
@@ -254,6 +254,7 @@ function parseRemoteActivity(assetActivity: AssetActivityPartsFragment): Activit
|
||||
title: assetActivity.type,
|
||||
descriptor: assetActivity.transaction.to,
|
||||
receipt: assetActivity.transaction,
|
||||
nonce: assetActivity.transaction.nonce,
|
||||
}
|
||||
const parsedFields = ActivityParserByType[assetActivity.type]?.(changes, assetActivity)
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ export type Activity = {
|
||||
logos?: Array<string | undefined>
|
||||
currencies?: Array<Currency | undefined>
|
||||
otherAccount?: string
|
||||
receipt?: Receipt
|
||||
receipt?: Omit<Receipt, 'nonce'>
|
||||
nonce?: number | null
|
||||
}
|
||||
|
||||
export type ActivityMap = { [hash: string]: Activity | undefined }
|
||||
|
||||
@@ -30,6 +30,7 @@ const Wrapper = styled(Column)<{ numItems: number; isExpanded: boolean }>`
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
// TODO(WEB-3288): Replace this component to use `components/Expand` under the hood
|
||||
type ExpandoRowProps = PropsWithChildren<{ title?: string; numItems: number; isExpanded: boolean; toggle: () => void }>
|
||||
export function ExpandoRow({ title = t`Hidden`, numItems, isExpanded, toggle, children }: ExpandoRowProps) {
|
||||
if (numItems === 0) return null
|
||||
|
||||
@@ -126,7 +126,7 @@ export function useGetCachedTokens(chains: SupportedChainId[]): TokenGetterFn {
|
||||
const local: { [address: string]: Token | undefined } = {}
|
||||
const missing = new Set<string>()
|
||||
addresses.forEach((address) => {
|
||||
const cached = tokenCache.get(chainId, address) ?? allTokens[chainId][address]?.token
|
||||
const cached = tokenCache.get(chainId, address) ?? allTokens[chainId]?.[address]
|
||||
cached ? (local[address] = cached) : missing.add(address)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Token } from '@uniswap/sdk-core'
|
||||
import { AddressMap } from '@uniswap/smart-order-router'
|
||||
import { abi as MulticallABI } from '@uniswap/v3-periphery/artifacts/contracts/lens/UniswapInterfaceMulticall.sol/UniswapInterfaceMulticall.json'
|
||||
import { abi as NFTPositionManagerABI } from '@uniswap/v3-periphery/artifacts/contracts/NonfungiblePositionManager.sol/NonfungiblePositionManager.json'
|
||||
import MulticallJSON from '@uniswap/v3-periphery/artifacts/contracts/lens/UniswapInterfaceMulticall.sol/UniswapInterfaceMulticall.json'
|
||||
import NFTPositionManagerJSON from '@uniswap/v3-periphery/artifacts/contracts/NonfungiblePositionManager.sol/NonfungiblePositionManager.json'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import { MULTICALL_ADDRESS, NONFUNGIBLE_POSITION_MANAGER_ADDRESSES as V3NFT_ADDRESSES } from 'constants/addresses'
|
||||
import { isSupportedChain, SupportedChainId } from 'constants/chains'
|
||||
@@ -43,11 +43,11 @@ function useContractMultichain<T extends BaseContract>(
|
||||
}
|
||||
|
||||
export function useV3ManagerContracts(chainIds: SupportedChainId[]): ContractMap<NonfungiblePositionManager> {
|
||||
return useContractMultichain<NonfungiblePositionManager>(V3NFT_ADDRESSES, NFTPositionManagerABI, chainIds)
|
||||
return useContractMultichain<NonfungiblePositionManager>(V3NFT_ADDRESSES, NFTPositionManagerJSON.abi, chainIds)
|
||||
}
|
||||
|
||||
export function useInterfaceMulticallContracts(chainIds: SupportedChainId[]): ContractMap<UniswapInterfaceMulticall> {
|
||||
return useContractMultichain<UniswapInterfaceMulticall>(MULTICALL_ADDRESS, MulticallABI, chainIds)
|
||||
return useContractMultichain<UniswapInterfaceMulticall>(MULTICALL_ADDRESS, MulticallJSON.abi, chainIds)
|
||||
}
|
||||
|
||||
type PriceMap = { [key: CurrencyKey]: number | undefined }
|
||||
|
||||
@@ -22,12 +22,12 @@ import { PositionInfo } from './cache'
|
||||
import { useFeeValues } from './hooks'
|
||||
import useMultiChainPositions from './useMultiChainPositions'
|
||||
|
||||
/*
|
||||
This hook takes an array of PositionInfo objects (format used by the Uniswap Labs gql API).
|
||||
The hook access PositionInfo.details (format used by the NFT position contract),
|
||||
filters the PositionDetails data for malicious content,
|
||||
and then returns the original data in its original format.
|
||||
*/
|
||||
/**
|
||||
* Takes an array of PositionInfo objects (format used by the Uniswap Labs gql API).
|
||||
* The hook access PositionInfo.details (format used by the NFT position contract),
|
||||
* filters the PositionDetails data for malicious content,
|
||||
* and then returns the original data in its original format.
|
||||
*/
|
||||
function useFilterPossiblyMaliciousPositionInfo(positions: PositionInfo[] | undefined): PositionInfo[] {
|
||||
const tokenIdsToPositionInfo: Record<string, PositionInfo> = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CurrencyAmount, Token } from '@uniswap/sdk-core'
|
||||
import { abi as IUniswapV3PoolStateABI } from '@uniswap/v3-core/artifacts/contracts/interfaces/pool/IUniswapV3PoolState.sol/IUniswapV3PoolState.json'
|
||||
import IUniswapV3PoolStateJSON from '@uniswap/v3-core/artifacts/contracts/interfaces/pool/IUniswapV3PoolState.sol/IUniswapV3PoolState.json'
|
||||
import { computePoolAddress, Pool, Position } from '@uniswap/v3-sdk'
|
||||
import { V3_CORE_FACTORY_ADDRESSES } from 'constants/addresses'
|
||||
import { SupportedChainId } from 'constants/chains'
|
||||
@@ -118,7 +118,7 @@ export default function useMultiChainPositions(account: string, chains = DEFAULT
|
||||
// Combines PositionDetails with Pool data to build our return type
|
||||
const fetchPositionInfo = useCallback(
|
||||
async (positionDetails: PositionDetails[], chainId: SupportedChainId, multicall: UniswapInterfaceMulticall) => {
|
||||
const poolInterface = new Interface(IUniswapV3PoolStateABI) as UniswapV3PoolInterface
|
||||
const poolInterface = new Interface(IUniswapV3PoolStateJSON.abi) as UniswapV3PoolInterface
|
||||
const tokens = await getTokens(
|
||||
positionDetails.flatMap((details) => [details.token0, details.token1]),
|
||||
chainId
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { sendAnalyticsEvent } from '@uniswap/analytics'
|
||||
import { InterfaceElementName } from '@uniswap/analytics-events'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import { WalletConnect } from '@web3-react/walletconnect'
|
||||
import Column, { AutoColumn } from 'components/Column'
|
||||
import Modal from 'components/Modal'
|
||||
import { RowBetween } from 'components/Row'
|
||||
import { uniwalletConnectConnection } from 'connection'
|
||||
import { ActivationStatus, useActivationState } from 'connection/activate'
|
||||
import { ConnectionType } from 'connection/types'
|
||||
import { UniwalletConnect } from 'connection/WalletConnect'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useModalIsOpen, useToggleUniwalletModal } from 'state/application/hooks'
|
||||
import { ApplicationModal } from 'state/application/reducer'
|
||||
import { useEffect, useState } from 'react'
|
||||
import styled, { useTheme } from 'styled-components/macro'
|
||||
import { CloseIcon, ThemedText } from 'theme'
|
||||
|
||||
@@ -39,44 +38,37 @@ const Divider = styled.div`
|
||||
`
|
||||
|
||||
export default function UniwalletModal() {
|
||||
const open = useModalIsOpen(ApplicationModal.UNIWALLET_CONNECT)
|
||||
const toggle = useToggleUniwalletModal()
|
||||
|
||||
const { activationState, cancelActivation } = useActivationState()
|
||||
const [uri, setUri] = useState<string>()
|
||||
|
||||
// Displays the modal if a Uniswap Wallet Connection is pending & qrcode URI is available
|
||||
const open =
|
||||
activationState.status === ActivationStatus.PENDING &&
|
||||
activationState.connection.type === ConnectionType.UNIWALLET &&
|
||||
!!uri
|
||||
|
||||
useEffect(() => {
|
||||
;(uniwalletConnectConnection.connector as WalletConnect).events.addListener(
|
||||
UniwalletConnect.UNI_URI_AVAILABLE,
|
||||
(uri) => {
|
||||
uri && setUri(uri)
|
||||
toggle()
|
||||
}
|
||||
)
|
||||
}, [toggle])
|
||||
}, [])
|
||||
|
||||
const { account } = useWeb3React()
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
sendAnalyticsEvent('Uniswap wallet modal opened', { userConnected: !!account })
|
||||
if (account) {
|
||||
toggle()
|
||||
}
|
||||
}
|
||||
}, [account, open, toggle])
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
uniwalletConnectConnection.connector.deactivate?.()
|
||||
toggle()
|
||||
}, [toggle])
|
||||
if (open) sendAnalyticsEvent('Uniswap wallet modal opened')
|
||||
}, [open])
|
||||
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<Modal isOpen={open} onDismiss={onClose}>
|
||||
<Modal isOpen={open} onDismiss={cancelActivation}>
|
||||
<UniwalletConnectWrapper>
|
||||
<HeaderRow>
|
||||
<ThemedText.SubHeader>
|
||||
<Trans>Scan with Uniswap Wallet</Trans>
|
||||
</ThemedText.SubHeader>
|
||||
<CloseIcon onClick={onClose} />
|
||||
<CloseIcon onClick={cancelActivation} />
|
||||
</HeaderRow>
|
||||
<QRCodeWrapper>
|
||||
{uri && (
|
||||
|
||||
@@ -27,6 +27,11 @@ export function useToggleAccountDrawer() {
|
||||
}, [updateAccountDrawerOpen])
|
||||
}
|
||||
|
||||
export function useCloseAccountDrawer() {
|
||||
const updateAccountDrawerOpen = useUpdateAtom(accountDrawerOpenAtom)
|
||||
return useCallback(() => updateAccountDrawerOpen(false), [updateAccountDrawerOpen])
|
||||
}
|
||||
|
||||
export function useAccountDrawer(): [boolean, () => void] {
|
||||
const accountDrawerOpen = useAtomValue(accountDrawerOpenAtom)
|
||||
return [accountDrawerOpen, useToggleAccountDrawer()]
|
||||
|
||||
@@ -296,7 +296,7 @@ export function ButtonConfirmed({
|
||||
}
|
||||
}
|
||||
|
||||
export function ButtonError({ error, ...rest }: { error?: boolean } & ButtonProps) {
|
||||
export function ButtonError({ error, ...rest }: { error?: boolean } & BaseButtonProps) {
|
||||
if (error) {
|
||||
return <ButtonErrorStyle {...rest} />
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import styled, { DefaultTheme } from 'styled-components/macro'
|
||||
|
||||
type Gap = keyof DefaultTheme['grids']
|
||||
import styled from 'styled-components/macro'
|
||||
import { Gap } from 'theme'
|
||||
|
||||
export const Column = styled.div<{
|
||||
gap?: Gap
|
||||
|
||||
@@ -204,6 +204,7 @@ interface SwapCurrencyInputPanelProps {
|
||||
renderBalance?: (amount: CurrencyAmount<Currency>) => ReactNode
|
||||
locked?: boolean
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function SwapCurrencyInputPanel({
|
||||
@@ -226,6 +227,7 @@ export default function SwapCurrencyInputPanel({
|
||||
hideInput = false,
|
||||
locked = false,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
...rest
|
||||
}: SwapCurrencyInputPanelProps) {
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
@@ -258,13 +260,13 @@ export default function SwapCurrencyInputPanel({
|
||||
className="token-amount-input"
|
||||
value={value}
|
||||
onUserInput={onUserInput}
|
||||
disabled={!chainAllowed}
|
||||
disabled={!chainAllowed || disabled}
|
||||
$loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CurrencySelect
|
||||
disabled={!chainAllowed}
|
||||
disabled={!chainAllowed || disabled}
|
||||
visible={currency !== undefined}
|
||||
selected={!!currency}
|
||||
hideInput={hideInput}
|
||||
|
||||
105
src/components/Expand/__snapshots__/index.test.tsx.snap
Normal file
105
src/components/Expand/__snapshots__/index.test.tsx.snap
Normal file
@@ -0,0 +1,105 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Expand renders correctly 1`] = `
|
||||
<DocumentFragment>
|
||||
.c1 {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
width: 100%;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
-webkit-align-items: center;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
-webkit-box-pack: start;
|
||||
-webkit-justify-content: flex-start;
|
||||
-ms-flex-pack: start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.c3 {
|
||||
-webkit-box-pack: justify;
|
||||
-webkit-justify-content: space-between;
|
||||
-ms-flex-pack: justify;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.c0 {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
-ms-flex-direction: column;
|
||||
flex-direction: column;
|
||||
-webkit-box-pack: start;
|
||||
-webkit-justify-content: flex-start;
|
||||
-ms-flex-pack: start;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.c4 {
|
||||
cursor: pointer;
|
||||
-webkit-box-pack: end;
|
||||
-webkit-justify-content: flex-end;
|
||||
-ms-flex-pack: end;
|
||||
justify-content: flex-end;
|
||||
width: unset;
|
||||
}
|
||||
|
||||
.c5 {
|
||||
color: #7780A0;
|
||||
-webkit-transform: rotate(0deg);
|
||||
-ms-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
-webkit-transition: -webkit-transform 250ms;
|
||||
-webkit-transition: transform 250ms;
|
||||
transition: transform 250ms;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0"
|
||||
>
|
||||
<div
|
||||
class="c1 c2 c3"
|
||||
>
|
||||
<span>
|
||||
Header
|
||||
</span>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
class="c1 c2 c4"
|
||||
>
|
||||
<span>
|
||||
Button
|
||||
</span>
|
||||
<svg
|
||||
class="c5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polyline
|
||||
points="6 9 12 15 18 9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
30
src/components/Expand/index.test.tsx
Normal file
30
src/components/Expand/index.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { fireEvent, render, screen } from 'test-utils/render'
|
||||
|
||||
import Expand from './index'
|
||||
|
||||
describe('Expand', () => {
|
||||
it('renders correctly', () => {
|
||||
const { asFragment } = render(
|
||||
<Expand header={<span>Header</span>} button={<span>Button</span>}>
|
||||
Body
|
||||
</Expand>
|
||||
)
|
||||
expect(asFragment()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('toggles children on button press', () => {
|
||||
render(
|
||||
<Expand header={<span>Header</span>} button={<span>Button</span>}>
|
||||
Body
|
||||
</Expand>
|
||||
)
|
||||
|
||||
const button = screen.getByText('Button')
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(screen.queryByText('Body')).not.toBeNull()
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(screen.queryByText('Body')).toBeNull()
|
||||
})
|
||||
})
|
||||
43
src/components/Expand/index.tsx
Normal file
43
src/components/Expand/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import Column from 'components/Column'
|
||||
import React, { PropsWithChildren, ReactElement, useState } from 'react'
|
||||
import { ChevronDown } from 'react-feather'
|
||||
import styled from 'styled-components/macro'
|
||||
|
||||
import Row, { RowBetween } from '../Row'
|
||||
|
||||
const ButtonContainer = styled(Row)`
|
||||
cursor: pointer;
|
||||
justify-content: flex-end;
|
||||
width: unset;
|
||||
`
|
||||
|
||||
const ExpandIcon = styled(ChevronDown)<{ $isExpanded: boolean }>`
|
||||
color: ${({ theme }) => theme.textSecondary};
|
||||
transform: ${({ $isExpanded }) => ($isExpanded ? 'rotate(180deg)' : 'rotate(0deg)')};
|
||||
transition: transform ${({ theme }) => theme.transition.duration.medium};
|
||||
`
|
||||
|
||||
export default function Expand({
|
||||
header,
|
||||
button,
|
||||
children,
|
||||
testId,
|
||||
}: PropsWithChildren<{
|
||||
header: ReactElement
|
||||
button: ReactElement
|
||||
testId?: string
|
||||
}>) {
|
||||
const [isExpanded, setExpanded] = useState(false)
|
||||
return (
|
||||
<Column gap="md">
|
||||
<RowBetween>
|
||||
{header}
|
||||
<ButtonContainer data-testid={testId} onClick={() => setExpanded(!isExpanded)} aria-expanded={isExpanded}>
|
||||
{button}
|
||||
<ExpandIcon $isExpanded={isExpanded} />
|
||||
</ButtonContainer>
|
||||
</RowBetween>
|
||||
{isExpanded && children}
|
||||
</Column>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { BaseVariant, FeatureFlag, featureFlagSettings, useUpdateFlag } from 'featureFlags'
|
||||
import { DetailsV2Variant, useDetailsV2Flag } from 'featureFlags/flags/nftDetails'
|
||||
import { PayWithAnyTokenVariant, usePayWithAnyTokenFlag } from 'featureFlags/flags/payWithAnyToken'
|
||||
import { SwapWidgetVariant, useSwapWidgetFlag } from 'featureFlags/flags/swapWidget'
|
||||
import { TraceJsonRpcVariant, useTraceJsonRpcFlag } from 'featureFlags/flags/traceJsonRpc'
|
||||
import { useUpdateAtom } from 'jotai/utils'
|
||||
import { Children, PropsWithChildren, ReactElement, ReactNode, useCallback, useState } from 'react'
|
||||
@@ -203,18 +201,6 @@ export default function FeatureFlagModal() {
|
||||
<X size={24} />
|
||||
</CloseButton>
|
||||
</Header>
|
||||
<FeatureFlagOption
|
||||
variant={PayWithAnyTokenVariant}
|
||||
value={usePayWithAnyTokenFlag()}
|
||||
featureFlag={FeatureFlag.payWithAnyToken}
|
||||
label="Pay With Any Token"
|
||||
/>
|
||||
<FeatureFlagOption
|
||||
variant={SwapWidgetVariant}
|
||||
value={useSwapWidgetFlag()}
|
||||
featureFlag={FeatureFlag.swapWidget}
|
||||
label="Swap Widget"
|
||||
/>
|
||||
<FeatureFlagOption
|
||||
variant={DetailsV2Variant}
|
||||
value={useDetailsV2Flag()}
|
||||
|
||||
@@ -19,7 +19,7 @@ const Wrapper = styled.div<{ isDarkMode: boolean }>`
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
margin: 0;
|
||||
min-height: 720px;
|
||||
flex: 1 1;
|
||||
min-width: 375px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -125,7 +125,7 @@ export default function FiatOnrampModal() {
|
||||
}, [fetchSignedIframeUrl])
|
||||
|
||||
return (
|
||||
<Modal isOpen={fiatOnrampModalOpen} onDismiss={closeModal} maxHeight={720}>
|
||||
<Modal isOpen={fiatOnrampModalOpen} onDismiss={closeModal} height={80 /* vh */}>
|
||||
<Wrapper data-testid="fiat-onramp-modal" isDarkMode={isDarkMode}>
|
||||
{error ? (
|
||||
<>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function Loader({
|
||||
{...rest}
|
||||
>
|
||||
<path
|
||||
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 9.27455 20.9097 6.80375"
|
||||
d="M2,12 a10,10 0 0,1 10,-10 M12,22 a10,10 0 0,1 -10,-10 M22,12 a10,10 0 0,1 -10,10"
|
||||
strokeWidth={strokeWidth ?? '2.5'}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import { Unicon } from 'components/Unicon'
|
||||
import { Connection, ConnectionType } from 'connection'
|
||||
import { Connection, ConnectionType } from 'connection/types'
|
||||
import useENSAvatar from 'hooks/useENSAvatar'
|
||||
import styled from 'styled-components/macro'
|
||||
import { useIsDarkMode } from 'theme/components/ThemeToggle'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/**
|
||||
* Generates an SVG path for the east brush handle.
|
||||
* Apply `scale(-1, 1)` to generate west brush handle.
|
||||
*
|
||||
|
||||
@@ -79,6 +79,7 @@ interface ModalProps {
|
||||
isOpen: boolean
|
||||
onDismiss?: () => void
|
||||
onSwipe?: () => void
|
||||
height?: number // takes precedence over minHeight and maxHeight
|
||||
minHeight?: number | false
|
||||
maxHeight?: number
|
||||
maxWidth?: number
|
||||
@@ -94,6 +95,7 @@ export default function Modal({
|
||||
minHeight = false,
|
||||
maxHeight = 90,
|
||||
maxWidth = 420,
|
||||
height,
|
||||
initialFocusRef,
|
||||
children,
|
||||
onSwipe = onDismiss,
|
||||
@@ -139,8 +141,8 @@ export default function Modal({
|
||||
}
|
||||
: {})}
|
||||
aria-label="dialog"
|
||||
$minHeight={minHeight}
|
||||
$maxHeight={maxHeight}
|
||||
$minHeight={height ?? minHeight}
|
||||
$maxHeight={height ?? maxHeight}
|
||||
$scrollOverlay={$scrollOverlay}
|
||||
$hideBorder={hideBorder}
|
||||
$maxWidth={maxWidth}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { t } from '@lingui/macro'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import { MouseoverTooltip } from 'components/Tooltip'
|
||||
import { ConnectionType } from 'connection'
|
||||
import { useGetConnection } from 'connection'
|
||||
import { ConnectionType } from 'connection/types'
|
||||
import { getChainInfo } from 'constants/chainInfo'
|
||||
import { SupportedChainId } from 'constants/chains'
|
||||
import { SupportedChainId, UniWalletSupportedChains } from 'constants/chains'
|
||||
import { useOnClickOutside } from 'hooks/useOnClickOutside'
|
||||
import useSelectChain from 'hooks/useSelectChain'
|
||||
import useSyncChainQuery from 'hooks/useSyncChainQuery'
|
||||
@@ -76,7 +76,7 @@ export const ChainSelector = ({ leftAlign }: ChainSelectorProps) => {
|
||||
<Column paddingX="8">
|
||||
{NETWORK_SELECTOR_CHAINS.map((chainId: SupportedChainId) => (
|
||||
<ChainSelectorRow
|
||||
disabled={isUniWallet && chainId === SupportedChainId.CELO}
|
||||
disabled={isUniWallet && !UniWalletSupportedChains.includes(chainId)}
|
||||
onSelectChain={onSelectChain}
|
||||
targetChain={chainId}
|
||||
key={chainId}
|
||||
@@ -95,7 +95,7 @@ export const ChainSelector = ({ leftAlign }: ChainSelectorProps) => {
|
||||
|
||||
return (
|
||||
<Box position="relative" ref={ref}>
|
||||
<MouseoverTooltip text={t`Your wallet's current network is unsupported.`} disableHover={isSupported}>
|
||||
<MouseoverTooltip text={t`Your wallet's current network is unsupported.`} disabled={isSupported}>
|
||||
<Row
|
||||
as="button"
|
||||
gap="8"
|
||||
|
||||
@@ -57,13 +57,13 @@ export function FindPoolTabs({ origin }: { origin: string }) {
|
||||
export function AddRemoveTabs({
|
||||
adding,
|
||||
creating,
|
||||
defaultSlippage,
|
||||
autoSlippage,
|
||||
positionID,
|
||||
children,
|
||||
}: {
|
||||
adding: boolean
|
||||
creating: boolean
|
||||
defaultSlippage: Percent
|
||||
autoSlippage: Percent
|
||||
positionID?: string | undefined
|
||||
showBackLink?: boolean
|
||||
children?: ReactNode | undefined
|
||||
@@ -108,7 +108,7 @@ export function AddRemoveTabs({
|
||||
)}
|
||||
</ThemedText.DeprecatedMediumHeader>
|
||||
<Box style={{ marginRight: '.5rem' }}>{children}</Box>
|
||||
<SettingsTab placeholderSlippage={defaultSlippage} />
|
||||
<SettingsTab autoSlippage={autoSlippage} />
|
||||
</RowBetween>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
@@ -99,8 +99,8 @@ export default function Popover({
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null)
|
||||
const [arrowElement, setArrowElement] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
const options = useMemo(
|
||||
(): Options => ({
|
||||
const options: Options = useMemo(
|
||||
() => ({
|
||||
placement,
|
||||
strategy: 'fixed',
|
||||
modifiers: [
|
||||
@@ -109,7 +109,7 @@ export default function Popover({
|
||||
{ name: 'preventOverflow', options: { padding: 8 } },
|
||||
],
|
||||
}),
|
||||
[arrowElement, offsetX, offsetY, placement]
|
||||
[placement, offsetX, offsetY, arrowElement]
|
||||
)
|
||||
|
||||
const { styles, update, attributes } = usePopper(referenceElement, popperElement, options)
|
||||
|
||||
@@ -3,8 +3,8 @@ import { parseLocalActivity } from 'components/AccountDrawer/MiniPortfolio/Activ
|
||||
import { PortfolioLogo } from 'components/AccountDrawer/MiniPortfolio/PortfolioLogo'
|
||||
import PortfolioRow from 'components/AccountDrawer/MiniPortfolio/PortfolioRow'
|
||||
import Column from 'components/Column'
|
||||
import { useAllTokensMultichain } from 'hooks/Tokens'
|
||||
import useENSName from 'hooks/useENSName'
|
||||
import { useCombinedActiveList } from 'state/lists/hooks'
|
||||
import { useTransaction } from 'state/transactions/hooks'
|
||||
import { TransactionDetails } from 'state/transactions/types'
|
||||
import styled from 'styled-components/macro'
|
||||
@@ -19,7 +19,7 @@ const Descriptor = styled(ThemedText.BodySmall)`
|
||||
|
||||
function TransactionPopupContent({ tx, chainId }: { tx: TransactionDetails; chainId: number }) {
|
||||
const success = tx.receipt?.status === 1
|
||||
const tokens = useCombinedActiveList()
|
||||
const tokens = useAllTokensMultichain()
|
||||
const activity = parseLocalActivity(tx, chainId, tokens)
|
||||
const { ENSName } = useENSName(activity?.otherAccount)
|
||||
|
||||
|
||||
50
src/components/Radio/index.tsx
Normal file
50
src/components/Radio/index.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { RowBetween } from 'components/Row'
|
||||
import { darken } from 'polished'
|
||||
import { PropsWithChildren } from 'react'
|
||||
import styled from 'styled-components/macro'
|
||||
|
||||
const Button = styled.button<{ isActive?: boolean; activeElement?: boolean }>`
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 2px solid ${({ theme, isActive }) => (isActive ? theme.accentAction : theme.backgroundOutline)};
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
outline: none;
|
||||
padding: 5px;
|
||||
width: fit-content;
|
||||
`
|
||||
|
||||
const ButtonFill = styled.span<{ isActive?: boolean }>`
|
||||
background: ${({ theme, isActive }) => (isActive ? theme.accentAction : theme.textTertiary)};
|
||||
border-radius: 50%;
|
||||
:hover {
|
||||
background: ${({ isActive, theme }) =>
|
||||
isActive ? darken(0.05, theme.accentAction) : darken(0.05, theme.deprecated_bg4)};
|
||||
color: ${({ isActive, theme }) => (isActive ? theme.white : theme.textTertiary)};
|
||||
}
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
opacity: ${({ isActive }) => (isActive ? 1 : 0)};
|
||||
`
|
||||
|
||||
const Container = styled(RowBetween)`
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
interface RadioProps {
|
||||
className?: string
|
||||
isActive: boolean
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
export default function Radio({ className, isActive, children, toggle }: PropsWithChildren<RadioProps>) {
|
||||
return (
|
||||
<Container className={className} onClick={toggle}>
|
||||
{children}
|
||||
<Button isActive={isActive}>
|
||||
<ButtonFill isActive={isActive} />
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Protocol } from '@uniswap/router-sdk'
|
||||
import { Currency, Percent } from '@uniswap/sdk-core'
|
||||
import { FeeAmount } from '@uniswap/v3-sdk'
|
||||
import { RoutingDiagramEntry } from 'components/swap/SwapRoute'
|
||||
import { DAI, USDC_MAINNET, WBTC } from 'constants/tokens'
|
||||
import { render } from 'test-utils/render'
|
||||
import { RoutingDiagramEntry } from 'utils/getRoutingDiagramEntries'
|
||||
|
||||
import RoutingDiagram from './RoutingDiagram'
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ import Badge from 'components/Badge'
|
||||
import DoubleCurrencyLogo from 'components/DoubleLogo'
|
||||
import CurrencyLogo from 'components/Logo/CurrencyLogo'
|
||||
import Row, { AutoRow } from 'components/Row'
|
||||
import { RoutingDiagramEntry } from 'components/swap/SwapRoute'
|
||||
import { useTokenInfoFromActiveList } from 'hooks/useTokenInfoFromActiveList'
|
||||
import { Box } from 'rebass'
|
||||
import styled from 'styled-components/macro'
|
||||
import { ThemedText } from 'theme'
|
||||
import { Z_INDEX } from 'theme/zIndex'
|
||||
import { RoutingDiagramEntry } from 'utils/getRoutingDiagramEntries'
|
||||
|
||||
import { ReactComponent as DotLine } from '../../assets/svg/dot_line.svg'
|
||||
import { MouseoverTooltip } from '../Tooltip'
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Box } from 'rebass/styled-components'
|
||||
import styled from 'styled-components/macro'
|
||||
import { Gap } from 'theme'
|
||||
|
||||
// TODO(WEB-3289):
|
||||
// Setting `width: 100%` by default prevents composability in complex flex layouts.
|
||||
// Same applies to `RowFixed` and its negative margins. This component needs to be
|
||||
// further investigated and improved to make UI work easier.
|
||||
const Row = styled(Box)<{
|
||||
width?: string
|
||||
align?: string
|
||||
@@ -8,7 +13,7 @@ const Row = styled(Box)<{
|
||||
padding?: string
|
||||
border?: string
|
||||
borderRadius?: string
|
||||
gap?: string
|
||||
gap?: Gap | string
|
||||
}>`
|
||||
width: ${({ width }) => width ?? '100%'};
|
||||
display: flex;
|
||||
@@ -18,7 +23,7 @@ const Row = styled(Box)<{
|
||||
padding: ${({ padding }) => padding};
|
||||
border: ${({ border }) => border};
|
||||
border-radius: ${({ borderRadius }) => borderRadius};
|
||||
gap: ${({ gap }) => gap};
|
||||
gap: ${({ gap, theme }) => gap && (theme.grids[gap as Gap] || gap)};
|
||||
`
|
||||
|
||||
export const RowBetween = styled(Row)`
|
||||
|
||||
@@ -294,7 +294,7 @@ export default function CurrencyList({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ListWrapper>
|
||||
<ListWrapper data-testid="currency-list-wrapper">
|
||||
{isLoading ? (
|
||||
<FixedSizeList
|
||||
className={scrollbarStyle}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function CurrencySearch({
|
||||
}
|
||||
}, [isAddressSearch])
|
||||
|
||||
const defaultTokens = useDefaultActiveTokens()
|
||||
const defaultTokens = useDefaultActiveTokens(chainId)
|
||||
const filteredTokens: Token[] = useMemo(() => {
|
||||
return Object.values(defaultTokens).filter(getTokenFilter(debouncedQuery))
|
||||
}, [defaultTokens, debouncedQuery])
|
||||
@@ -123,7 +123,7 @@ export function CurrencySearch({
|
||||
|
||||
const filteredSortedTokens = useSortTokensByQuery(debouncedQuery, sortedTokens)
|
||||
|
||||
const native = useNativeCurrency()
|
||||
const native = useNativeCurrency(chainId)
|
||||
const wrapped = native.wrapped
|
||||
|
||||
const searchCurrencies: Currency[] = useMemo(() => {
|
||||
|
||||
@@ -105,7 +105,7 @@ export default memo(function CurrencySearchModal({
|
||||
break
|
||||
}
|
||||
return (
|
||||
<Modal isOpen={isOpen} onDismiss={onDismiss} maxHeight={modalHeight} minHeight={modalHeight}>
|
||||
<Modal isOpen={isOpen} onDismiss={onDismiss} height={modalHeight}>
|
||||
{content}
|
||||
</Modal>
|
||||
)
|
||||
|
||||
46
src/components/Settings/Input/index.tsx
Normal file
46
src/components/Settings/Input/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import styled from 'styled-components/macro'
|
||||
|
||||
import Row from '../../Row'
|
||||
|
||||
export const Input = styled.input`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
text-align: right;
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::placeholder {
|
||||
color: ${({ theme }) => theme.textTertiary};
|
||||
}
|
||||
`
|
||||
|
||||
export const InputContainer = styled(Row)<{ error?: boolean }>`
|
||||
padding: 8px 16px;
|
||||
border-radius: 12px;
|
||||
width: auto;
|
||||
flex: 1;
|
||||
input {
|
||||
color: ${({ theme, error }) => (error ? theme.accentFailure : theme.textPrimary)};
|
||||
}
|
||||
border: 1px solid ${({ theme, error }) => (error ? theme.accentFailure : theme.deprecated_bg3)};
|
||||
${({ theme, error }) =>
|
||||
error
|
||||
? `
|
||||
border: 1px solid ${theme.accentFailure};
|
||||
:focus-within {
|
||||
border-color: ${theme.accentFailureSoft};
|
||||
}
|
||||
`
|
||||
: `
|
||||
border: 1px solid ${theme.backgroundOutline};
|
||||
:focus-within {
|
||||
border-color: ${theme.accentActiveSoft};
|
||||
}
|
||||
`}
|
||||
`
|
||||
98
src/components/Settings/MaxSlippageSettings/index.test.tsx
Normal file
98
src/components/Settings/MaxSlippageSettings/index.test.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
import store from 'state'
|
||||
import { updateUserSlippageTolerance } from 'state/user/reducer'
|
||||
import { SlippageTolerance } from 'state/user/types'
|
||||
import { fireEvent, render, screen } from 'test-utils/render'
|
||||
|
||||
import MaxSlippageSettings from '.'
|
||||
|
||||
const AUTO_SLIPPAGE = new Percent(5, 10_000)
|
||||
|
||||
const renderAndExpandSlippageSettings = () => {
|
||||
render(<MaxSlippageSettings autoSlippage={AUTO_SLIPPAGE} />)
|
||||
|
||||
// By default, the button to expand Slippage component and show `input` will have `Auto` label
|
||||
fireEvent.click(screen.getByText('Auto'))
|
||||
}
|
||||
|
||||
// Switch to custom mode by tapping on `Custom` label
|
||||
const switchToCustomSlippage = () => {
|
||||
fireEvent.click(screen.getByText('Custom'))
|
||||
}
|
||||
|
||||
const getSlippageInput = () => screen.getByTestId('slippage-input') as HTMLInputElement
|
||||
|
||||
describe('MaxSlippageSettings', () => {
|
||||
describe('input', () => {
|
||||
// Restore to default slippage before each unit test
|
||||
beforeEach(() => {
|
||||
store.dispatch(updateUserSlippageTolerance({ userSlippageTolerance: SlippageTolerance.Auto }))
|
||||
})
|
||||
it('does not render auto slippage as a value, but a placeholder', () => {
|
||||
renderAndExpandSlippageSettings()
|
||||
switchToCustomSlippage()
|
||||
|
||||
expect(getSlippageInput().value).toBe('')
|
||||
})
|
||||
it('renders custom slippage above the input', () => {
|
||||
renderAndExpandSlippageSettings()
|
||||
switchToCustomSlippage()
|
||||
|
||||
fireEvent.change(getSlippageInput(), { target: { value: '0.5' } })
|
||||
|
||||
expect(screen.queryAllByText('0.50%').length).toEqual(1)
|
||||
})
|
||||
it('updates input value on blur with the slippage in store', () => {
|
||||
renderAndExpandSlippageSettings()
|
||||
switchToCustomSlippage()
|
||||
|
||||
const input = getSlippageInput()
|
||||
fireEvent.change(input, { target: { value: '0.5' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(input.value).toBe('0.50')
|
||||
})
|
||||
it('clears errors on blur and overwrites incorrect value with the latest correct value', () => {
|
||||
renderAndExpandSlippageSettings()
|
||||
switchToCustomSlippage()
|
||||
|
||||
const input = getSlippageInput()
|
||||
fireEvent.change(input, { target: { value: '5' } })
|
||||
fireEvent.change(input, { target: { value: '50' } })
|
||||
fireEvent.change(input, { target: { value: '500' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(input.value).toBe('50.00')
|
||||
})
|
||||
it('does not allow to enter more than 2 digits after the decimal point', () => {
|
||||
renderAndExpandSlippageSettings()
|
||||
switchToCustomSlippage()
|
||||
|
||||
const input = getSlippageInput()
|
||||
fireEvent.change(input, { target: { value: '0.01' } })
|
||||
fireEvent.change(input, { target: { value: '0.011' } })
|
||||
|
||||
expect(input.value).toBe('0.01')
|
||||
})
|
||||
it('does not accept non-numerical values', () => {
|
||||
renderAndExpandSlippageSettings()
|
||||
switchToCustomSlippage()
|
||||
|
||||
const input = getSlippageInput()
|
||||
fireEvent.change(input, { target: { value: 'c' } })
|
||||
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
it('does not set slippage when user enters `.` value', () => {
|
||||
renderAndExpandSlippageSettings()
|
||||
switchToCustomSlippage()
|
||||
|
||||
const input = getSlippageInput()
|
||||
fireEvent.change(input, { target: { value: '.' } })
|
||||
expect(input.value).toBe('.')
|
||||
|
||||
fireEvent.blur(input)
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
175
src/components/Settings/MaxSlippageSettings/index.tsx
Normal file
175
src/components/Settings/MaxSlippageSettings/index.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
import Expand from 'components/Expand'
|
||||
import QuestionHelper from 'components/QuestionHelper'
|
||||
import Row, { RowBetween } from 'components/Row'
|
||||
import React, { useState } from 'react'
|
||||
import { useUserSlippageTolerance } from 'state/user/hooks'
|
||||
import { SlippageTolerance } from 'state/user/types'
|
||||
import styled from 'styled-components/macro'
|
||||
import { CautionTriangle, ThemedText } from 'theme'
|
||||
|
||||
import { Input, InputContainer } from '../Input'
|
||||
|
||||
enum SlippageError {
|
||||
InvalidInput = 'InvalidInput',
|
||||
}
|
||||
|
||||
const Option = styled(Row)<{ isActive: boolean }>`
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
text-align: center;
|
||||
gap: 4px;
|
||||
border-radius: 12px;
|
||||
background: ${({ isActive, theme }) => (isActive ? theme.backgroundInteractive : 'transparent')};
|
||||
pointer-events: ${({ isActive }) => isActive && 'none'};
|
||||
`
|
||||
|
||||
const Switch = styled(Row)`
|
||||
width: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid ${({ theme }) => theme.backgroundOutline};
|
||||
border-radius: 16px;
|
||||
`
|
||||
|
||||
const NUMBER_WITH_MAX_TWO_DECIMAL_PLACES = /^(?:\d*\.\d{0,2}|\d+)$/
|
||||
const MINIMUM_RECOMMENDED_SLIPPAGE = new Percent(5, 10_000)
|
||||
const MAXIMUM_RECOMMENDED_SLIPPAGE = new Percent(1, 100)
|
||||
|
||||
export default function MaxSlippageSettings({ autoSlippage }: { autoSlippage: Percent }) {
|
||||
const [userSlippageTolerance, setUserSlippageTolerance] = useUserSlippageTolerance()
|
||||
|
||||
// In order to trigger `custom` mode, we need to set `userSlippageTolerance` to a value that is not `auto`.
|
||||
// To do so, we use `autoSlippage` value. However, since users are likely to change that value,
|
||||
// we render it as a placeholder instead of a value.
|
||||
const defaultSlippageInputValue =
|
||||
userSlippageTolerance !== SlippageTolerance.Auto && !userSlippageTolerance.equalTo(autoSlippage)
|
||||
? userSlippageTolerance.toFixed(2)
|
||||
: ''
|
||||
|
||||
// If user has previously entered a custom slippage, we want to show that value in the input field
|
||||
// instead of a placeholder.
|
||||
const [slippageInput, setSlippageInput] = useState(defaultSlippageInputValue)
|
||||
const [slippageError, setSlippageError] = useState<SlippageError | false>(false)
|
||||
|
||||
const parseSlippageInput = (value: string) => {
|
||||
// Do not allow non-numerical characters in the input field or more than two decimals
|
||||
if (value.length > 0 && !NUMBER_WITH_MAX_TWO_DECIMAL_PLACES.test(value)) {
|
||||
return
|
||||
}
|
||||
|
||||
setSlippageInput(value)
|
||||
setSlippageError(false)
|
||||
|
||||
// If the input is empty, set the slippage to the default
|
||||
if (value.length === 0) {
|
||||
setUserSlippageTolerance(SlippageTolerance.Auto)
|
||||
return
|
||||
}
|
||||
|
||||
if (value === '.') {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse user input and set the slippage if valid, error otherwise
|
||||
try {
|
||||
const parsed = Math.floor(Number.parseFloat(value) * 100)
|
||||
if (parsed > 5000) {
|
||||
setSlippageError(SlippageError.InvalidInput)
|
||||
} else {
|
||||
setUserSlippageTolerance(new Percent(parsed, 10_000))
|
||||
}
|
||||
} catch (e) {
|
||||
setSlippageError(SlippageError.InvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
const tooLow =
|
||||
userSlippageTolerance !== SlippageTolerance.Auto && userSlippageTolerance.lessThan(MINIMUM_RECOMMENDED_SLIPPAGE)
|
||||
const tooHigh =
|
||||
userSlippageTolerance !== SlippageTolerance.Auto && userSlippageTolerance.greaterThan(MAXIMUM_RECOMMENDED_SLIPPAGE)
|
||||
|
||||
return (
|
||||
<Expand
|
||||
testId="max-slippage-settings"
|
||||
header={
|
||||
<Row width="auto">
|
||||
<ThemedText.BodySecondary>
|
||||
<Trans>Max slippage</Trans>
|
||||
</ThemedText.BodySecondary>
|
||||
<QuestionHelper
|
||||
text={
|
||||
<Trans>Your transaction will revert if the price changes unfavorably by more than this percentage.</Trans>
|
||||
}
|
||||
/>
|
||||
</Row>
|
||||
}
|
||||
button={
|
||||
<ThemedText.BodyPrimary>
|
||||
{userSlippageTolerance === SlippageTolerance.Auto ? (
|
||||
<Trans>Auto</Trans>
|
||||
) : (
|
||||
`${userSlippageTolerance.toFixed(2)}%`
|
||||
)}
|
||||
</ThemedText.BodyPrimary>
|
||||
}
|
||||
>
|
||||
<RowBetween gap="md">
|
||||
<Switch>
|
||||
<Option
|
||||
onClick={() => {
|
||||
// Reset the input field when switching to auto
|
||||
setSlippageInput('')
|
||||
setUserSlippageTolerance(SlippageTolerance.Auto)
|
||||
}}
|
||||
isActive={userSlippageTolerance === SlippageTolerance.Auto}
|
||||
>
|
||||
<ThemedText.BodyPrimary>
|
||||
<Trans>Auto</Trans>
|
||||
</ThemedText.BodyPrimary>
|
||||
</Option>
|
||||
<Option
|
||||
onClick={() => {
|
||||
// When switching to custom slippage, use `auto` value as a default.
|
||||
setUserSlippageTolerance(autoSlippage)
|
||||
}}
|
||||
isActive={userSlippageTolerance !== SlippageTolerance.Auto}
|
||||
>
|
||||
<ThemedText.BodyPrimary>
|
||||
<Trans>Custom</Trans>
|
||||
</ThemedText.BodyPrimary>
|
||||
</Option>
|
||||
</Switch>
|
||||
<InputContainer gap="md" error={!!slippageError}>
|
||||
<Input
|
||||
data-testid="slippage-input"
|
||||
placeholder={autoSlippage.toFixed(2)}
|
||||
value={slippageInput}
|
||||
onChange={(e) => parseSlippageInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
// When the input field is blurred, reset the input field to the default value
|
||||
setSlippageInput(defaultSlippageInputValue)
|
||||
setSlippageError(false)
|
||||
}}
|
||||
/>
|
||||
<ThemedText.BodyPrimary>%</ThemedText.BodyPrimary>
|
||||
</InputContainer>
|
||||
</RowBetween>
|
||||
{tooLow || tooHigh ? (
|
||||
<RowBetween gap="md">
|
||||
<CautionTriangle />
|
||||
<ThemedText.Caption color="accentWarning">
|
||||
{tooLow ? (
|
||||
<Trans>
|
||||
Slippage below {MINIMUM_RECOMMENDED_SLIPPAGE.toFixed(2)}% may result in a failed transaction
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>Your transaction may be frontrun and result in an unfavorable trade.</Trans>
|
||||
)}
|
||||
</ThemedText.Caption>
|
||||
</RowBetween>
|
||||
) : null}
|
||||
</Expand>
|
||||
)
|
||||
}
|
||||
36
src/components/Settings/MenuButton/index.test.tsx
Normal file
36
src/components/Settings/MenuButton/index.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
import { useUserSlippageTolerance } from 'state/user/hooks'
|
||||
import { SlippageTolerance } from 'state/user/types'
|
||||
import { mocked } from 'test-utils/mocked'
|
||||
import { render, screen } from 'test-utils/render'
|
||||
import { lightTheme } from 'theme/colors'
|
||||
import noop from 'utils/noop'
|
||||
|
||||
import MenuButton from '.'
|
||||
|
||||
jest.mock('state/user/hooks')
|
||||
|
||||
const renderButton = () => {
|
||||
render(<MenuButton disabled={false} onClick={noop} isActive={false} />)
|
||||
}
|
||||
|
||||
describe('MenuButton', () => {
|
||||
it('should render an icon when slippage is Auto', () => {
|
||||
mocked(useUserSlippageTolerance).mockReturnValue([SlippageTolerance.Auto, noop])
|
||||
renderButton()
|
||||
expect(screen.queryByText('slippage')).not.toBeInTheDocument()
|
||||
})
|
||||
it('should render an icon with a custom slippage value', () => {
|
||||
mocked(useUserSlippageTolerance).mockReturnValue([new Percent(5, 10_000), noop])
|
||||
renderButton()
|
||||
expect(screen.queryByText('0.05% slippage')).toBeInTheDocument()
|
||||
})
|
||||
it('should render an icon with a custom slippage and a warning when value is out of bounds', () => {
|
||||
mocked(useUserSlippageTolerance).mockReturnValue([new Percent(1, 10_000), noop])
|
||||
renderButton()
|
||||
expect(screen.getByTestId('settings-icon-with-slippage')).toHaveStyleRule(
|
||||
'background-color',
|
||||
lightTheme.accentWarningSoft
|
||||
)
|
||||
})
|
||||
})
|
||||
91
src/components/Settings/MenuButton/index.tsx
Normal file
91
src/components/Settings/MenuButton/index.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { t, Trans } from '@lingui/macro'
|
||||
import Row from 'components/Row'
|
||||
import { Settings } from 'react-feather'
|
||||
import { useUserSlippageTolerance } from 'state/user/hooks'
|
||||
import { SlippageTolerance } from 'state/user/types'
|
||||
import styled from 'styled-components/macro'
|
||||
import { ThemedText } from 'theme'
|
||||
import validateUserSlippageTolerance, { SlippageValidationResult } from 'utils/validateUserSlippageTolerance'
|
||||
|
||||
const Icon = styled(Settings)`
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
> * {
|
||||
stroke: ${({ theme }) => theme.textSecondary};
|
||||
}
|
||||
`
|
||||
|
||||
const Button = styled.button<{ isActive: boolean }>`
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
|
||||
:not([disabled]):hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
${({ isActive }) => isActive && `opacity: 0.7`}
|
||||
`
|
||||
|
||||
const IconContainer = styled(Row)`
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
`
|
||||
|
||||
const IconContainerWithSlippage = styled(IconContainer)<{ displayWarning?: boolean }>`
|
||||
div {
|
||||
color: ${({ theme, displayWarning }) => (displayWarning ? theme.accentWarning : theme.textSecondary)};
|
||||
}
|
||||
|
||||
background-color: ${({ theme, displayWarning }) =>
|
||||
displayWarning ? theme.accentWarningSoft : theme.backgroundModule};
|
||||
`
|
||||
|
||||
const ButtonContent = () => {
|
||||
const [userSlippageTolerance] = useUserSlippageTolerance()
|
||||
|
||||
if (userSlippageTolerance === SlippageTolerance.Auto) {
|
||||
return (
|
||||
<IconContainer>
|
||||
<Icon />
|
||||
</IconContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const isInvalidSlippage = validateUserSlippageTolerance(userSlippageTolerance) !== SlippageValidationResult.Valid
|
||||
|
||||
return (
|
||||
<IconContainerWithSlippage data-testid="settings-icon-with-slippage" gap="sm" displayWarning={isInvalidSlippage}>
|
||||
<ThemedText.Caption>
|
||||
<Trans>{userSlippageTolerance.toFixed(2)}% slippage</Trans>
|
||||
</ThemedText.Caption>
|
||||
<Icon />
|
||||
</IconContainerWithSlippage>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MenuButton({
|
||||
disabled,
|
||||
onClick,
|
||||
isActive,
|
||||
}: {
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
isActive: boolean
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
isActive={isActive}
|
||||
id="open-settings-dialog-button"
|
||||
data-testid="open-settings-dialog-button"
|
||||
aria-label={t`Transaction Settings`}
|
||||
>
|
||||
<ButtonContent />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
81
src/components/Settings/RouterPreferenceSettings/index.tsx
Normal file
81
src/components/Settings/RouterPreferenceSettings/index.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import Column from 'components/Column'
|
||||
import Radio from 'components/Radio'
|
||||
import { RowBetween, RowFixed } from 'components/Row'
|
||||
import Toggle from 'components/Toggle'
|
||||
import { RouterPreference } from 'state/routing/slice'
|
||||
import { useRouterPreference } from 'state/user/hooks'
|
||||
import styled from 'styled-components/macro'
|
||||
import { ThemedText } from 'theme'
|
||||
|
||||
const Preference = styled(Radio)`
|
||||
background-color: ${({ theme }) => theme.backgroundModule};
|
||||
padding: 12px 16px;
|
||||
`
|
||||
|
||||
const PreferencesContainer = styled(Column)`
|
||||
gap: 1.5px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
export default function RouterPreferenceSettings() {
|
||||
const [routerPreference, setRouterPreference] = useRouterPreference()
|
||||
|
||||
const isAutoRoutingActive = routerPreference === RouterPreference.AUTO
|
||||
|
||||
return (
|
||||
<Column gap="md">
|
||||
<RowBetween gap="sm">
|
||||
<RowFixed>
|
||||
<Column gap="xs">
|
||||
<ThemedText.BodySecondary>
|
||||
<Trans>Auto Router API</Trans>
|
||||
</ThemedText.BodySecondary>
|
||||
<ThemedText.Caption color="textSecondary">
|
||||
<Trans>Use the Uniswap Labs API to get faster quotes.</Trans>
|
||||
</ThemedText.Caption>
|
||||
</Column>
|
||||
</RowFixed>
|
||||
<Toggle
|
||||
id="toggle-optimized-router-button"
|
||||
isActive={isAutoRoutingActive}
|
||||
toggle={() => setRouterPreference(isAutoRoutingActive ? RouterPreference.API : RouterPreference.AUTO)}
|
||||
/>
|
||||
</RowBetween>
|
||||
{!isAutoRoutingActive && (
|
||||
<PreferencesContainer>
|
||||
<Preference
|
||||
isActive={routerPreference === RouterPreference.API}
|
||||
toggle={() => setRouterPreference(RouterPreference.API)}
|
||||
>
|
||||
<Column gap="xs">
|
||||
<ThemedText.BodyPrimary>
|
||||
<Trans>Uniswap API</Trans>
|
||||
</ThemedText.BodyPrimary>
|
||||
<ThemedText.Caption color="textSecondary">
|
||||
<Trans>Finds the best route on the Uniswap Protocol using the Uniswap Labs Routing API.</Trans>
|
||||
</ThemedText.Caption>
|
||||
</Column>
|
||||
</Preference>
|
||||
<Preference
|
||||
isActive={routerPreference === RouterPreference.CLIENT}
|
||||
toggle={() => setRouterPreference(RouterPreference.CLIENT)}
|
||||
>
|
||||
<Column gap="xs">
|
||||
<ThemedText.BodyPrimary>
|
||||
<Trans>Uniswap client</Trans>
|
||||
</ThemedText.BodyPrimary>
|
||||
<ThemedText.Caption color="textSecondary">
|
||||
<Trans>
|
||||
Finds the best route on the Uniswap Protocol through your browser. May result in high latency and
|
||||
prices.
|
||||
</Trans>
|
||||
</ThemedText.Caption>
|
||||
</Column>
|
||||
</Preference>
|
||||
</PreferencesContainer>
|
||||
)}
|
||||
</Column>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc'
|
||||
import store from 'state'
|
||||
import { updateUserDeadline } from 'state/user/reducer'
|
||||
import { fireEvent, render, screen } from 'test-utils/render'
|
||||
|
||||
import TransactionDeadlineSettings from '.'
|
||||
|
||||
const renderAndExpandTransactionDeadlineSettings = () => {
|
||||
render(<TransactionDeadlineSettings />)
|
||||
|
||||
// By default, the button to expand Slippage component and show `input` will have `<deadline>m` label
|
||||
fireEvent.click(screen.getByText(`${DEFAULT_DEADLINE_FROM_NOW / 60}m`))
|
||||
}
|
||||
|
||||
const getDeadlineInput = () => screen.getByTestId('deadline-input') as HTMLInputElement
|
||||
|
||||
describe('TransactionDeadlineSettings', () => {
|
||||
describe('input', () => {
|
||||
// Restore to default transaction deadline before each unit test
|
||||
beforeEach(() => {
|
||||
store.dispatch(updateUserDeadline({ userDeadline: DEFAULT_DEADLINE_FROM_NOW }))
|
||||
})
|
||||
it('does not render default deadline as a value, but a placeholder', () => {
|
||||
renderAndExpandTransactionDeadlineSettings()
|
||||
expect(getDeadlineInput().value).toBe('')
|
||||
})
|
||||
it('renders custom deadline above the input', () => {
|
||||
renderAndExpandTransactionDeadlineSettings()
|
||||
|
||||
fireEvent.change(getDeadlineInput(), { target: { value: '50' } })
|
||||
|
||||
expect(screen.queryAllByText('50m').length).toEqual(1)
|
||||
})
|
||||
it('marks deadline as invalid if it is greater than 4320m (3 days) or 0m', () => {
|
||||
renderAndExpandTransactionDeadlineSettings()
|
||||
|
||||
const input = getDeadlineInput()
|
||||
fireEvent.change(input, { target: { value: '4321' } })
|
||||
fireEvent.change(input, { target: { value: '0' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
it('clears errors on blur and overwrites incorrect value with the latest correct value', () => {
|
||||
renderAndExpandTransactionDeadlineSettings()
|
||||
|
||||
const input = getDeadlineInput()
|
||||
fireEvent.change(input, { target: { value: '5' } })
|
||||
fireEvent.change(input, { target: { value: '4321' } })
|
||||
|
||||
// Label renders latest correct value, at this point input is higlighted as invalid
|
||||
expect(screen.queryAllByText('5m').length).toEqual(1)
|
||||
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(input.value).toBe('5')
|
||||
})
|
||||
it('does not accept non-numerical values', () => {
|
||||
renderAndExpandTransactionDeadlineSettings()
|
||||
|
||||
const input = getDeadlineInput()
|
||||
fireEvent.change(input, { target: { value: 'c' } })
|
||||
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import Expand from 'components/Expand'
|
||||
import QuestionHelper from 'components/QuestionHelper'
|
||||
import Row from 'components/Row'
|
||||
import { Input, InputContainer } from 'components/Settings/Input'
|
||||
import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc'
|
||||
import ms from 'ms.macro'
|
||||
import React, { useState } from 'react'
|
||||
import { useUserTransactionTTL } from 'state/user/hooks'
|
||||
import { ThemedText } from 'theme'
|
||||
|
||||
enum DeadlineError {
|
||||
InvalidInput = 'InvalidInput',
|
||||
}
|
||||
|
||||
const THREE_DAYS_IN_SECONDS = ms`3 days` / 1000
|
||||
const NUMBERS_ONLY = /^[0-9\b]+$/
|
||||
|
||||
export default function TransactionDeadlineSettings() {
|
||||
const [deadline, setDeadline] = useUserTransactionTTL()
|
||||
|
||||
const defaultInputValue = deadline && deadline !== DEFAULT_DEADLINE_FROM_NOW ? (deadline / 60).toString() : ''
|
||||
|
||||
// If user has previously entered a custom deadline, we want to show that value in the input field
|
||||
// instead of a placeholder by defualt
|
||||
const [deadlineInput, setDeadlineInput] = useState(defaultInputValue)
|
||||
const [deadlineError, setDeadlineError] = useState<DeadlineError | false>(false)
|
||||
|
||||
function parseCustomDeadline(value: string) {
|
||||
// Do not allow non-numerical characters in the input field
|
||||
if (value.length > 0 && !NUMBERS_ONLY.test(value)) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeadlineInput(value)
|
||||
setDeadlineError(false)
|
||||
|
||||
// If the input is empty, set the deadline to the default
|
||||
if (value.length === 0) {
|
||||
setDeadline(DEFAULT_DEADLINE_FROM_NOW)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse user input and set the deadline if valid, error otherwise
|
||||
try {
|
||||
const parsed: number = Number.parseInt(value) * 60
|
||||
if (parsed === 0 || parsed > THREE_DAYS_IN_SECONDS) {
|
||||
setDeadlineError(DeadlineError.InvalidInput)
|
||||
} else {
|
||||
setDeadline(parsed)
|
||||
}
|
||||
} catch (error) {
|
||||
setDeadlineError(DeadlineError.InvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Expand
|
||||
testId="transaction-deadline-settings"
|
||||
header={
|
||||
<Row width="auto">
|
||||
<ThemedText.BodySecondary>
|
||||
<Trans>Transaction deadline</Trans>
|
||||
</ThemedText.BodySecondary>
|
||||
<QuestionHelper
|
||||
text={<Trans>Your transaction will revert if it is pending for more than this period of time.</Trans>}
|
||||
/>
|
||||
</Row>
|
||||
}
|
||||
button={<Trans>{deadline / 60}m</Trans>}
|
||||
>
|
||||
<Row>
|
||||
<InputContainer gap="md" error={!!deadlineError}>
|
||||
<Input
|
||||
data-testid="deadline-input"
|
||||
placeholder={(DEFAULT_DEADLINE_FROM_NOW / 60).toString()}
|
||||
value={deadlineInput}
|
||||
onChange={(e) => parseCustomDeadline(e.target.value)}
|
||||
onBlur={() => {
|
||||
// When the input field is blurred, reset the input field to the current deadline
|
||||
setDeadlineInput(defaultInputValue)
|
||||
setDeadlineError(false)
|
||||
}}
|
||||
/>
|
||||
<ThemedText.BodyPrimary>
|
||||
<Trans>minutes</Trans>
|
||||
</ThemedText.BodyPrimary>
|
||||
</InputContainer>
|
||||
</Row>
|
||||
</Expand>
|
||||
)
|
||||
}
|
||||
@@ -1,261 +1,74 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { t, Trans } from '@lingui/macro'
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import { sendEvent } from 'components/analytics'
|
||||
import { AutoColumn } from 'components/Column'
|
||||
import { L2_CHAIN_IDS } from 'constants/chains'
|
||||
import { useOnClickOutside } from 'hooks/useOnClickOutside'
|
||||
import { isSupportedChainId } from 'lib/hooks/routing/clientSideSmartOrderRouter'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Settings, X } from 'react-feather'
|
||||
import { Text } from 'rebass'
|
||||
import styled, { useTheme } from 'styled-components/macro'
|
||||
import { useRef } from 'react'
|
||||
import { useModalIsOpen, useToggleSettingsMenu } from 'state/application/hooks'
|
||||
import { ApplicationModal } from 'state/application/reducer'
|
||||
import styled from 'styled-components/macro'
|
||||
import { Divider } from 'theme'
|
||||
|
||||
import { useOnClickOutside } from '../../hooks/useOnClickOutside'
|
||||
import { useModalIsOpen, useToggleSettingsMenu } from '../../state/application/hooks'
|
||||
import { ApplicationModal } from '../../state/application/reducer'
|
||||
import { useClientSideRouter, useExpertModeManager } from '../../state/user/hooks'
|
||||
import { ThemedText } from '../../theme'
|
||||
import { ButtonError } from '../Button'
|
||||
import { AutoColumn } from '../Column'
|
||||
import Modal from '../Modal'
|
||||
import QuestionHelper from '../QuestionHelper'
|
||||
import { RowBetween, RowFixed } from '../Row'
|
||||
import Toggle from '../Toggle'
|
||||
import TransactionSettings from '../TransactionSettings'
|
||||
import MaxSlippageSettings from './MaxSlippageSettings'
|
||||
import MenuButton from './MenuButton'
|
||||
import RouterPreferenceSettings from './RouterPreferenceSettings'
|
||||
import TransactionDeadlineSettings from './TransactionDeadlineSettings'
|
||||
|
||||
const StyledMenuIcon = styled(Settings)`
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
|
||||
> * {
|
||||
stroke: ${({ theme }) => theme.textSecondary};
|
||||
}
|
||||
`
|
||||
|
||||
const StyledCloseIcon = styled(X)`
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
> * {
|
||||
stroke: ${({ theme }) => theme.textSecondary};
|
||||
}
|
||||
`
|
||||
|
||||
const StyledMenuButton = styled.button<{ disabled: boolean }>`
|
||||
const Menu = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 0.5rem;
|
||||
height: 20px;
|
||||
|
||||
${({ disabled }) =>
|
||||
!disabled &&
|
||||
`
|
||||
:hover,
|
||||
:focus {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
`}
|
||||
`
|
||||
const EmojiWrapper = styled.div`
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
right: 0px;
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
const StyledMenu = styled.div`
|
||||
margin-left: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
border: none;
|
||||
text-align: left;
|
||||
`
|
||||
|
||||
const MenuFlyout = styled.span`
|
||||
const MenuFlyout = styled(AutoColumn)`
|
||||
min-width: 20.125rem;
|
||||
background-color: ${({ theme }) => theme.backgroundSurface};
|
||||
border: 1px solid ${({ theme }) => theme.backgroundOutline};
|
||||
box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.01), 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 16px 24px rgba(0, 0, 0, 0.04),
|
||||
0px 24px 32px rgba(0, 0, 0, 0.01);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 1rem;
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
right: 0rem;
|
||||
top: 100%;
|
||||
margin-top: 10px;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
color: ${({ theme }) => theme.textPrimary};
|
||||
|
||||
${({ theme }) => theme.deprecated_mediaWidth.deprecated_upToMedium`
|
||||
min-width: 18.125rem;
|
||||
`};
|
||||
|
||||
user-select: none;
|
||||
gap: 16px;
|
||||
padding: 1rem;
|
||||
`
|
||||
|
||||
const Break = styled.div`
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: ${({ theme }) => theme.deprecated_bg3};
|
||||
`
|
||||
|
||||
const ModalContentWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 0;
|
||||
background-color: ${({ theme }) => theme.backgroundInteractive};
|
||||
border-radius: 20px;
|
||||
`
|
||||
|
||||
export default function SettingsTab({ placeholderSlippage }: { placeholderSlippage: Percent }) {
|
||||
export default function SettingsTab({ autoSlippage }: { autoSlippage: Percent }) {
|
||||
const { chainId } = useWeb3React()
|
||||
const showDeadlineSettings = Boolean(chainId && !L2_CHAIN_IDS.includes(chainId))
|
||||
|
||||
const node = useRef<HTMLDivElement | null>(null)
|
||||
const open = useModalIsOpen(ApplicationModal.SETTINGS)
|
||||
const toggle = useToggleSettingsMenu()
|
||||
const isOpen = useModalIsOpen(ApplicationModal.SETTINGS)
|
||||
|
||||
const theme = useTheme()
|
||||
const toggleMenu = useToggleSettingsMenu()
|
||||
useOnClickOutside(node, isOpen ? toggleMenu : undefined)
|
||||
|
||||
const [expertMode, toggleExpertMode] = useExpertModeManager()
|
||||
|
||||
const [clientSideRouter, setClientSideRouter] = useClientSideRouter()
|
||||
|
||||
// show confirmation view before turning on
|
||||
const [showConfirmation, setShowConfirmation] = useState(false)
|
||||
|
||||
useOnClickOutside(node, open ? toggle : undefined)
|
||||
const isSupportedChain = isSupportedChainId(chainId)
|
||||
|
||||
return (
|
||||
<StyledMenu ref={node}>
|
||||
<Modal isOpen={showConfirmation} onDismiss={() => setShowConfirmation(false)} maxHeight={100}>
|
||||
<ModalContentWrapper>
|
||||
<AutoColumn gap="lg">
|
||||
<RowBetween style={{ padding: '0 2rem' }}>
|
||||
<div />
|
||||
<Text fontWeight={500} fontSize={20}>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</Text>
|
||||
<StyledCloseIcon onClick={() => setShowConfirmation(false)} />
|
||||
</RowBetween>
|
||||
<Break />
|
||||
<AutoColumn gap="lg" style={{ padding: '0 2rem' }}>
|
||||
<Text fontWeight={500} fontSize={20}>
|
||||
<Trans>
|
||||
Expert mode turns off the confirm transaction prompt and allows high slippage trades that often result
|
||||
in bad rates and lost funds.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text fontWeight={600} fontSize={20}>
|
||||
<Trans>ONLY USE THIS MODE IF YOU KNOW WHAT YOU ARE DOING.</Trans>
|
||||
</Text>
|
||||
<ButtonError
|
||||
error={true}
|
||||
padding="12px"
|
||||
onClick={() => {
|
||||
const confirmWord = t`confirm`
|
||||
if (window.prompt(t`Please type the word "${confirmWord}" to enable expert mode.`) === confirmWord) {
|
||||
toggleExpertMode()
|
||||
setShowConfirmation(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text fontSize={20} fontWeight={500} id="confirm-expert-mode">
|
||||
<Trans>Turn On Expert Mode</Trans>
|
||||
</Text>
|
||||
</ButtonError>
|
||||
</AutoColumn>
|
||||
</AutoColumn>
|
||||
</ModalContentWrapper>
|
||||
</Modal>
|
||||
<StyledMenuButton
|
||||
disabled={!isSupportedChainId(chainId)}
|
||||
onClick={toggle}
|
||||
id="open-settings-dialog-button"
|
||||
aria-label={t`Transaction Settings`}
|
||||
>
|
||||
<StyledMenuIcon data-testid="swap-settings-button" />
|
||||
{expertMode ? (
|
||||
<EmojiWrapper>
|
||||
<span role="img" aria-label="wizard-icon">
|
||||
🧙
|
||||
</span>
|
||||
</EmojiWrapper>
|
||||
) : null}
|
||||
</StyledMenuButton>
|
||||
{open && (
|
||||
<Menu ref={node}>
|
||||
<MenuButton disabled={!isSupportedChain} isActive={isOpen} onClick={toggleMenu} />
|
||||
{isOpen && (
|
||||
<MenuFlyout>
|
||||
<AutoColumn gap="md" style={{ padding: '1rem' }}>
|
||||
<Text fontWeight={600} fontSize={14}>
|
||||
<Trans>Settings</Trans>
|
||||
</Text>
|
||||
<TransactionSettings placeholderSlippage={placeholderSlippage} />
|
||||
<Text fontWeight={600} fontSize={14}>
|
||||
<Trans>Interface Settings</Trans>
|
||||
</Text>
|
||||
{isSupportedChainId(chainId) && (
|
||||
<RowBetween>
|
||||
<RowFixed>
|
||||
<ThemedText.DeprecatedBlack fontWeight={400} fontSize={14} color={theme.textSecondary}>
|
||||
<Trans>Auto Router API</Trans>
|
||||
</ThemedText.DeprecatedBlack>
|
||||
<QuestionHelper text={<Trans>Use the Uniswap Labs API to get faster quotes.</Trans>} />
|
||||
</RowFixed>
|
||||
<Toggle
|
||||
id="toggle-optimized-router-button"
|
||||
isActive={!clientSideRouter}
|
||||
toggle={() => {
|
||||
sendEvent({
|
||||
category: 'Routing',
|
||||
action: clientSideRouter ? 'enable routing API' : 'disable routing API',
|
||||
})
|
||||
setClientSideRouter(!clientSideRouter)
|
||||
}}
|
||||
/>
|
||||
</RowBetween>
|
||||
)}
|
||||
<RowBetween>
|
||||
<RowFixed>
|
||||
<ThemedText.DeprecatedBlack fontWeight={400} fontSize={14} color={theme.textSecondary}>
|
||||
<Trans>Expert Mode</Trans>
|
||||
</ThemedText.DeprecatedBlack>
|
||||
<QuestionHelper
|
||||
text={
|
||||
<Trans>Allow high price impact trades and skip the confirm screen. Use at your own risk.</Trans>
|
||||
}
|
||||
/>
|
||||
</RowFixed>
|
||||
<Toggle
|
||||
id="toggle-expert-mode-button"
|
||||
isActive={expertMode}
|
||||
toggle={
|
||||
expertMode
|
||||
? () => {
|
||||
toggleExpertMode()
|
||||
setShowConfirmation(false)
|
||||
}
|
||||
: () => {
|
||||
toggle()
|
||||
setShowConfirmation(true)
|
||||
}
|
||||
}
|
||||
/>
|
||||
</RowBetween>
|
||||
</AutoColumn>
|
||||
<RouterPreferenceSettings />
|
||||
<Divider />
|
||||
<MaxSlippageSettings autoSlippage={autoSlippage} />
|
||||
{showDeadlineSettings && (
|
||||
<>
|
||||
<Divider />
|
||||
<TransactionDeadlineSettings />
|
||||
</>
|
||||
)}
|
||||
</MenuFlyout>
|
||||
)}
|
||||
</StyledMenu>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { formatCurrencyAmount, NumberType } from '@uniswap/conedison/format'
|
||||
import { Currency } from '@uniswap/sdk-core'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import { NATIVE_CHAIN_ID } from 'constants/tokens'
|
||||
import { useDummyGateEnabled } from 'featureFlags/flags/dummyFeatureGate'
|
||||
import { CHAIN_ID_TO_BACKEND_NAME } from 'graphql/data/util'
|
||||
import { useStablecoinValue } from 'hooks/useStablecoinPrice'
|
||||
import useCurrencyBalance from 'lib/hooks/useCurrencyBalance'
|
||||
@@ -88,7 +87,6 @@ export default function MobileBalanceSummaryFooter({ token }: { token: Currency
|
||||
const formattedBalance = formatCurrencyAmount(balance, NumberType.TokenNonTx)
|
||||
const formattedUsdValue = formatCurrencyAmount(useStablecoinValue(balance), NumberType.FiatTokenStats)
|
||||
const chain = CHAIN_ID_TO_BACKEND_NAME[token.chainId].toLowerCase()
|
||||
const isDummyGateFlagEnabled = useDummyGateEnabled()
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
@@ -104,7 +102,7 @@ export default function MobileBalanceSummaryFooter({ token }: { token: Currency
|
||||
</BalanceInfo>
|
||||
)}
|
||||
<SwapButton to={`/swap?chainName=${chain}&outputCurrency=${token.isNative ? NATIVE_CHAIN_ID : token.address}`}>
|
||||
<Trans>{isDummyGateFlagEnabled ? 'Go to Swap' : 'Swap'}</Trans>
|
||||
<Trans>Swap</Trans>
|
||||
</SwapButton>
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
@@ -262,11 +262,9 @@ export function PriceChart({ width, height, prices: originalPrices, timePeriod }
|
||||
const crosshairEdgeMax = width * 0.85
|
||||
const crosshairAtEdge = !!crosshair && crosshair > crosshairEdgeMax
|
||||
|
||||
/*
|
||||
* Default curve doesn't look good for the HOUR chart.
|
||||
* Higher values make the curve more rigid, lower values smooth the curve but make it less "sticky" to real data points,
|
||||
* making it unacceptable for shorter durations / smaller variances.
|
||||
*/
|
||||
// Default curve doesn't look good for the HOUR chart.
|
||||
// Higher values make the curve more rigid, lower values smooth the curve but make it less "sticky" to real data points,
|
||||
// making it unacceptable for shorter durations / smaller variances.
|
||||
const curveTension = timePeriod === TimePeriod.HOUR ? 1 : 0.9
|
||||
|
||||
const getX = useMemo(() => (p: PricePoint) => timeScale(p.timestamp), [timeScale])
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { WidgetSkeleton } from 'components/Widget'
|
||||
import { DEFAULT_WIDGET_WIDTH } from 'components/Widget'
|
||||
import { SwapSkeleton } from 'components/swap/SwapSkeleton'
|
||||
import { ArrowLeft } from 'react-feather'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import styled, { useTheme } from 'styled-components/macro'
|
||||
@@ -11,6 +10,8 @@ import { BreadcrumbNavLink } from './BreadcrumbNavLink'
|
||||
import { TokenPrice } from './PriceChart'
|
||||
import { StatPair, StatsWrapper, StatWrapper } from './StatsSection'
|
||||
|
||||
const SWAP_COMPONENT_WIDTH = 360
|
||||
|
||||
export const Hr = styled.hr`
|
||||
background-color: ${({ theme }) => theme.backgroundOutline};
|
||||
border: none;
|
||||
@@ -43,7 +44,7 @@ export const RightPanel = styled.div`
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
width: ${DEFAULT_WIDGET_WIDTH}px;
|
||||
width: ${SWAP_COMPONENT_WIDTH}px;
|
||||
|
||||
@media screen and (min-width: ${({ theme }) => theme.breakpoint.lg}px) {
|
||||
display: flex;
|
||||
@@ -260,7 +261,7 @@ export function TokenDetailsPageSkeleton() {
|
||||
<TokenDetailsLayout>
|
||||
<TokenDetailsSkeleton />
|
||||
<RightPanel>
|
||||
<WidgetSkeleton />
|
||||
<SwapSkeleton />
|
||||
</RightPanel>
|
||||
</TokenDetailsLayout>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { Trace } from '@uniswap/analytics'
|
||||
import { InterfacePageName } from '@uniswap/analytics-events'
|
||||
import { Currency, Field } from '@uniswap/widgets'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import CurrencyLogo from 'components/Logo/CurrencyLogo'
|
||||
import { AboutSection } from 'components/Tokens/TokenDetails/About'
|
||||
@@ -22,23 +21,23 @@ import TokenDetailsSkeleton, {
|
||||
import StatsSection from 'components/Tokens/TokenDetails/StatsSection'
|
||||
import TokenSafetyMessage from 'components/TokenSafety/TokenSafetyMessage'
|
||||
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
|
||||
import Widget from 'components/Widget'
|
||||
import { SwapTokens } from 'components/Widget/inputs'
|
||||
import { NATIVE_CHAIN_ID, nativeOnChain } from 'constants/tokens'
|
||||
import { checkWarning } from 'constants/tokenSafety'
|
||||
import { TokenPriceQuery } from 'graphql/data/__generated__/types-and-hooks'
|
||||
import { Chain, TokenQuery, TokenQueryData } from 'graphql/data/Token'
|
||||
import { QueryToken } from 'graphql/data/Token'
|
||||
import { CHAIN_NAME_TO_CHAIN_ID, getTokenDetailsURL } from 'graphql/data/util'
|
||||
import { useIsUserAddedTokenOnChain } from 'hooks/Tokens'
|
||||
import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
|
||||
import { UNKNOWN_TOKEN_SYMBOL, useTokenFromActiveNetwork } from 'lib/hooks/useCurrency'
|
||||
import { getTokenAddress } from 'lib/utils/analytics'
|
||||
import { Swap } from 'pages/Swap'
|
||||
import { useCallback, useMemo, useState, useTransition } from 'react'
|
||||
import { ArrowLeft } from 'react-feather'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Field } from 'state/swap/actions'
|
||||
import { SwapState } from 'state/swap/reducer'
|
||||
import styled from 'styled-components/macro'
|
||||
import { isAddress } from 'utils'
|
||||
import { addressesAreEquivalent } from 'utils/addressesAreEquivalent'
|
||||
|
||||
import { OnChangeTimePeriod } from './ChartSection'
|
||||
import InvalidTokenDetails from './InvalidTokenDetails'
|
||||
@@ -111,6 +110,7 @@ export default function TokenDetails({
|
||||
[urlAddress]
|
||||
)
|
||||
|
||||
const { chainId: connectedChainId } = useWeb3React()
|
||||
const pageChainId = CHAIN_NAME_TO_CHAIN_ID[chain]
|
||||
|
||||
const tokenQueryData = tokenQuery.token
|
||||
@@ -124,7 +124,6 @@ export default function TokenDetails({
|
||||
)
|
||||
|
||||
const { token: detailedToken, didFetchFromChain } = useRelevantToken(address, pageChainId, tokenQueryData)
|
||||
const { token: inputToken } = useRelevantToken(inputTokenAddress, pageChainId, undefined)
|
||||
|
||||
const tokenWarning = address ? checkWarning(address) : null
|
||||
const isBlockedToken = tokenWarning?.canProceed === false
|
||||
@@ -146,34 +145,39 @@ export default function TokenDetails({
|
||||
)
|
||||
useOnGlobalChainSwitch(navigateToTokenForChain)
|
||||
|
||||
const navigateToWidgetSelectedToken = useCallback(
|
||||
(tokens: SwapTokens) => {
|
||||
const newDefaultToken = tokens[Field.OUTPUT] ?? tokens.default
|
||||
const address = newDefaultToken?.isNative ? NATIVE_CHAIN_ID : newDefaultToken?.address
|
||||
const handleCurrencyChange = useCallback(
|
||||
(tokens: Pick<SwapState, Field.INPUT | Field.OUTPUT>) => {
|
||||
if (
|
||||
addressesAreEquivalent(tokens[Field.INPUT]?.currencyId, address) ||
|
||||
addressesAreEquivalent(tokens[Field.OUTPUT]?.currencyId, address)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const newDefaultTokenID = tokens[Field.OUTPUT]?.currencyId ?? tokens[Field.INPUT]?.currencyId
|
||||
startTokenTransition(() =>
|
||||
navigate(
|
||||
getTokenDetailsURL({
|
||||
address,
|
||||
// The function falls back to "NATIVE" if the address is null
|
||||
address: newDefaultTokenID === 'ETH' ? null : newDefaultTokenID,
|
||||
chain,
|
||||
inputAddress: tokens[Field.INPUT] ? getTokenAddress(tokens[Field.INPUT] as Currency) : null,
|
||||
inputAddress:
|
||||
// If only one token was selected before we navigate, then it was the default token and it's being replaced.
|
||||
// On the new page, the *new* default token becomes the output, and we don't have another option to set as the input token.
|
||||
tokens[Field.INPUT] && tokens[Field.INPUT]?.currencyId !== newDefaultTokenID
|
||||
? tokens[Field.INPUT]?.currencyId
|
||||
: null,
|
||||
})
|
||||
)
|
||||
)
|
||||
},
|
||||
[chain, navigate]
|
||||
[address, chain, navigate]
|
||||
)
|
||||
|
||||
const [continueSwap, setContinueSwap] = useState<{ resolve: (value: boolean | PromiseLike<boolean>) => void }>()
|
||||
|
||||
const [openTokenSafetyModal, setOpenTokenSafetyModal] = useState(false)
|
||||
|
||||
// Show token safety modal if Swap-reviewing a warning token, at all times if the current token is blocked
|
||||
const shouldShowSpeedbump = !useIsUserAddedTokenOnChain(address, pageChainId) && tokenWarning !== null
|
||||
const onReviewSwapClick = useCallback(
|
||||
() => new Promise<boolean>((resolve) => (shouldShowSpeedbump ? setContinueSwap({ resolve }) : resolve(true))),
|
||||
[shouldShowSpeedbump]
|
||||
)
|
||||
|
||||
const onResolveSwap = useCallback(
|
||||
(value: boolean) => {
|
||||
continueSwap?.resolve(value)
|
||||
@@ -234,13 +238,14 @@ export default function TokenDetails({
|
||||
|
||||
<RightPanel onClick={() => isBlockedToken && setOpenTokenSafetyModal(true)}>
|
||||
<div style={{ pointerEvents: isBlockedToken ? 'none' : 'auto' }}>
|
||||
<Widget
|
||||
defaultTokens={{
|
||||
[Field.INPUT]: inputToken ?? undefined,
|
||||
default: detailedToken ?? undefined,
|
||||
<Swap
|
||||
chainId={pageChainId}
|
||||
prefilledState={{
|
||||
[Field.INPUT]: { currencyId: inputTokenAddress },
|
||||
[Field.OUTPUT]: { currencyId: address === NATIVE_CHAIN_ID ? 'ETH' : address },
|
||||
}}
|
||||
onDefaultTokenChange={navigateToWidgetSelectedToken}
|
||||
onReviewSwapClick={onReviewSwapClick}
|
||||
onCurrencyChange={handleCurrencyChange}
|
||||
disableTokenInputs={pageChainId !== connectedChainId}
|
||||
/>
|
||||
</div>
|
||||
{tokenWarning && <TokenSafetyMessage tokenAddress={address} warning={tokenWarning} />}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { transparentize } from 'polished'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react'
|
||||
import styled from 'styled-components/macro'
|
||||
import noop from 'utils/noop'
|
||||
|
||||
import Popover, { PopoverProps } from '../Popover'
|
||||
|
||||
export const TooltipContainer = styled.div`
|
||||
max-width: 256px;
|
||||
export enum TooltipSize {
|
||||
Small = '256px',
|
||||
Large = '400px',
|
||||
}
|
||||
|
||||
const getPaddingForSize = (size: TooltipSize) => {
|
||||
switch (size) {
|
||||
case TooltipSize.Small:
|
||||
return '12px'
|
||||
case TooltipSize.Large:
|
||||
return '16px 20px'
|
||||
}
|
||||
}
|
||||
|
||||
const TooltipContainer = styled.div<{ size: TooltipSize }>`
|
||||
max-width: ${({ size }) => size};
|
||||
width: calc(100vw - 16px);
|
||||
cursor: default;
|
||||
padding: 0.6rem 1rem;
|
||||
padding: ${({ size }) => getPaddingForSize(size)};
|
||||
pointer-events: auto;
|
||||
|
||||
color: ${({ theme }) => theme.textPrimary};
|
||||
@@ -23,30 +38,23 @@ export const TooltipContainer = styled.div`
|
||||
box-shadow: 0 4px 8px 0 ${({ theme }) => transparentize(0.9, theme.shadow1)};
|
||||
`
|
||||
|
||||
interface TooltipProps extends Omit<PopoverProps, 'content'> {
|
||||
type TooltipProps = Omit<PopoverProps, 'content'> & {
|
||||
text: ReactNode
|
||||
open?: () => void
|
||||
close?: () => void
|
||||
disableHover?: boolean // disable the hover and content display
|
||||
size?: TooltipSize
|
||||
disabled?: boolean
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
interface TooltipContentProps extends Omit<PopoverProps, 'content'> {
|
||||
content: ReactNode
|
||||
onOpen?: () => void
|
||||
open?: () => void
|
||||
close?: () => void
|
||||
// whether to wrap the content in a `TooltipContainer`
|
||||
wrap?: boolean
|
||||
disableHover?: boolean // disable the hover and content display
|
||||
}
|
||||
|
||||
export default function Tooltip({ text, open, close, disableHover, ...rest }: TooltipProps) {
|
||||
// TODO(WEB-3305)
|
||||
// Migrate to MouseoverTooltip and move this component inline to MouseoverTooltip
|
||||
export default function Tooltip({ text, open, close, disabled, size = TooltipSize.Small, ...rest }: TooltipProps) {
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
text && (
|
||||
<TooltipContainer onMouseEnter={disableHover ? noop : open} onMouseLeave={disableHover ? noop : close}>
|
||||
<TooltipContainer size={size} onMouseEnter={disabled ? noop : open} onMouseLeave={disabled ? noop : close}>
|
||||
{text}
|
||||
</TooltipContainer>
|
||||
)
|
||||
@@ -56,27 +64,24 @@ export default function Tooltip({ text, open, close, disableHover, ...rest }: To
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipContent({ content, wrap = false, open, close, disableHover, ...rest }: TooltipContentProps) {
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
wrap ? (
|
||||
<TooltipContainer onMouseEnter={disableHover ? noop : open} onMouseLeave={disableHover ? noop : close}>
|
||||
{content}
|
||||
</TooltipContainer>
|
||||
) : (
|
||||
content
|
||||
)
|
||||
}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// TODO(WEB-3305)
|
||||
// Do not pass through PopoverProps. Prefer higher-level interface to control MouseoverTooltip.
|
||||
type MouseoverTooltipProps = Omit<PopoverProps, 'content' | 'show'> &
|
||||
PropsWithChildren<{
|
||||
text: ReactNode
|
||||
size?: TooltipSize
|
||||
disabled?: boolean
|
||||
timeout?: number
|
||||
placement?: PopoverProps['placement']
|
||||
onOpen?: () => void
|
||||
}>
|
||||
|
||||
/** Standard text tooltip. */
|
||||
export function MouseoverTooltip({ text, disableHover, children, timeout, ...rest }: Omit<TooltipProps, 'show'>) {
|
||||
export function MouseoverTooltip({ text, disabled, children, onOpen, timeout, ...rest }: MouseoverTooltipProps) {
|
||||
const [show, setShow] = useState(false)
|
||||
const open = () => text && setShow(true)
|
||||
const open = () => {
|
||||
setShow(true)
|
||||
onOpen?.()
|
||||
}
|
||||
const close = () => setShow(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -93,49 +98,10 @@ export function MouseoverTooltip({ text, disableHover, children, timeout, ...res
|
||||
}, [timeout, show])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
{...rest}
|
||||
open={open}
|
||||
close={close}
|
||||
disableHover={disableHover}
|
||||
show={show}
|
||||
text={disableHover ? null : text}
|
||||
>
|
||||
<div onMouseEnter={disableHover ? noop : open} onMouseLeave={disableHover || timeout ? noop : close}>
|
||||
<Tooltip {...rest} open={open} close={close} disabled={disabled} show={show} text={disabled ? null : text}>
|
||||
<div onMouseEnter={disabled ? noop : open} onMouseLeave={disabled || timeout ? noop : close}>
|
||||
{children}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tooltip that displays custom content. */
|
||||
export function MouseoverTooltipContent({
|
||||
content,
|
||||
children,
|
||||
onOpen: openCallback = undefined,
|
||||
disableHover,
|
||||
...rest
|
||||
}: Omit<TooltipContentProps, 'show'>) {
|
||||
const [show, setShow] = useState(false)
|
||||
const open = () => {
|
||||
setShow(true)
|
||||
openCallback?.()
|
||||
}
|
||||
const close = () => {
|
||||
setShow(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipContent
|
||||
{...rest}
|
||||
open={open}
|
||||
close={close}
|
||||
show={!disableHover && show}
|
||||
content={disableHover ? null : content}
|
||||
>
|
||||
<div onMouseEnter={open} onMouseLeave={close}>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@ import useAccountRiskCheck from 'hooks/useAccountRiskCheck'
|
||||
import { lazy } from 'react'
|
||||
import { useModalIsOpen, useToggleModal } from 'state/application/hooks'
|
||||
import { ApplicationModal } from 'state/application/reducer'
|
||||
import { retry } from 'utils/retry'
|
||||
|
||||
const Bag = lazy(() => import('nft/components/bag/Bag'))
|
||||
const TransactionCompleteModal = lazy(() => import('nft/components/collection/TransactionCompleteModal'))
|
||||
const AirdropModal = lazy(() => import('components/AirdropModal'))
|
||||
const Bag = lazy(() => retry(() => import('nft/components/bag/Bag')))
|
||||
const TransactionCompleteModal = lazy(() => retry(() => import('nft/components/collection/TransactionCompleteModal')))
|
||||
const AirdropModal = lazy(() => retry(() => import('components/AirdropModal')))
|
||||
|
||||
export default function TopLevelModals() {
|
||||
const addressClaimOpen = useModalIsOpen(ApplicationModal.ADDRESS_CLAIM)
|
||||
|
||||
@@ -20,7 +20,7 @@ import { TransactionSummary } from '../AccountDetails/TransactionSummary'
|
||||
import { ButtonLight, ButtonPrimary } from '../Button'
|
||||
import { AutoColumn, ColumnCenter } from '../Column'
|
||||
import Modal from '../Modal'
|
||||
import { RowBetween, RowFixed } from '../Row'
|
||||
import Row, { RowBetween, RowFixed } from '../Row'
|
||||
import AnimatedConfirmation from './AnimatedConfirmation'
|
||||
|
||||
const Wrapper = styled.div`
|
||||
@@ -28,16 +28,12 @@ const Wrapper = styled.div`
|
||||
border-radius: 20px;
|
||||
outline: 1px solid ${({ theme }) => theme.backgroundOutline};
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
`
|
||||
const Section = styled(AutoColumn)<{ inline?: boolean }>`
|
||||
padding: ${({ inline }) => (inline ? '0' : '0')};
|
||||
padding: 16px;
|
||||
`
|
||||
|
||||
const BottomSection = styled(Section)`
|
||||
const BottomSection = styled(AutoColumn)`
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
padding-bottom: 10px;
|
||||
`
|
||||
|
||||
const ConfirmedIcon = styled(ColumnCenter)<{ inline?: boolean }>`
|
||||
@@ -50,6 +46,10 @@ const StyledLogo = styled.img`
|
||||
margin-left: 6px;
|
||||
`
|
||||
|
||||
const ConfirmationModalContentWrapper = styled(AutoColumn)`
|
||||
padding-bottom: 12px;
|
||||
`
|
||||
|
||||
function ConfirmationPendingContent({
|
||||
onDismiss,
|
||||
pendingText,
|
||||
@@ -59,8 +59,6 @@ function ConfirmationPendingContent({
|
||||
pendingText: ReactNode
|
||||
inline?: boolean // not in modal
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<AutoColumn gap="md">
|
||||
@@ -74,15 +72,15 @@ function ConfirmationPendingContent({
|
||||
<CustomLightSpinner src={Circle} alt="loader" size={inline ? '40px' : '90px'} />
|
||||
</ConfirmedIcon>
|
||||
<AutoColumn gap="md" justify="center">
|
||||
<Text fontWeight={500} fontSize={20} color={theme.textPrimary} textAlign="center">
|
||||
<ThemedText.SubHeaderLarge color="textPrimary" textAlign="center">
|
||||
<Trans>Waiting for confirmation</Trans>
|
||||
</Text>
|
||||
<Text fontWeight={600} fontSize={16} color={theme.textPrimary} textAlign="center">
|
||||
</ThemedText.SubHeaderLarge>
|
||||
<ThemedText.SubHeader color="textPrimary" textAlign="center">
|
||||
{pendingText}
|
||||
</Text>
|
||||
<Text fontWeight={400} fontSize={12} color={theme.textSecondary} textAlign="center" marginBottom="12px">
|
||||
</ThemedText.SubHeader>
|
||||
<ThemedText.SubHeaderSmall color="textSecondary" textAlign="center" marginBottom="12px">
|
||||
<Trans>Confirm this transaction in your wallet</Trans>
|
||||
</Text>
|
||||
</ThemedText.SubHeaderSmall>
|
||||
</AutoColumn>
|
||||
</AutoColumn>
|
||||
</Wrapper>
|
||||
@@ -125,7 +123,7 @@ function TransactionSubmittedContent({
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Section inline={inline}>
|
||||
<AutoColumn>
|
||||
{!inline && (
|
||||
<RowBetween>
|
||||
<div />
|
||||
@@ -135,7 +133,7 @@ function TransactionSubmittedContent({
|
||||
<ConfirmedIcon inline={inline}>
|
||||
<ArrowUpCircle strokeWidth={1} size={inline ? '40px' : '75px'} color={theme.accentActive} />
|
||||
</ConfirmedIcon>
|
||||
<AutoColumn gap="md" justify="center" style={{ paddingBottom: '12px' }}>
|
||||
<ConfirmationModalContentWrapper gap="md" justify="center">
|
||||
<ThemedText.MediumHeader textAlign="center">
|
||||
<Trans>Transaction submitted</Trans>
|
||||
</ThemedText.MediumHeader>
|
||||
@@ -154,19 +152,19 @@ function TransactionSubmittedContent({
|
||||
</ButtonLight>
|
||||
)}
|
||||
<ButtonPrimary onClick={onDismiss} style={{ margin: '20px 0 0 0' }} data-testid="dismiss-tx-confirmation">
|
||||
<Text fontWeight={600} fontSize={20} color={theme.accentTextLightPrimary}>
|
||||
<ThemedText.HeadlineSmall color={theme.accentTextLightPrimary}>
|
||||
{inline ? <Trans>Return</Trans> : <Trans>Close</Trans>}
|
||||
</Text>
|
||||
</ThemedText.HeadlineSmall>
|
||||
</ButtonPrimary>
|
||||
{chainId && hash && (
|
||||
<ExternalLink href={getExplorerLink(chainId, hash, ExplorerDataType.TRANSACTION)}>
|
||||
<Text fontWeight={600} fontSize={14} color={theme.accentAction}>
|
||||
<ThemedText.Link color={theme.accentAction}>
|
||||
<Trans>View on {chainId === SupportedChainId.MAINNET ? 'Etherscan' : 'Block Explorer'}</Trans>
|
||||
</Text>
|
||||
</ThemedText.Link>
|
||||
</ExternalLink>
|
||||
)}
|
||||
</AutoColumn>
|
||||
</Section>
|
||||
</ConfirmationModalContentWrapper>
|
||||
</AutoColumn>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
@@ -184,15 +182,15 @@ export function ConfirmationModalContent({
|
||||
}) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Section>
|
||||
<RowBetween>
|
||||
<Text fontWeight={500} fontSize={16}>
|
||||
{title}
|
||||
</Text>
|
||||
<AutoColumn gap="sm">
|
||||
<Row>
|
||||
<Row justify="center" marginLeft="24px">
|
||||
<ThemedText.SubHeader>{title}</ThemedText.SubHeader>
|
||||
</Row>
|
||||
<CloseIcon onClick={onDismiss} data-cy="confirmation-close-icon" />
|
||||
</RowBetween>
|
||||
</Row>
|
||||
{topContent()}
|
||||
</Section>
|
||||
</AutoColumn>
|
||||
{bottomContent && <BottomSection gap="12px">{bottomContent()}</BottomSection>}
|
||||
</Wrapper>
|
||||
)
|
||||
@@ -202,7 +200,7 @@ export function TransactionErrorContent({ message, onDismiss }: { message: React
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<Wrapper>
|
||||
<Section>
|
||||
<AutoColumn>
|
||||
<RowBetween>
|
||||
<Text fontWeight={600} fontSize={16}>
|
||||
<Trans>Error</Trans>
|
||||
@@ -213,7 +211,7 @@ export function TransactionErrorContent({ message, onDismiss }: { message: React
|
||||
<AlertTriangle color={theme.accentCritical} style={{ strokeWidth: 1 }} size={90} />
|
||||
<ThemedText.MediumHeader textAlign="center">{message}</ThemedText.MediumHeader>
|
||||
</AutoColumn>
|
||||
</Section>
|
||||
</AutoColumn>
|
||||
<BottomSection gap="12px">
|
||||
<ButtonPrimary onClick={onDismiss}>
|
||||
<Trans>Dismiss</Trans>
|
||||
@@ -252,7 +250,7 @@ function L2Content({
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Section inline={inline}>
|
||||
<AutoColumn>
|
||||
{!inline && (
|
||||
<RowBetween mb="16px">
|
||||
<Badge>
|
||||
@@ -277,7 +275,7 @@ function L2Content({
|
||||
)}
|
||||
</ConfirmedIcon>
|
||||
<AutoColumn gap="md" justify="center">
|
||||
<Text fontWeight={500} fontSize={20} textAlign="center">
|
||||
<ThemedText.SubHeaderLarge textAlign="center">
|
||||
{!hash ? (
|
||||
<Trans>Confirm transaction in wallet</Trans>
|
||||
) : !confirmed ? (
|
||||
@@ -287,20 +285,20 @@ function L2Content({
|
||||
) : (
|
||||
<Trans>Error</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Text fontWeight={400} fontSize={16} textAlign="center">
|
||||
</ThemedText.SubHeaderLarge>
|
||||
<ThemedText.BodySecondary textAlign="center">
|
||||
{transaction ? <TransactionSummary info={transaction.info} /> : pendingText}
|
||||
</Text>
|
||||
</ThemedText.BodySecondary>
|
||||
{chainId && hash ? (
|
||||
<ExternalLink href={getExplorerLink(chainId, hash, ExplorerDataType.TRANSACTION)}>
|
||||
<Text fontWeight={500} fontSize={14} color={theme.accentAction}>
|
||||
<ThemedText.SubHeaderSmall color={theme.accentAction}>
|
||||
<Trans>View on Explorer</Trans>
|
||||
</Text>
|
||||
</ThemedText.SubHeaderSmall>
|
||||
</ExternalLink>
|
||||
) : (
|
||||
<div style={{ height: '17px' }} />
|
||||
)}
|
||||
<Text color={theme.textTertiary} style={{ margin: '20px 0 0 0' }} fontSize="14px">
|
||||
<ThemedText.SubHeaderSmall color={theme.textTertiary} marginTop="20px">
|
||||
{!secondsToConfirm ? (
|
||||
<div style={{ height: '24px' }} />
|
||||
) : (
|
||||
@@ -311,14 +309,14 @@ function L2Content({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Text>
|
||||
</ThemedText.SubHeaderSmall>
|
||||
<ButtonPrimary onClick={onDismiss} style={{ margin: '4px 0 0 0' }}>
|
||||
<Text fontWeight={500} fontSize={20}>
|
||||
<ThemedText.SubHeaderLarge>
|
||||
{inline ? <Trans>Return</Trans> : <Trans>Close</Trans>}
|
||||
</Text>
|
||||
</ThemedText.SubHeaderLarge>
|
||||
</ButtonPrimary>
|
||||
</AutoColumn>
|
||||
</Section>
|
||||
</AutoColumn>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import { L2_CHAIN_IDS } from 'constants/chains'
|
||||
import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc'
|
||||
import ms from 'ms.macro'
|
||||
import { darken } from 'polished'
|
||||
import { useState } from 'react'
|
||||
import { useUserSlippageTolerance, useUserTransactionTTL } from 'state/user/hooks'
|
||||
import styled, { useTheme } from 'styled-components/macro'
|
||||
|
||||
import { ThemedText } from '../../theme'
|
||||
import { AutoColumn } from '../Column'
|
||||
import QuestionHelper from '../QuestionHelper'
|
||||
import { RowBetween, RowFixed } from '../Row'
|
||||
|
||||
enum SlippageError {
|
||||
InvalidInput = 'InvalidInput',
|
||||
}
|
||||
|
||||
enum DeadlineError {
|
||||
InvalidInput = 'InvalidInput',
|
||||
}
|
||||
|
||||
const FancyButton = styled.button`
|
||||
color: ${({ theme }) => theme.textPrimary};
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
border-radius: 36px;
|
||||
font-size: 1rem;
|
||||
width: auto;
|
||||
min-width: 3.5rem;
|
||||
border: 1px solid ${({ theme }) => theme.deprecated_bg3};
|
||||
outline: none;
|
||||
background: ${({ theme }) => theme.deprecated_bg1};
|
||||
:hover {
|
||||
border: 1px solid ${({ theme }) => theme.deprecated_bg4};
|
||||
}
|
||||
:focus {
|
||||
border: 1px solid ${({ theme }) => theme.accentAction};
|
||||
}
|
||||
`
|
||||
|
||||
const Option = styled(FancyButton)<{ active: boolean }>`
|
||||
margin-right: 8px;
|
||||
border-radius: 12px;
|
||||
:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
background-color: ${({ active, theme }) => active && theme.accentAction};
|
||||
color: ${({ active, theme }) => (active ? theme.white : theme.textPrimary)};
|
||||
`
|
||||
|
||||
const Input = styled.input`
|
||||
background: ${({ theme }) => theme.deprecated_bg1};
|
||||
font-size: 16px;
|
||||
border-radius: 12px;
|
||||
width: auto;
|
||||
outline: none;
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
color: ${({ theme, color }) => (color === 'red' ? theme.accentFailure : theme.textPrimary)};
|
||||
text-align: right;
|
||||
|
||||
::placeholder {
|
||||
color: ${({ theme }) => theme.textTertiary};
|
||||
}
|
||||
`
|
||||
|
||||
const OptionCustom = styled(FancyButton)<{ active?: boolean; warning?: boolean }>`
|
||||
height: 2rem;
|
||||
position: relative;
|
||||
padding: 0 0.75rem;
|
||||
border-radius: 12px;
|
||||
flex: 1;
|
||||
border: ${({ theme, active, warning }) =>
|
||||
active
|
||||
? `1px solid ${warning ? theme.accentFailure : theme.accentAction}`
|
||||
: warning && `1px solid ${theme.accentFailure}`};
|
||||
:hover {
|
||||
border: ${({ theme, active, warning }) =>
|
||||
active && `1px solid ${warning ? darken(0.1, theme.accentFailure) : darken(0.1, theme.accentAction)}`};
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0px;
|
||||
border-radius: 2rem;
|
||||
}
|
||||
`
|
||||
|
||||
const SlippageEmojiContainer = styled.span`
|
||||
color: #f3841e;
|
||||
${({ theme }) => theme.deprecated_mediaWidth.deprecated_upToSmall`
|
||||
display: none;
|
||||
`}
|
||||
`
|
||||
|
||||
interface TransactionSettingsProps {
|
||||
placeholderSlippage: Percent // varies according to the context in which the settings dialog is placed
|
||||
}
|
||||
|
||||
const THREE_DAYS_IN_SECONDS = ms`3 days` / 1000
|
||||
|
||||
export default function TransactionSettings({ placeholderSlippage }: TransactionSettingsProps) {
|
||||
const { chainId } = useWeb3React()
|
||||
const theme = useTheme()
|
||||
|
||||
const [userSlippageTolerance, setUserSlippageTolerance] = useUserSlippageTolerance()
|
||||
|
||||
const [deadline, setDeadline] = useUserTransactionTTL()
|
||||
|
||||
const [slippageInput, setSlippageInput] = useState('')
|
||||
const [slippageError, setSlippageError] = useState<SlippageError | false>(false)
|
||||
|
||||
const [deadlineInput, setDeadlineInput] = useState('')
|
||||
const [deadlineError, setDeadlineError] = useState<DeadlineError | false>(false)
|
||||
|
||||
function parseSlippageInput(value: string) {
|
||||
// populate what the user typed and clear the error
|
||||
setSlippageInput(value)
|
||||
setSlippageError(false)
|
||||
|
||||
if (value.length === 0) {
|
||||
setUserSlippageTolerance('auto')
|
||||
} else {
|
||||
const parsed = Math.floor(Number.parseFloat(value) * 100)
|
||||
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 5000) {
|
||||
setUserSlippageTolerance('auto')
|
||||
if (value !== '.') {
|
||||
setSlippageError(SlippageError.InvalidInput)
|
||||
}
|
||||
} else {
|
||||
setUserSlippageTolerance(new Percent(parsed, 10_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tooLow = userSlippageTolerance !== 'auto' && userSlippageTolerance.lessThan(new Percent(5, 10_000))
|
||||
const tooHigh = userSlippageTolerance !== 'auto' && userSlippageTolerance.greaterThan(new Percent(1, 100))
|
||||
|
||||
function parseCustomDeadline(value: string) {
|
||||
// populate what the user typed and clear the error
|
||||
setDeadlineInput(value)
|
||||
setDeadlineError(false)
|
||||
|
||||
if (value.length === 0) {
|
||||
setDeadline(DEFAULT_DEADLINE_FROM_NOW)
|
||||
} else {
|
||||
try {
|
||||
const parsed: number = Math.floor(Number.parseFloat(value) * 60)
|
||||
if (!Number.isInteger(parsed) || parsed < 60 || parsed > THREE_DAYS_IN_SECONDS) {
|
||||
setDeadlineError(DeadlineError.InvalidInput)
|
||||
} else {
|
||||
setDeadline(parsed)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setDeadlineError(DeadlineError.InvalidInput)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showCustomDeadlineRow = Boolean(chainId && !L2_CHAIN_IDS.includes(chainId))
|
||||
|
||||
return (
|
||||
<AutoColumn gap="md">
|
||||
<AutoColumn gap="sm">
|
||||
<RowFixed>
|
||||
<ThemedText.DeprecatedBlack fontWeight={400} fontSize={14} color={theme.textSecondary}>
|
||||
<Trans>Slippage tolerance</Trans>
|
||||
</ThemedText.DeprecatedBlack>
|
||||
<QuestionHelper
|
||||
text={
|
||||
<Trans>Your transaction will revert if the price changes unfavorably by more than this percentage.</Trans>
|
||||
}
|
||||
/>
|
||||
</RowFixed>
|
||||
<RowBetween>
|
||||
<Option
|
||||
onClick={() => {
|
||||
parseSlippageInput('')
|
||||
}}
|
||||
active={userSlippageTolerance === 'auto'}
|
||||
>
|
||||
<Trans>Auto</Trans>
|
||||
</Option>
|
||||
<OptionCustom active={userSlippageTolerance !== 'auto'} warning={!!slippageError} tabIndex={-1}>
|
||||
<RowBetween>
|
||||
{tooLow || tooHigh ? (
|
||||
<SlippageEmojiContainer>
|
||||
<span role="img" aria-label="warning">
|
||||
⚠️
|
||||
</span>
|
||||
</SlippageEmojiContainer>
|
||||
) : null}
|
||||
<Input
|
||||
placeholder={placeholderSlippage.toFixed(2)}
|
||||
value={
|
||||
slippageInput.length > 0
|
||||
? slippageInput
|
||||
: userSlippageTolerance === 'auto'
|
||||
? ''
|
||||
: userSlippageTolerance.toFixed(2)
|
||||
}
|
||||
onChange={(e) => parseSlippageInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
setSlippageInput('')
|
||||
setSlippageError(false)
|
||||
}}
|
||||
color={slippageError ? 'red' : ''}
|
||||
/>
|
||||
%
|
||||
</RowBetween>
|
||||
</OptionCustom>
|
||||
</RowBetween>
|
||||
{slippageError || tooLow || tooHigh ? (
|
||||
<RowBetween
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
paddingTop: '7px',
|
||||
color: slippageError ? 'red' : '#F3841E',
|
||||
}}
|
||||
>
|
||||
{slippageError ? (
|
||||
<Trans>Enter a valid slippage percentage</Trans>
|
||||
) : tooLow ? (
|
||||
<Trans>Your transaction may fail</Trans>
|
||||
) : (
|
||||
<Trans>Your transaction may be frontrun</Trans>
|
||||
)}
|
||||
</RowBetween>
|
||||
) : null}
|
||||
</AutoColumn>
|
||||
|
||||
{showCustomDeadlineRow && (
|
||||
<AutoColumn gap="sm">
|
||||
<RowFixed>
|
||||
<ThemedText.DeprecatedBlack fontSize={14} fontWeight={400} color={theme.textSecondary}>
|
||||
<Trans>Transaction deadline</Trans>
|
||||
</ThemedText.DeprecatedBlack>
|
||||
<QuestionHelper
|
||||
text={<Trans>Your transaction will revert if it is pending for more than this period of time.</Trans>}
|
||||
/>
|
||||
</RowFixed>
|
||||
<RowFixed>
|
||||
<OptionCustom style={{ width: '80px' }} warning={!!deadlineError} tabIndex={-1}>
|
||||
<Input
|
||||
placeholder={(DEFAULT_DEADLINE_FROM_NOW / 60).toString()}
|
||||
value={
|
||||
deadlineInput.length > 0
|
||||
? deadlineInput
|
||||
: deadline === DEFAULT_DEADLINE_FROM_NOW
|
||||
? ''
|
||||
: (deadline / 60).toString()
|
||||
}
|
||||
onChange={(e) => parseCustomDeadline(e.target.value)}
|
||||
onBlur={() => {
|
||||
setDeadlineInput('')
|
||||
setDeadlineError(false)
|
||||
}}
|
||||
color={deadlineError ? 'red' : ''}
|
||||
/>
|
||||
</OptionCustom>
|
||||
<ThemedText.DeprecatedBody style={{ paddingLeft: '8px' }} fontSize={14}>
|
||||
<Trans>minutes</Trans>
|
||||
</ThemedText.DeprecatedBody>
|
||||
</RowFixed>
|
||||
</AutoColumn>
|
||||
)}
|
||||
</AutoColumn>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { useCloseAccountDrawer } from 'components/AccountDrawer'
|
||||
import { ButtonEmpty, ButtonPrimary } from 'components/Button'
|
||||
import { ActivationStatus, useActivationState } from 'connection/activate'
|
||||
import { AlertTriangle } from 'react-feather'
|
||||
import styled from 'styled-components/macro'
|
||||
import { ThemedText } from 'theme'
|
||||
@@ -20,13 +22,15 @@ const AlertTriangleIcon = styled(AlertTriangle)`
|
||||
color: ${({ theme }) => theme.accentCritical};
|
||||
`
|
||||
|
||||
export default function ConnectionErrorView({
|
||||
retryActivation,
|
||||
openOptions,
|
||||
}: {
|
||||
retryActivation: () => void
|
||||
openOptions: () => void
|
||||
}) {
|
||||
// TODO(cartcrom): move this to a top level modal, rather than inline in the drawer
|
||||
export default function ConnectionErrorView() {
|
||||
const { activationState, tryActivation, cancelActivation } = useActivationState()
|
||||
const closeDrawer = useCloseAccountDrawer()
|
||||
|
||||
if (activationState.status !== ActivationStatus.ERROR) return null
|
||||
|
||||
const retry = () => tryActivation(activationState.connection, closeDrawer)
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<AlertTriangleIcon />
|
||||
@@ -38,11 +42,11 @@ export default function ConnectionErrorView({
|
||||
The connection attempt failed. Please click try again and follow the steps to connect in your wallet.
|
||||
</Trans>
|
||||
</ThemedText.BodyPrimary>
|
||||
<ButtonPrimary $borderRadius="16px" onClick={retryActivation}>
|
||||
<ButtonPrimary $borderRadius="16px" onClick={retry}>
|
||||
<Trans>Try Again</Trans>
|
||||
</ButtonPrimary>
|
||||
<ButtonEmpty width="fit-content" padding="0" marginTop={20}>
|
||||
<ThemedText.Link onClick={openOptions} marginBottom={12}>
|
||||
<ThemedText.Link onClick={cancelActivation} marginBottom={12}>
|
||||
<Trans>Back to wallet selection</Trans>
|
||||
</ThemedText.Link>
|
||||
</ButtonEmpty>
|
||||
|
||||
85
src/components/WalletModal/Option.test.tsx
Normal file
85
src/components/WalletModal/Option.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Connector } from '@web3-react/types'
|
||||
import UNIWALLET_ICON from 'assets/images/uniwallet.png'
|
||||
import { useCloseAccountDrawer } from 'components/AccountDrawer'
|
||||
import { Connection, ConnectionType } from 'connection/types'
|
||||
import { mocked } from 'test-utils/mocked'
|
||||
import { createDeferredPromise } from 'test-utils/promise'
|
||||
import { act, render } from 'test-utils/render'
|
||||
|
||||
import Option from './Option'
|
||||
|
||||
const mockCloseDrawer = jest.fn()
|
||||
jest.mock('components/AccountDrawer')
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'debug').mockReturnValue()
|
||||
mocked(useCloseAccountDrawer).mockReturnValue(mockCloseDrawer)
|
||||
})
|
||||
|
||||
const mockConnection1: Connection = {
|
||||
getName: () => 'Mock Connection 1',
|
||||
connector: {
|
||||
activate: jest.fn(),
|
||||
deactivate: jest.fn(),
|
||||
} as unknown as Connector,
|
||||
getIcon: () => UNIWALLET_ICON,
|
||||
type: ConnectionType.UNIWALLET,
|
||||
} as unknown as Connection
|
||||
|
||||
const mockConnection2: Connection = {
|
||||
getName: () => 'Mock Connection 2',
|
||||
connector: {
|
||||
activate: jest.fn(),
|
||||
deactivate: jest.fn(),
|
||||
} as unknown as Connector,
|
||||
getIcon: () => UNIWALLET_ICON,
|
||||
type: ConnectionType.INJECTED,
|
||||
} as unknown as Connection
|
||||
|
||||
describe('Wallet Option', () => {
|
||||
it('renders default state', () => {
|
||||
const component = render(<Option connection={mockConnection1} />)
|
||||
const option = component.getByTestId('wallet-option-UNIWALLET')
|
||||
expect(option).toBeEnabled()
|
||||
expect(option).toHaveProperty('selected', false)
|
||||
|
||||
expect(option).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('connect when clicked', async () => {
|
||||
const activationResponse = createDeferredPromise()
|
||||
mocked(mockConnection1.connector.activate).mockReturnValue(activationResponse.promise)
|
||||
|
||||
const component = render(
|
||||
<>
|
||||
<Option connection={mockConnection1} />
|
||||
<Option connection={mockConnection2} />
|
||||
</>
|
||||
)
|
||||
const option1 = component.getByTestId('wallet-option-UNIWALLET')
|
||||
const option2 = component.getByTestId('wallet-option-INJECTED')
|
||||
|
||||
expect(option1).toBeEnabled()
|
||||
expect(option1).toHaveProperty('selected', false)
|
||||
expect(option2).toBeEnabled()
|
||||
expect(option2).toHaveProperty('selected', false)
|
||||
|
||||
await act(() => option1.click())
|
||||
|
||||
expect(option1).toBeDisabled()
|
||||
expect(option1).toHaveProperty('selected', true)
|
||||
expect(option2).toBeDisabled()
|
||||
expect(option2).toHaveProperty('selected', false)
|
||||
|
||||
expect(mockCloseDrawer).toHaveBeenCalledTimes(0)
|
||||
|
||||
await act(async () => activationResponse.resolve())
|
||||
|
||||
expect(mockCloseDrawer).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(option1).toBeEnabled()
|
||||
expect(option1).toHaveProperty('selected', false)
|
||||
expect(option2).toBeEnabled()
|
||||
expect(option2).toHaveProperty('selected', false)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { TraceEvent } from '@uniswap/analytics'
|
||||
import { BrowserEvent, InterfaceElementName, InterfaceEventName } from '@uniswap/analytics-events'
|
||||
import { useCloseAccountDrawer } from 'components/AccountDrawer'
|
||||
import Loader from 'components/Icons/LoadingSpinner'
|
||||
import { Connection, ConnectionType } from 'connection'
|
||||
import { ActivationStatus, useActivationState } from 'connection/activate'
|
||||
import { Connection } from 'connection/types'
|
||||
import styled from 'styled-components/macro'
|
||||
import { useIsDarkMode } from 'theme/components/ThemeToggle'
|
||||
import { flexColumnNoWrap, flexRowNoWrap } from 'theme/styles'
|
||||
@@ -14,27 +16,26 @@ const OptionCardLeft = styled.div`
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const OptionCardClickable = styled.button<{ isActive?: boolean; clickable?: boolean }>`
|
||||
const OptionCardClickable = styled.button<{ selected: boolean }>`
|
||||
background-color: ${({ theme }) => theme.backgroundModule};
|
||||
border: none;
|
||||
width: 100% !important;
|
||||
border-color: ${({ theme, isActive }) => (isActive ? theme.accentActive : 'transparent')};
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
padding: 18px;
|
||||
|
||||
margin-top: 0;
|
||||
transition: ${({ theme }) => theme.transition.duration.fast};
|
||||
opacity: ${({ disabled }) => (disabled ? '0.5' : '1')};
|
||||
opacity: ${({ disabled, selected }) => (disabled && !selected ? '0.5' : '1')};
|
||||
|
||||
&:hover {
|
||||
cursor: ${({ clickable }) => clickable && 'pointer'};
|
||||
background-color: ${({ theme, clickable }) => clickable && theme.hoverState};
|
||||
cursor: ${({ disabled }) => !disabled && 'pointer'};
|
||||
background-color: ${({ theme, disabled }) => !disabled && theme.hoverState};
|
||||
}
|
||||
&:focus {
|
||||
background-color: ${({ theme, clickable }) => clickable && theme.hoverState};
|
||||
background-color: ${({ theme, disabled }) => !disabled && theme.hoverState};
|
||||
}
|
||||
`
|
||||
|
||||
@@ -62,15 +63,16 @@ const IconWrapper = styled.div`
|
||||
`};
|
||||
`
|
||||
|
||||
type OptionProps = {
|
||||
connection: Connection
|
||||
activate: () => void
|
||||
pendingConnectionType?: ConnectionType
|
||||
}
|
||||
export default function Option({ connection, pendingConnectionType, activate }: OptionProps) {
|
||||
const isPending = pendingConnectionType === connection.type
|
||||
export default function Option({ connection }: { connection: Connection }) {
|
||||
const { activationState, tryActivation } = useActivationState()
|
||||
const closeDrawer = useCloseAccountDrawer()
|
||||
const activate = () => tryActivation(connection, closeDrawer)
|
||||
|
||||
const isSomeOptionPending = activationState.status === ActivationStatus.PENDING
|
||||
const isCurrentOptionPending = isSomeOptionPending && activationState.connection.type === connection.type
|
||||
const isDarkMode = useIsDarkMode()
|
||||
const content = (
|
||||
|
||||
return (
|
||||
<TraceEvent
|
||||
events={[BrowserEvent.onClick]}
|
||||
name={InterfaceEventName.WALLET_SELECTED}
|
||||
@@ -78,10 +80,10 @@ export default function Option({ connection, pendingConnectionType, activate }:
|
||||
element={InterfaceElementName.WALLET_TYPE_OPTION}
|
||||
>
|
||||
<OptionCardClickable
|
||||
onClick={!pendingConnectionType ? activate : undefined}
|
||||
clickable={!pendingConnectionType}
|
||||
disabled={Boolean(!isPending && !!pendingConnectionType)}
|
||||
data-testid="wallet-modal-option"
|
||||
onClick={activate}
|
||||
disabled={isSomeOptionPending}
|
||||
selected={isCurrentOptionPending}
|
||||
data-testid={`wallet-option-${connection.type}`}
|
||||
>
|
||||
<OptionCardLeft>
|
||||
<IconWrapper>
|
||||
@@ -90,10 +92,8 @@ export default function Option({ connection, pendingConnectionType, activate }:
|
||||
<HeaderText>{connection.getName()}</HeaderText>
|
||||
{connection.isNew && <NewBadge />}
|
||||
</OptionCardLeft>
|
||||
{isPending && <Loader />}
|
||||
{isCurrentOptionPending && <Loader />}
|
||||
</OptionCardClickable>
|
||||
</TraceEvent>
|
||||
)
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
132
src/components/WalletModal/__snapshots__/Option.test.tsx.snap
Normal file
132
src/components/WalletModal/__snapshots__/Option.test.tsx.snap
Normal file
@@ -0,0 +1,132 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Wallet Option renders default state 1`] = `
|
||||
.c1 {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-flex-flow: column nowrap;
|
||||
-ms-flex-flow: column nowrap;
|
||||
flex-flow: column nowrap;
|
||||
-webkit-flex-direction: row;
|
||||
-ms-flex-direction: row;
|
||||
flex-direction: row;
|
||||
-webkit-align-items: center;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.c0 {
|
||||
background-color: #F5F6FC;
|
||||
border: none;
|
||||
width: 100% !important;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-flex-direction: row;
|
||||
-ms-flex-direction: row;
|
||||
flex-direction: row;
|
||||
-webkit-align-items: center;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
-webkit-box-pack: justify;
|
||||
-webkit-justify-content: space-between;
|
||||
-ms-flex-pack: justify;
|
||||
justify-content: space-between;
|
||||
padding: 18px;
|
||||
-webkit-transition: 125ms;
|
||||
transition: 125ms;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.c0:hover {
|
||||
cursor: pointer;
|
||||
background-color: #ADBCFF3d;
|
||||
}
|
||||
|
||||
.c0:focus {
|
||||
background-color: #ADBCFF3d;
|
||||
}
|
||||
|
||||
.c3 {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-flex-flow: row nowrap;
|
||||
-ms-flex-flow: row nowrap;
|
||||
flex-flow: row nowrap;
|
||||
-webkit-align-items: center;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
-webkit-box-pack: center;
|
||||
-webkit-justify-content: center;
|
||||
-ms-flex-pack: center;
|
||||
justify-content: center;
|
||||
color: #0D111C;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-flex-flow: column nowrap;
|
||||
-ms-flex-flow: column nowrap;
|
||||
flex-flow: column nowrap;
|
||||
-webkit-align-items: center;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
-webkit-box-pack: center;
|
||||
-webkit-justify-content: center;
|
||||
-ms-flex-pack: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.c2 > img,
|
||||
.c2 span {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
@media (max-width:960px) {
|
||||
.c2 {
|
||||
-webkit-align-items: flex-end;
|
||||
-webkit-box-align: flex-end;
|
||||
-ms-flex-align: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
<button
|
||||
class="c0"
|
||||
data-testid="wallet-option-UNIWALLET"
|
||||
>
|
||||
<div
|
||||
class="c1"
|
||||
>
|
||||
<div
|
||||
class="c2"
|
||||
>
|
||||
<img
|
||||
alt="Icon"
|
||||
src="uniwallet.png"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="c3"
|
||||
>
|
||||
Mock Connection 1
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user