Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd4042aa16 | ||
|
|
1dcafd2f2d | ||
|
|
66fcdb4465 | ||
|
|
e398e8b950 | ||
|
|
6424fdfbcd | ||
|
|
95814e3271 | ||
|
|
caa2524e27 | ||
|
|
d28a4b34cd | ||
|
|
f3a80c6272 | ||
|
|
b89ee36448 | ||
|
|
fbc55db937 | ||
|
|
835c62acfa | ||
|
|
8fe7c7a0a7 | ||
|
|
41113e6e41 | ||
|
|
58b25d29a9 | ||
|
|
a2db3e2719 | ||
|
|
b62f9066a7 | ||
|
|
258f22e037 | ||
|
|
38b306a80f | ||
|
|
9050f09bfe | ||
|
|
77d46c361a | ||
|
|
4fb48bdd1f | ||
|
|
cf2b6bf568 | ||
|
|
03095f4e48 | ||
|
|
b2966f8d29 | ||
|
|
ef6d1f20ed | ||
|
|
10b156ff2b | ||
|
|
146c5f29cf | ||
|
|
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 |
@@ -2,13 +2,18 @@
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
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
|
||||
10
.github/actions/setup/action.yml
vendored
10
.github/actions/setup/action.yml
vendored
@@ -53,16 +53,6 @@ runs:
|
||||
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
|
||||
|
||||
|
||||
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'
|
||||
|
||||
15
.github/pull_request_template.md
vendored
15
.github/pull_request_template.md
vendored
@@ -6,7 +6,7 @@
|
||||
|
||||
|
||||
<!-- Delete inapplicable lines: -->
|
||||
_JIRA ticket:_
|
||||
_Linear ticket:_
|
||||
_Slack thread:_
|
||||
_Relevant docs:_
|
||||
|
||||
@@ -14,9 +14,16 @@ _Relevant docs:_
|
||||
<!-- Delete this section if your change does not affect UI. -->
|
||||
## Screen capture
|
||||
|
||||
| Before | After (Desktop) | After (Mobile) |
|
||||
| ------------ |---------------- | -------------- |
|
||||
| paste_before | past_after | paste_after |
|
||||
### Before
|
||||
| Mobile | Desktop |
|
||||
| ------------ | ------------ |
|
||||
| paste_before | paste_before |
|
||||
|
||||
|
||||
### After
|
||||
| Mobile | Desktop |
|
||||
| ------------ | ----------- |
|
||||
| paste_after | paste_after |
|
||||
|
||||
|
||||
## Test plan
|
||||
|
||||
13
.github/workflows/1-main-to-staging.yml
vendored
13
.github/workflows/1-main-to-staging.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 1 | Push main to releases/staging
|
||||
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.
|
||||
@@ -12,14 +12,21 @@ jobs:
|
||||
name: 'Push to staging branch'
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: release
|
||||
name: push/staging
|
||||
steps:
|
||||
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
|
||||
ref: main
|
||||
- name: Git push
|
||||
- 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
|
||||
|
||||
30
.github/workflows/2-deploy-to-staging.yml
vendored
30
.github/workflows/2-deploy-to-staging.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 2 | Deploy to staging
|
||||
name: 2 | Deploy staging
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -8,8 +8,19 @@ jobs:
|
||||
deploy-to-staging:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: staging
|
||||
name: deploy/staging
|
||||
steps:
|
||||
- name: Send Slack message that deploy is starting
|
||||
uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
|
||||
continue-on-error: true
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Staging deploy started for branch: ${{ github.ref_name }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
- run: yarn prepare
|
||||
@@ -21,6 +32,7 @@ jobs:
|
||||
with:
|
||||
node-version: 16
|
||||
- name: Update Cloudflare Pages deployment
|
||||
id: pages-deployment
|
||||
uses: cloudflare/pages-action@364c7ca09a4b57837c5967871d64a2c31adb8c0d
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -28,6 +40,20 @@ jobs:
|
||||
projectName: interface-staging
|
||||
directory: build
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Cloudflare uses `main` as the default production branch, so we push using the `main` branch so that it can be aliased by a custom domain.
|
||||
branch: main
|
||||
- name: Send Slack message about deployment outcome
|
||||
uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
|
||||
continue-on-error: true
|
||||
if: always()
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Staging deploy **${{ steps.pages-deployment.outcome }}** for: ${{ github.ref_name }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
- name: Upload source maps to Sentry
|
||||
uses: getsentry/action-release@bd5f874fcda966ba48139b0140fb3ec0cb3aabdd
|
||||
continue-on-error: true
|
||||
|
||||
60
.github/workflows/3-staging-to-prod.yml
vendored
60
.github/workflows/3-staging-to-prod.yml
vendored
@@ -1,63 +1,27 @@
|
||||
name: 3 | Generate PR for releases/staging to releases/prod
|
||||
name: 3 | Push staging -> prod
|
||||
|
||||
# This CI job is responsible for generating PRs that bring the HEAD of `releases/staging` into `releases/prod`.
|
||||
# These PRs are meant to be the only (standard) way that code is merged into the `releases/prod` branch.
|
||||
# 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:
|
||||
|
||||
# https://github.com/peter-evans/create-pull-request/blob/main/docs/examples.md#keep-a-branch-up-to-date-with-another
|
||||
jobs:
|
||||
prod-gen-pr:
|
||||
name: 'Generate PR for merging to releases/prod branch'
|
||||
push-prod:
|
||||
name: 'Push to prod branch'
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: release
|
||||
name: push/prod
|
||||
steps:
|
||||
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
|
||||
ref: main
|
||||
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
|
||||
ref: releases/prod
|
||||
- name: Reset promotion branch
|
||||
ref: releases/staging
|
||||
- name: Git config
|
||||
run: |
|
||||
git fetch origin releases/staging:releases/staging
|
||||
git reset --hard releases/staging
|
||||
- name: Setup git
|
||||
run: |
|
||||
git config user.name "UL Mobile Service Account"
|
||||
git config user.name "UL Service Account"
|
||||
git config user.email "hello-happy-puppy@users.noreply.github.com"
|
||||
- name: Add CODEOWNERS file
|
||||
- name: Git push
|
||||
run: |
|
||||
echo "@uniswap/web-reviewers" > CODEOWNERS
|
||||
git add CODEOWNERS
|
||||
git commit -m "ci: add CODEOWNERS file"
|
||||
- uses: peter-evans/create-pull-request@ea54357f43e3d1cf1125471d0814f4d02cc0d364
|
||||
id: create-pull-request
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
|
||||
base: 'releases/prod'
|
||||
title: 'ci: promotes staging to prod'
|
||||
delete-branch: 'true'
|
||||
committer: 'UL Service Account <hello-happy-puppy@users.noreply.github.com>'
|
||||
author: 'UL Service Account <hello-happy-puppy@users.noreply.github.com>'
|
||||
branch: 'approvals/staging-to-prod'
|
||||
- name: Update PR body
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "### Description" > /tmp/pr_desc
|
||||
echo "" >> /tmp/pr_desc
|
||||
echo "This PR promotes the following commits from `releases/staging` to `releases/prod`." >> /tmp/pr_desc
|
||||
echo "" >> /tmp/pr_desc
|
||||
gh pr view ${{ steps.create-pull-request.outputs.pull-request-number }} --json commits | jq '.commits[] | [.oid, .messageHeadline] | @tsv' | sed 's/"//g' | sed 's/\\t/ - /g' >> /tmp/pr_desc
|
||||
echo "" >> /tmp/pr_desc
|
||||
echo "**Once approved this PR will be automatically merged via a merge commit.**" >> /tmp/pr_desc
|
||||
gh pr edit ${{ steps.create-pull-request.outputs.pull-request-number }} -b "$(cat /tmp/pr_desc)"
|
||||
- name: Enable PR automerge
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
|
||||
run: gh pr merge --delete-branch --merge --auto "${{ steps.create-pull-request.outputs.pull-request-number }}"
|
||||
git push origin releases/staging:releases/prod --force
|
||||
|
||||
33
.github/workflows/4-deploy-to-prod.yml
vendored
33
.github/workflows/4-deploy-to-prod.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 4 | Deploy to prod
|
||||
name: 4 | Deploy prod
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -6,16 +6,28 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy-to-prod:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: prod
|
||||
name: deploy/prod
|
||||
steps:
|
||||
- name: Send Slack message that build is starting
|
||||
uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
|
||||
continue-on-error: true
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Production deploy started for branch: ${{ github.ref_name }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
- 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
|
||||
uses: mathieudutour/github-tag-action@d745f2e74aaf1ee82e747b181f7a0967978abee0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
release_branches: releases/prod
|
||||
@@ -69,14 +81,28 @@ jobs:
|
||||
|
||||
- name: Update Cloudflare Pages deployment
|
||||
uses: cloudflare/pages-action@364c7ca09a4b57837c5967871d64a2c31adb8c0d
|
||||
id: pages-deployment
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
|
||||
directory: build
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Cloudflare uses `main` as the default production branch, so we push using the `main` branch so that it can be aliased by a custom domain.
|
||||
branch: main
|
||||
|
||||
- name: Send Slack message about deployment outcome
|
||||
uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
|
||||
continue-on-error: true
|
||||
if: always()
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"text": "Production deploy **${{ steps.pages-deployment.outcome }}** for: ${{ github.ref_name }}"
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
- name: Upload source maps to Sentry
|
||||
uses: getsentry/action-release@4744f6a65149f441c5f396d5b0877307c0db52c7
|
||||
continue-on-error: true
|
||||
@@ -88,4 +114,3 @@ jobs:
|
||||
environment: production
|
||||
sourcemaps: './build/static/js'
|
||||
url_prefix: '~/static/js'
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ name: Slack notifications for releases/* merges
|
||||
# | sed 's/"//g' | sed 's/\\t/;/g' | sed 's/\\n/;/g' | sed 's/\\//g' \
|
||||
# We then use awk to format the TSV into a Slack message
|
||||
# | awk -F';' '{print "• <"$1"|"$2"> (<https://github.com/"$3"|"$3">, "$4") - "$5}' \
|
||||
# Finally, we need to deal with some escaping issues with newlines so that we don't break the Slack message format
|
||||
# We need to deal with some escaping issues with newlines so that we don't break the Slack message format
|
||||
# | sed 's/$/\\n/g' | tr -d '\n' \
|
||||
# Finally we have to truncate the message to 3,000 characters max, otherwise Slack will reject it
|
||||
# | awk '{print substr($0,0,3000);}' \
|
||||
# Then shove the bytes into a file to store them in their exact format
|
||||
# > /tmp/parsed_github_context
|
||||
|
||||
@@ -25,6 +27,8 @@ jobs:
|
||||
notify-slack:
|
||||
name: 'Emit Slack notification(s)'
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: notify/releases
|
||||
steps:
|
||||
- name: Parse event to slug
|
||||
id: parse-slug
|
||||
@@ -38,6 +42,7 @@ jobs:
|
||||
| sed 's/"//g' | sed 's/\\t/;/g' | sed 's/\\n/;/g' | sed 's/\\//g' \
|
||||
| awk -F';' '{print "• <"$1"|"$2"> (<https://github.com/"$3"|"$3">, "$4") - "$5}' \
|
||||
| sed 's/$/\\n/g' | tr -d '\n' \
|
||||
| awk '{print substr($0,0,3000);}' \
|
||||
> /tmp/parsed_github_context
|
||||
echo "SLACK_COMMITS=$(cat /tmp/parsed_github_context)" >> "$GITHUB_OUTPUT"
|
||||
- name: Send custom JSON data to Slack workflow
|
||||
|
||||
2
.github/workflows/release.yaml
vendored
2
.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:
|
||||
|
||||
|
||||
55
.github/workflows/test.yml
vendored
55
.github/workflows/test.yml
vendored
@@ -16,6 +16,8 @@ on:
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: ${{ github.ref_name == 'main' && 'notify/test' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
@@ -26,9 +28,16 @@ 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_WEBHOOK_URL }}
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: ${{ github.ref_name == 'main' && 'notify/test' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
@@ -39,16 +48,30 @@ jobs:
|
||||
key: ${{ runner.os }}-tsc-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
|
||||
restore-keys: ${{ runner.os }}-tsc-${{ hashFiles('**/yarn.lock') }}-
|
||||
- run: yarn typecheck
|
||||
- if: failure() && github.ref_name == 'main'
|
||||
uses: ./.github/actions/report
|
||||
with:
|
||||
name: Typecheck
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
deps-tests:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: ${{ github.ref_name == 'main' && 'notify/test' }}
|
||||
steps:
|
||||
- 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_WEBHOOK_URL }}
|
||||
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: ${{ github.ref_name == 'main' && 'notify/test' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/setup
|
||||
@@ -65,6 +88,11 @@ jobs:
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
flags: unit-tests
|
||||
- if: failure() && github.ref_name == 'main'
|
||||
uses: ./.github/actions/report
|
||||
with:
|
||||
name: Unit tests
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
build-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -95,6 +123,8 @@ jobs:
|
||||
cypress-test-matrix:
|
||||
needs: [build-e2e, cypress-rerun]
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: ${{ github.ref_name == 'main' && 'notify/test' }}
|
||||
container: cypress/browsers:node-18.14.1-chrome-111.0.5563.64-1-ff-111.0-edge-111.0.1661.43-1
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -107,7 +137,7 @@ jobs:
|
||||
id: cypress-cache
|
||||
with:
|
||||
path: /root/.cache/Cypress
|
||||
key: ${{ runner.os }}-cypress
|
||||
key: ${{ runner.os }}-cypress-${{ hashFiles('**/node_modules/cypress/package.json') }}
|
||||
- run: |
|
||||
yarn cypress install
|
||||
yarn cypress info
|
||||
@@ -120,14 +150,25 @@ jobs:
|
||||
- uses: cypress-io/github-action@v4
|
||||
with:
|
||||
install: false
|
||||
record: true
|
||||
parallel: true
|
||||
start: yarn serve
|
||||
wait-on: 'http://localhost:3000'
|
||||
browser: chrome
|
||||
record: true
|
||||
parallel: true
|
||||
group: e2e
|
||||
env:
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COMMIT_INFO_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }}
|
||||
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title || github.event.head_commit.message }}
|
||||
COMMIT_INFO_AUTHOR: ${{ github.event.sender.login || github.event.head_commit.author.login }}
|
||||
# Cypress requires an email for filtering by author, but GitHub does not expose one.
|
||||
# GitHub's public profile email can be deterministically produced from user id/login.
|
||||
COMMIT_INFO_EMAIL: ${{ github.event.sender.id || github.event.head_commit.author.id }}+${{ github.event.sender.login || github.event.head_commit.author.login }}@users.noreply.github.com
|
||||
COMMIT_INFO_SHA: ${{ github.event.pull_request.head.sha || github.event.head_commit.sha }}
|
||||
COMMIT_INFO_TIMESTAMP: ${{ github.event.pull_request.updated_at || github.event.head_commit.timestamp }}
|
||||
CYPRESS_PULL_REQUEST_ID: ${{ github.event.pull_request.number }}
|
||||
CYPRESS_PULL_REQUEST_URL: ${{ github.event.pull_request.html_url }}
|
||||
|
||||
- uses: codecov/codecov-action@v3
|
||||
with:
|
||||
@@ -136,9 +177,15 @@ jobs:
|
||||
verbose: true
|
||||
flags: e2e-tests
|
||||
|
||||
- if: failure() && github.ref_name == 'main'
|
||||
uses: ./.github/actions/report
|
||||
with:
|
||||
name: Cypress tests
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
# 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:
|
||||
|
||||
118
craco.config.cjs
118
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,20 +131,67 @@ 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.splitChunks = Object.assign(webpackConfig.optimization.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,
|
||||
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
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import codeCoverageTask from '@cypress/code-coverage/task'
|
||||
import { defineConfig } from 'cypress'
|
||||
import { setupHardhatEvents } from 'cypress-hardhat'
|
||||
import { unlinkSync } from 'fs'
|
||||
|
||||
export default defineConfig({
|
||||
projectId: 'yp82ef',
|
||||
videoUploadOnPasses: false,
|
||||
defaultCommandTimeout: 24000, // 2x average block time
|
||||
chromeWebSecurity: false,
|
||||
experimentalMemoryManagement: true, // better memory management, see https://github.com/cypress-io/cypress/pull/25462
|
||||
retries: { runMode: 2 },
|
||||
e2e: {
|
||||
async setupNodeEvents(on, config) {
|
||||
await setupHardhatEvents(on, config)
|
||||
codeCoverageTask(on, config)
|
||||
|
||||
// Delete recorded videos for specs that passed without flakes.
|
||||
on('after:spec', async (spec, results) => {
|
||||
if (results && results.video) {
|
||||
// If there were no failures (including flakes), delete the recorded video.
|
||||
if (!results.tests?.some((test) => test.attempts.some((attempt) => attempt?.state === 'failed'))) {
|
||||
unlinkSync(results.video)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...config,
|
||||
// Only enable Chrome.
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('Landing Page', () => {
|
||||
})
|
||||
|
||||
it('shows landing page when the unicorn icon in nav is selected', () => {
|
||||
cy.visit('/swap')
|
||||
cy.get(getTestSelector('uniswap-logo')).click()
|
||||
cy.get(getTestSelector('landing-page'))
|
||||
})
|
||||
|
||||
58
cypress/e2e/mini-portfolio/accounts.test.ts
Normal file
58
cypress/e2e/mini-portfolio/accounts.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { getTestSelector } from '../../utils'
|
||||
|
||||
describe('Mini Portfolio account drawer', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept(/api.uniswap.org\/v1\/graphql/, cy.spy().as('gqlSpy'))
|
||||
cy.visit('/swap', { ethereum: 'hardhat' })
|
||||
})
|
||||
|
||||
it('fetches balances when account button is first hovered', () => {
|
||||
// The balances should not be fetched before the account button is hovered
|
||||
cy.get('@gqlSpy').should('not.have.been.called')
|
||||
|
||||
// Balances should have been fetched once after hover
|
||||
cy.get(getTestSelector('web3-status-connected')).trigger('mouseover')
|
||||
cy.get('@gqlSpy').should('have.been.calledOnce')
|
||||
|
||||
// Balances should not be refetched upon second hover
|
||||
cy.get(getTestSelector('web3-status-connected')).trigger('mouseover')
|
||||
cy.get('@gqlSpy').should('have.been.calledOnce')
|
||||
|
||||
// Balances should not be refetched upon opening drawer
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get('@gqlSpy').should('have.been.calledOnce')
|
||||
|
||||
// Balances should not be refetched upon closing & reopening drawer
|
||||
cy.get(getTestSelector('close-account-drawer')).click()
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get('@gqlSpy').should('have.been.calledOnce')
|
||||
})
|
||||
|
||||
it('refetches balances when account changes', () => {
|
||||
cy.hardhat().then((hardhat) => {
|
||||
const accountA = hardhat.wallets[0].address
|
||||
const accountB = hardhat.wallets[1].address
|
||||
|
||||
// Opens the account drawer
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
|
||||
// A shortened version of the first account's address should be shown
|
||||
cy.contains(accountA.slice(0, 6)).should('exist')
|
||||
|
||||
// Stores the current portfolio balance to later compare to next account's balance
|
||||
cy.get(getTestSelector('portfolio-total-balance'))
|
||||
.invoke('text')
|
||||
.then((originalBalance) => {
|
||||
// TODO(INFRA-3) Replace window.ethereum access below with cypress-hardhat utility
|
||||
// Simulates the wallet changing accounts via eip-1193 event
|
||||
cy.window().then((win) => win.ethereum.emit('accountsChanged', [accountB]))
|
||||
|
||||
// The second account's address should now be shown
|
||||
cy.contains(accountB.slice(0, 6)).should('exist')
|
||||
|
||||
// The second account's portfolio balance should differ from the original balance
|
||||
cy.get(getTestSelector('portfolio-total-balance')).should('not.have.text', originalBalance)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
116
cypress/e2e/mini-portfolio/activity-history.test.ts
Normal file
116
cypress/e2e/mini-portfolio/activity-history.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { USDC_MAINNET } from '../../../src/constants/tokens'
|
||||
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?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`, { ethereum: 'hardhat' }).hardhat({
|
||||
automine: false,
|
||||
})
|
||||
|
||||
// Input swap info.
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('1').should('have.value', '1')
|
||||
cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '')
|
||||
|
||||
cy.get('#swap-button').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')
|
||||
})
|
||||
})
|
||||
@@ -51,9 +51,9 @@ describe('Permit2', () => {
|
||||
.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 20 seconds.
|
||||
// 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, 20)
|
||||
cy.wrap(allowance.expiration).should('be.closeTo', expected, 40)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('Service Worker', () => {
|
||||
throw new Error(
|
||||
'\n' +
|
||||
'Service Worker tests must be run on a production-like build\n' +
|
||||
'To test, build with `yarn build:e2e` and serve with `yarn serve`'
|
||||
'To test, build with `yarn build` and serve with `yarn serve`'
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -20,66 +20,78 @@ describe('Service Worker', () => {
|
||||
}
|
||||
})
|
||||
|
||||
function unregister() {
|
||||
return cy.log('unregister service worker').then(async () => {
|
||||
const cacheKeys = await window.caches.keys()
|
||||
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
|
||||
if (cacheKey) {
|
||||
await window.caches.delete(cacheKey)
|
||||
}
|
||||
|
||||
function unregisterServiceWorker() {
|
||||
return cy.log('unregisters service worker').then(async () => {
|
||||
const sw = await window.navigator.serviceWorker.getRegistration(Cypress.config().baseUrl ?? undefined)
|
||||
await sw?.unregister()
|
||||
})
|
||||
}
|
||||
before(unregister)
|
||||
after(unregister)
|
||||
before(unregisterServiceWorker)
|
||||
after(unregisterServiceWorker)
|
||||
|
||||
beforeEach(() => {
|
||||
cy.intercept({ hostname: 'www.google-analytics.com' }, (req) => {
|
||||
const body = req.body.toString()
|
||||
if (req.query['ep.event_category'] === 'Service Worker' || body.includes('Service%20Worker')) {
|
||||
if (req.query['en'] === 'Not Installed' || body.includes('Not%20Installed')) {
|
||||
req.alias = 'NotInstalled'
|
||||
} else if (req.query['en'] === 'Cache Hit' || body.includes('Cache%20Hit')) {
|
||||
req.alias = 'CacheHit'
|
||||
} else if (req.query['en'] === 'Cache Miss' || body.includes('Cache%20Miss')) {
|
||||
req.alias = 'CacheMiss'
|
||||
}
|
||||
cy.intercept('https://api.uniswap.org/v1/amplitude-proxy', (req) => {
|
||||
const body = JSON.stringify(req.body)
|
||||
const serviceWorkerStatus = body.match(/"service_worker":"(\w+)"/)?.[1]
|
||||
if (serviceWorkerStatus) {
|
||||
req.alias = `ServiceWorker:${serviceWorkerStatus}`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('installs a ServiceWorker', () => {
|
||||
it('installs a ServiceWorker and reports the uninstalled status to analytics', () => {
|
||||
cy.visit('/', { serviceWorker: true })
|
||||
.get('#swap-page')
|
||||
// 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')
|
||||
})
|
||||
cy.wait('@ServiceWorker:uninstalled')
|
||||
cy.window().should(
|
||||
'have.nested.property',
|
||||
// The parent is checked instead of the AUT because it is on the same origin,
|
||||
// and the AUT will not be considered "activated" until the parent is idle.
|
||||
'parent.navigator.serviceWorker.controller.state',
|
||||
'activated'
|
||||
)
|
||||
})
|
||||
|
||||
it('records a cache hit', () => {
|
||||
cy.visit('/', { serviceWorker: true }).get('#swap-page').wait('@CacheHit', { timeout: 20000 })
|
||||
})
|
||||
|
||||
it('records a cache miss', () => {
|
||||
cy.then(async () => {
|
||||
const cacheKeys = await window.caches.keys()
|
||||
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
|
||||
assert(cacheKey)
|
||||
|
||||
const cache = await window.caches.open(cacheKey)
|
||||
const keys = await cache.keys()
|
||||
const key = keys.find((key) => key.url.match(/index/))
|
||||
assert(key)
|
||||
|
||||
await cache.put(key, new Response())
|
||||
describe('cache hit', () => {
|
||||
it('reports the hit to analytics', () => {
|
||||
cy.visit('/', { serviceWorker: true })
|
||||
cy.wait('@ServiceWorker:hit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache miss', () => {
|
||||
let cache: Cache | undefined
|
||||
let request: Request | undefined
|
||||
let response: Response | undefined
|
||||
before(() => {
|
||||
// Mocks the index.html in the cache to force a cache miss.
|
||||
cy.visit('/', { serviceWorker: true }).then(async () => {
|
||||
const cacheKeys = await window.caches.keys()
|
||||
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
|
||||
assert(cacheKey)
|
||||
|
||||
cache = await window.caches.open(cacheKey)
|
||||
const keys = await cache.keys()
|
||||
request = keys.find((key) => key.url.match(/index/))
|
||||
assert(request)
|
||||
|
||||
response = await cache.match(request)
|
||||
assert(response)
|
||||
|
||||
await cache.put(request, new Response())
|
||||
})
|
||||
})
|
||||
after(() => {
|
||||
// Restores the index.html in the cache so that re-runs behave as expected.
|
||||
// This is necessary because the Service Worker will not re-populate the cache.
|
||||
cy.then(async () => {
|
||||
if (cache && request && response) {
|
||||
await cache.put(request, response)
|
||||
}
|
||||
})
|
||||
})
|
||||
it('reports the miss to analytics', () => {
|
||||
cy.visit('/', { serviceWorker: true })
|
||||
cy.wait('@ServiceWorker:miss')
|
||||
})
|
||||
.visit('/', { serviceWorker: true })
|
||||
.get('#swap-page')
|
||||
.wait('@CacheMiss', { timeout: 20000 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
import { BigNumber } from '@ethersproject/bignumber'
|
||||
import { parseEther } from '@ethersproject/units'
|
||||
import { SupportedChainId, WETH9 } 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('swap-settings-button')).click()
|
||||
cy.contains('Max slippage').should('exist')
|
||||
cy.contains('Transaction deadline').should('exist')
|
||||
cy.contains('Auto Router API').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', '')
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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))
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
114
cypress/e2e/swap/errors.test.ts
Normal file
114
cypress/e2e/swap/errors.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { BigNumber } from '@ethersproject/bignumber'
|
||||
import { SupportedChainId } from '@uniswap/sdk-core'
|
||||
|
||||
import { UNI, USDC_MAINNET } from '../../../src/constants/tokens'
|
||||
import { getBalance, getTestSelector } from '../../utils'
|
||||
|
||||
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
|
||||
|
||||
describe('Swap errors', () => {
|
||||
it('wallet rejection', () => {
|
||||
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`, { ethereum: 'hardhat' })
|
||||
cy.hardhat().then((hardhat) => {
|
||||
// Stub the wallet to reject any transaction.
|
||||
cy.stub(hardhat.wallet, 'sendTransaction').log(false).rejects(new Error('user cancelled'))
|
||||
|
||||
// Attempt to swap.
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('1').should('have.value', '1')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '')
|
||||
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('transaction past deadline', () => {
|
||||
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`, { ethereum: 'hardhat' })
|
||||
cy.hardhat({ automine: false })
|
||||
getBalance(USDC_MAINNET).then((initialBalance) => {
|
||||
// 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('1') // 1 minute
|
||||
|
||||
// Click outside of modal to dismiss it.
|
||||
cy.get('body').click('topRight')
|
||||
cy.get(getTestSelector('deadline-input')).should('not.exist')
|
||||
|
||||
// Attempt to swap.
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('1').should('have.value', '1')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '')
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
|
||||
|
||||
// The pending transaction indicator should reflect the state.
|
||||
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
|
||||
cy.hardhat().then((hardhat) => hardhat.mine(1, /* 10 minutes */ 1000 * 60 * 10)) // mines past the deadline
|
||||
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
|
||||
|
||||
// TODO(WEB-2085): Fix this test - transaction popups are flakey.
|
||||
// cy.get(getTestSelector('transaction-popup')).contains('Swap failed')
|
||||
|
||||
// Verify the balance is unchanged.
|
||||
cy.get('#swap-currency-output [data-testid="balance-text"]').should('have.text', `Balance: ${initialBalance}`)
|
||||
getBalance(USDC_MAINNET).should('eq', initialBalance)
|
||||
})
|
||||
})
|
||||
|
||||
it('slippage failure', () => {
|
||||
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${UNI_MAINNET.address}`, { ethereum: 'hardhat' })
|
||||
cy.hardhat({ automine: false })
|
||||
getBalance(USDC_MAINNET).then((initialBalance) => {
|
||||
// Gas estimation fails for this transaction (that would normally fail), so we stub it.
|
||||
cy.hardhat().then((hardhat) => {
|
||||
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')
|
||||
|
||||
// Click outside of modal to dismiss it.
|
||||
cy.get('body').click('topRight')
|
||||
cy.get(getTestSelector('slippage-input')).should('not.exist')
|
||||
|
||||
// Swap 2 times.
|
||||
const AMOUNT_TO_SWAP = 200
|
||||
cy.get('#swap-currency-input .token-amount-input')
|
||||
.clear()
|
||||
.type(AMOUNT_TO_SWAP.toString())
|
||||
.should('have.value', AMOUNT_TO_SWAP.toString())
|
||||
cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '')
|
||||
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(AMOUNT_TO_SWAP.toString())
|
||||
.should('have.value', AMOUNT_TO_SWAP.toString())
|
||||
cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '')
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
|
||||
|
||||
// The pending transaction indicator should reflect the state.
|
||||
cy.get(getTestSelector('web3-status-connected')).should('contain', '2 Pending')
|
||||
cy.hardhat().then((hardhat) => hardhat.mine())
|
||||
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
|
||||
|
||||
// TODO(WEB-2085): Fix this test - transaction popups are flakey.
|
||||
// cy.get(getTestSelector('transaction-popup')).contains('Swap failed')
|
||||
|
||||
// Assert that the transactions were unsuccessful by checking on-chain balance.
|
||||
getBalance(UNI_MAINNET).should('equal', initialBalance)
|
||||
})
|
||||
})
|
||||
})
|
||||
14
cypress/e2e/swap/settings.test.ts
Normal file
14
cypress/e2e/swap/settings.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getTestSelector } from '../../utils'
|
||||
|
||||
describe('Swap settings', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
70
cypress/e2e/swap/swap.test.ts
Normal file
70
cypress/e2e/swap/swap.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { SupportedChainId } from '@uniswap/sdk-core'
|
||||
|
||||
import { UNI, USDC_MAINNET } from '../../../src/constants/tokens'
|
||||
import { getBalance, getTestSelector } from '../../utils'
|
||||
|
||||
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
|
||||
|
||||
describe('Swap', () => {
|
||||
describe('Swap on main page', () => {
|
||||
it('starts with ETH selected by default', () => {
|
||||
cy.visit('/swap')
|
||||
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('should default inputs from URL params ', () => {
|
||||
cy.visit(`/swap?inputCurrency=${UNI_MAINNET.address}`)
|
||||
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}`)
|
||||
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}`)
|
||||
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('inputs reset when navigating between pages', () => {
|
||||
cy.visit('/swap')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('have.value', '')
|
||||
cy.get('#swap-currency-input .token-amount-input').type('0.01').should('have.value', '0.01')
|
||||
cy.visit('/pool').visit('/swap')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('have.value', '')
|
||||
})
|
||||
|
||||
it('swaps ETH for USDC', () => {
|
||||
cy.visit('/swap', { ethereum: 'hardhat' })
|
||||
cy.hardhat({ automine: false })
|
||||
getBalance(USDC_MAINNET).then((initialBalance) => {
|
||||
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').should('have.value', '1')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '')
|
||||
cy.get('#swap-button').click()
|
||||
cy.get('#confirm-swap-or-send').click()
|
||||
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
|
||||
|
||||
// The pending transaction indicator should reflect the state.
|
||||
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
|
||||
cy.hardhat().then((hardhat) => hardhat.mine())
|
||||
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
|
||||
|
||||
// TODO(WEB-2085): Fix this test - transaction popups are flakey.
|
||||
// cy.get(getTestSelector('transaction-popup')).contains('Swapped')
|
||||
|
||||
// Verify the balance is updated.
|
||||
cy.get('#swap-currency-output [data-testid="balance-text"]').should(
|
||||
'have.text',
|
||||
`Balance: ${initialBalance + 1}`
|
||||
)
|
||||
getBalance(USDC_MAINNET).should('eq', initialBalance + 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
86
cypress/e2e/swap/wrap.test.ts
Normal file
86
cypress/e2e/swap/wrap.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { CurrencyAmount, SupportedChainId, WETH9 } from '@uniswap/sdk-core'
|
||||
|
||||
import { getBalance, getTestSelector } from '../../utils'
|
||||
|
||||
const WETH = WETH9[SupportedChainId.MAINNET]
|
||||
|
||||
describe('Swap wrap', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${WETH.address}`, { ethereum: 'hardhat' }).hardhat({
|
||||
automine: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('ETH to wETH is same value (wrapped swaps have no price impact)', () => {
|
||||
cy.get('#swap-currency-input .token-amount-input').clear().type('0.01').should('have.value', '0.01')
|
||||
cy.get('#swap-currency-output .token-amount-input').should('have.value', '0.01')
|
||||
|
||||
cy.get('#swap-currency-output .token-amount-input').clear().type('0.02').should('have.value', '0.02')
|
||||
cy.get('#swap-currency-input .token-amount-input').should('have.value', '0.02')
|
||||
})
|
||||
|
||||
it('should be able to wrap ETH', () => {
|
||||
getBalance(WETH).then((initialWethBalance) => {
|
||||
cy.contains('Enter ETH amount')
|
||||
|
||||
// Enter the amount to wrap.
|
||||
cy.get('#swap-currency-output .token-amount-input').click().type('1').should('have.value', 1)
|
||||
// This also ensures we don't click "Wrap" before the UI has caught up.
|
||||
cy.get('#swap-currency-input .token-amount-input').should('have.value', 1)
|
||||
|
||||
// Click the wrap button.
|
||||
cy.contains('Wrap').click()
|
||||
|
||||
// The pending transaction indicator should reflect the state.
|
||||
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
|
||||
cy.hardhat().then((hardhat) => hardhat.mine())
|
||||
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
|
||||
|
||||
// TODO(WEB-2085): Fix this test - transaction popups are flakey.
|
||||
// cy.get(getTestSelector('transaction-popup')).contains('Wrapped')
|
||||
// cy.get(getTestSelector('transaction-popup')).contains('1.00 ETH for 1.00 WETH')
|
||||
|
||||
// The UI balance should have increased.
|
||||
cy.get('#swap-currency-output').should('contain', `Balance: ${initialWethBalance + 1}`)
|
||||
|
||||
// The user's WETH account balance should have increased
|
||||
getBalance(WETH).should('equal', initialWethBalance + 1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should be able to unwrap WETH', () => {
|
||||
cy.hardhat().then(async (hardhat) => {
|
||||
await hardhat.fund(hardhat.wallet, CurrencyAmount.fromRawAmount(WETH, 1e18))
|
||||
await hardhat.mine()
|
||||
})
|
||||
|
||||
getBalance(WETH).then((initialWethBalance) => {
|
||||
// Swap input/output to unwrap WETH.
|
||||
cy.get(getTestSelector('swap-currency-button')).click()
|
||||
cy.contains('Enter WETH amount')
|
||||
|
||||
// Enter the amount to unwrap.
|
||||
cy.get('#swap-currency-output .token-amount-input').click().type('1').should('have.value', 1)
|
||||
// This also ensures we don't click "Wrap" before the UI has caught up.
|
||||
cy.get('#swap-currency-input .token-amount-input').should('have.value', 1)
|
||||
|
||||
// Click the unwrap button.
|
||||
cy.contains('Unwrap').click()
|
||||
|
||||
// The pending transaction indicator should reflect the state.
|
||||
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
|
||||
cy.hardhat().then((hardhat) => hardhat.mine())
|
||||
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
|
||||
|
||||
// TODO(WEB-2085): Fix this test - transaction popups are flakey.
|
||||
// cy.get(getTestSelector('transaction-popup')).contains('Unwrapped')
|
||||
// cy.get(getTestSelector('transaction-popup')).contains('1.00 WETH for 1.00 ETH')
|
||||
|
||||
// The UI balance should have increased.
|
||||
cy.get('#swap-currency-input').should('contain', `Balance: ${initialWethBalance - 1}`)
|
||||
|
||||
// The user's WETH account balance should have increased
|
||||
getBalance(WETH).should('equal', initialWethBalance - 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { SupportedChainId, WETH9 } from '@uniswap/sdk-core'
|
||||
|
||||
import { UNI } from '../../src/constants/tokens'
|
||||
import { FeatureFlag } from '../../src/featureFlags/flags/featureFlags'
|
||||
import { getTestSelector } from '../utils'
|
||||
|
||||
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
|
||||
@@ -96,7 +95,6 @@ describe('Token details', () => {
|
||||
cy.viewport(1200, 800)
|
||||
cy.visit(`/tokens/ethereum/${UNI_MAINNET.address}`, {
|
||||
ethereum: 'hardhat',
|
||||
featureFlags: [FeatureFlag.removeWidget],
|
||||
}).then(() => {
|
||||
cy.wait('@eth_blockNumber')
|
||||
cy.scrollTo('top')
|
||||
@@ -121,7 +119,7 @@ describe('Token details', () => {
|
||||
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', { featureFlags: [FeatureFlag.removeWidget] })
|
||||
cy.visit('/swap')
|
||||
cy.contains('UNI').should('not.exist')
|
||||
cy.contains('WETH').should('not.exist')
|
||||
})
|
||||
@@ -147,7 +145,7 @@ describe('Token details', () => {
|
||||
})
|
||||
|
||||
it('should show a L2 token even if the user is connected to a different network', () => {
|
||||
cy.visit('/tokens', { ethereum: 'hardhat', featureFlags: [FeatureFlag.removeWidget] })
|
||||
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')
|
||||
|
||||
@@ -1,96 +1,108 @@
|
||||
import { getTestSelector } from '../utils'
|
||||
|
||||
function visit(darkMode: boolean) {
|
||||
cy.visit('/swap', {
|
||||
onBeforeLoad(win) {
|
||||
cy.stub(win, 'matchMedia')
|
||||
.withArgs('(prefers-color-scheme: dark)')
|
||||
.returns({
|
||||
matches: darkMode,
|
||||
addEventListener() {
|
||||
// do nothing
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Wallet Dropdown', () => {
|
||||
before(() => {
|
||||
cy.visit('/pools')
|
||||
function itShouldChangeTheTheme() {
|
||||
it('should change the theme', () => {
|
||||
cy.get(getTestSelector('theme-lightmode')).click()
|
||||
|
||||
cy.get(getTestSelector('theme-lightmode')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-darkmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-auto')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
|
||||
cy.get(getTestSelector('theme-darkmode')).click()
|
||||
cy.get(getTestSelector('theme-lightmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-darkmode')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-auto')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
|
||||
cy.get(getTestSelector('theme-auto')).click()
|
||||
cy.get(getTestSelector('theme-lightmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-darkmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-auto')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
})
|
||||
}
|
||||
|
||||
function itShouldChangeTheLanguage() {
|
||||
it('should select a language', () => {
|
||||
cy.get(getTestSelector('wallet-language-item')).contains('Deutsch').click({ force: true })
|
||||
cy.get(getTestSelector('wallet-header')).should('contain', 'Sprache')
|
||||
cy.get(getTestSelector('wallet-language-item')).contains('English').click({ force: true })
|
||||
cy.get(getTestSelector('wallet-header')).should('contain', 'Language')
|
||||
cy.get(getTestSelector('wallet-back')).click()
|
||||
})
|
||||
}
|
||||
|
||||
describe('connected', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/')
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
})
|
||||
itShouldChangeTheTheme()
|
||||
itShouldChangeTheLanguage()
|
||||
})
|
||||
|
||||
it('should change the theme', () => {
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
cy.get(getTestSelector('theme-lightmode')).click()
|
||||
|
||||
cy.get(getTestSelector('theme-lightmode')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-darkmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-auto')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
|
||||
cy.get(getTestSelector('theme-darkmode')).click()
|
||||
cy.get(getTestSelector('theme-lightmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-darkmode')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-auto')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
|
||||
cy.get(getTestSelector('theme-auto')).click()
|
||||
cy.get(getTestSelector('theme-lightmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-darkmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
cy.get(getTestSelector('theme-auto')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
|
||||
describe('disconnected', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/')
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-disconnect')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
})
|
||||
itShouldChangeTheTheme()
|
||||
itShouldChangeTheLanguage()
|
||||
})
|
||||
|
||||
it('should select a language', () => {
|
||||
cy.get(getTestSelector('wallet-language-item')).contains('Deutsch').click({ force: true })
|
||||
cy.get(getTestSelector('wallet-header')).should('contain', 'Sprache')
|
||||
cy.get(getTestSelector('wallet-language-item')).contains('English').click({ force: true })
|
||||
cy.get(getTestSelector('wallet-header')).should('contain', 'Language')
|
||||
cy.get(getTestSelector('wallet-back')).click()
|
||||
describe('with color theme', () => {
|
||||
function visitSwapWithColorTheme({ dark }: { dark: boolean }) {
|
||||
cy.visit('/swap', {
|
||||
onBeforeLoad(win) {
|
||||
cy.stub(win, 'matchMedia')
|
||||
.withArgs('(prefers-color-scheme: dark)')
|
||||
.returns({
|
||||
matches: dark,
|
||||
addEventListener() {
|
||||
/* noop */
|
||||
},
|
||||
removeEventListener() {
|
||||
/* noop */
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
it('should properly use dark system theme when auto theme setting is selected', () => {
|
||||
visitSwapWithColorTheme({ dark: true })
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
cy.get(getTestSelector('theme-auto')).click()
|
||||
cy.get(getTestSelector('wallet-header')).should('have.css', 'color', 'rgb(152, 161, 192)')
|
||||
})
|
||||
|
||||
it('should properly use light system theme when auto theme setting is selected', () => {
|
||||
visitSwapWithColorTheme({ dark: false })
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
cy.get(getTestSelector('theme-auto')).click()
|
||||
cy.get(getTestSelector('wallet-header')).should('have.css', 'color', 'rgb(119, 128, 160)')
|
||||
})
|
||||
})
|
||||
|
||||
it('should change the theme when not connected', () => {
|
||||
cy.get(getTestSelector('wallet-disconnect')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
cy.get(getTestSelector('theme-lightmode')).should('exist')
|
||||
})
|
||||
describe('mobile', () => {
|
||||
beforeEach(() => {
|
||||
cy.viewport('iphone-6').visit('/')
|
||||
})
|
||||
|
||||
it('should select a language when not connected', () => {
|
||||
cy.get(getTestSelector('wallet-language-item')).contains('Deutsch').click({ force: true })
|
||||
cy.get(getTestSelector('wallet-header')).should('contain', 'Sprache')
|
||||
cy.get(getTestSelector('wallet-language-item')).contains('English').click({ force: true })
|
||||
cy.get(getTestSelector('wallet-header')).should('contain', 'Language')
|
||||
cy.get(getTestSelector('wallet-back')).click()
|
||||
})
|
||||
it('should dismiss the wallet bottom sheet when clicking buy crypto', () => {
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-buy-crypto')).click()
|
||||
cy.contains('Buy crypto').should('not.be.visible')
|
||||
})
|
||||
|
||||
it('should properly use dark system theme when auto theme setting is selected', () => {
|
||||
visit(true)
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
cy.get(getTestSelector('theme-auto')).click()
|
||||
cy.get(getTestSelector('wallet-header')).should('have.css', 'color', 'rgb(152, 161, 192)')
|
||||
})
|
||||
|
||||
it('should properly use light system theme when auto theme setting is selected', () => {
|
||||
visit(false)
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-settings')).click()
|
||||
cy.get(getTestSelector('theme-auto')).click()
|
||||
cy.get(getTestSelector('wallet-header')).should('have.css', 'color', 'rgb(119, 128, 160)')
|
||||
})
|
||||
|
||||
it('should dismiss the wallet bottom sheet when clicking buy crypto', () => {
|
||||
visit(false)
|
||||
cy.viewport('iphone-6')
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.get(getTestSelector('wallet-buy-crypto')).click()
|
||||
cy.contains('Buy crypto').should('not.be.visible')
|
||||
})
|
||||
|
||||
it('should use a bottom sheet and dismiss when on a mobile screen size', () => {
|
||||
visit(true)
|
||||
cy.viewport('iphone-6')
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.root().click(15, 40)
|
||||
cy.get(getTestSelector('wallet-settings')).should('not.be.visible')
|
||||
it('should use a bottom sheet and dismiss when on a mobile screen size', () => {
|
||||
cy.get(getTestSelector('web3-status-connected')).click()
|
||||
cy.root().click(15, 40)
|
||||
cy.get(getTestSelector('wallet-settings')).should('not.be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import { Currency } from '@uniswap/sdk-core'
|
||||
|
||||
export const getTestSelector = (selectorId: string) => `[data-testid=${selectorId}]`
|
||||
|
||||
export const getTestSelectorStartsWith = (selectorId: string) => `[data-testid^=${selectorId}]`
|
||||
|
||||
/** Gets the balance of a token as a Chainable. */
|
||||
export function getBalance(token: Currency) {
|
||||
return cy
|
||||
.hardhat()
|
||||
.then((hardhat) => hardhat.getBalance(hardhat.wallet, token))
|
||||
.then((balance) => Number(balance.toFixed(1)))
|
||||
}
|
||||
|
||||
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(...))`',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -19,7 +19,7 @@ module.exports = {
|
||||
chainId: 1,
|
||||
forking: mainnetFork,
|
||||
accounts: {
|
||||
count: 1,
|
||||
count: 2,
|
||||
},
|
||||
mining: {
|
||||
auto: true, // automine to make tests easier to write.
|
||||
|
||||
@@ -1,51 +1,3 @@
|
||||
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: [
|
||||
{
|
||||
@@ -108,7 +60,6 @@ const linguiConfig = {
|
||||
runtimeConfigModule: ['@lingui/core', 'i18n'],
|
||||
sourceLocale: 'en-US',
|
||||
pseudoLocale: 'pseudo',
|
||||
extractors: [cachingExtractor],
|
||||
}
|
||||
|
||||
export default linguiConfig
|
||||
|
||||
29
package.json
29
package.json
@@ -18,7 +18,7 @@
|
||||
"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",
|
||||
"prepare": "concurrently \"npm:ajv\" \"npm:contracts\" \"npm:graphql\" \"npm:i18n\"",
|
||||
"start": "craco start",
|
||||
"build": "craco build",
|
||||
"build:e2e": "REACT_APP_CSP_ALLOW_UNSAFE_EVAL=true REACT_APP_ADD_COVERAGE_INSTRUMENTATION=true craco build",
|
||||
@@ -67,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",
|
||||
@@ -98,24 +98,29 @@
|
||||
"@types/ua-parser-js": "^0.7.35",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/wcag-contrast": "^3.0.0",
|
||||
"@uniswap/eslint-config": "^1.2.0",
|
||||
"@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",
|
||||
"cypress": "10.3.1",
|
||||
"buffer": "^6.0.3",
|
||||
"concurrently": "^8.0.1",
|
||||
"cypress": "12.12.0",
|
||||
"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",
|
||||
@@ -136,6 +141,7 @@
|
||||
"@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",
|
||||
@@ -169,7 +175,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",
|
||||
@@ -254,18 +259,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",
|
||||
|
||||
@@ -10,14 +10,14 @@ export enum CardType {
|
||||
Secondary = 'Secondary',
|
||||
}
|
||||
|
||||
const StyledCard = styled.div<{ isDarkMode: boolean; backgroundImgSrc?: string; type: CardType }>`
|
||||
const StyledCard = styled.div<{ $isDarkMode: boolean; $backgroundImgSrc?: string; $type: CardType }>`
|
||||
display: flex;
|
||||
background: ${({ isDarkMode, backgroundImgSrc, type, theme }) =>
|
||||
isDarkMode
|
||||
? `${type === CardType.Primary ? theme.backgroundModule : theme.backgroundSurface} ${
|
||||
backgroundImgSrc ? ` url(${backgroundImgSrc})` : ''
|
||||
background: ${({ $isDarkMode, $backgroundImgSrc, $type, theme }) =>
|
||||
$isDarkMode
|
||||
? `${$type === CardType.Primary ? theme.backgroundModule : theme.backgroundSurface} ${
|
||||
$backgroundImgSrc ? ` url(${$backgroundImgSrc})` : ''
|
||||
}`
|
||||
: `${type === CardType.Primary ? 'white' : theme.backgroundModule} url(${backgroundImgSrc})`};
|
||||
: `${$type === CardType.Primary ? 'white' : theme.backgroundModule} url(${$backgroundImgSrc})`};
|
||||
background-size: auto 100%;
|
||||
background-position: right;
|
||||
background-repeat: no-repeat;
|
||||
@@ -30,15 +30,15 @@ const StyledCard = styled.div<{ isDarkMode: boolean; backgroundImgSrc?: string;
|
||||
padding: 24px;
|
||||
height: 212px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid ${({ theme, type }) => (type === CardType.Primary ? 'transparent' : theme.backgroundOutline)};
|
||||
border: 1px solid ${({ theme, $type }) => ($type === CardType.Primary ? 'transparent' : theme.backgroundOutline)};
|
||||
box-shadow: 0px 10px 24px 0px rgba(51, 53, 72, 0.04);
|
||||
transition: ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.ease} border`};
|
||||
|
||||
&:hover {
|
||||
border: 1px solid ${({ theme, isDarkMode }) => (isDarkMode ? theme.backgroundInteractive : theme.textTertiary)};
|
||||
border: 1px solid ${({ theme, $isDarkMode }) => ($isDarkMode ? theme.backgroundInteractive : theme.textTertiary)};
|
||||
}
|
||||
@media screen and (min-width: ${BREAKPOINTS.sm}px) {
|
||||
height: ${({ backgroundImgSrc }) => (backgroundImgSrc ? 360 : 260)}px;
|
||||
height: ${({ $backgroundImgSrc }) => ($backgroundImgSrc ? 360 : 260)}px;
|
||||
}
|
||||
@media screen and (min-width: ${BREAKPOINTS.xl}px) {
|
||||
padding: 32px;
|
||||
@@ -125,14 +125,14 @@ const Card = ({
|
||||
return (
|
||||
<TraceEvent events={[BrowserEvent.onClick]} name={SharedEventName.ELEMENT_CLICKED} element={elementName}>
|
||||
<StyledCard
|
||||
type={type}
|
||||
as={external ? 'a' : Link}
|
||||
to={external ? undefined : to}
|
||||
href={external ? to : undefined}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopenener noreferrer' : undefined}
|
||||
isDarkMode={isDarkMode}
|
||||
backgroundImgSrc={backgroundImgSrc}
|
||||
$backgroundImgSrc={backgroundImgSrc}
|
||||
$isDarkMode={isDarkMode}
|
||||
$type={type}
|
||||
>
|
||||
<TitleRow>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
|
||||
@@ -266,7 +266,7 @@ export default function AuthenticatedHeader({ account, openSettings }: { account
|
||||
<PortfolioDrawerContainer>
|
||||
{totalBalance !== undefined ? (
|
||||
<FadeInColumn gap="xs">
|
||||
<ThemedText.HeadlineLarge fontWeight={500}>
|
||||
<ThemedText.HeadlineLarge fontWeight={500} data-testid="portfolio-total-balance">
|
||||
{formatNumber(totalBalance, NumberType.PortfolioBalance)}
|
||||
</ThemedText.HeadlineLarge>
|
||||
<AutoRow marginBottom="20px">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { useMultichainTransactions } from 'state/transactions/hooks'
|
||||
@@ -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> = {}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,12 +22,14 @@ const EndColumn = styled(Column)`
|
||||
`
|
||||
|
||||
export default function PortfolioRow({
|
||||
['data-testid']: testId,
|
||||
left,
|
||||
title,
|
||||
descriptor,
|
||||
right,
|
||||
onClick,
|
||||
}: {
|
||||
'data-testid'?: string
|
||||
left: React.ReactNode
|
||||
title: React.ReactNode
|
||||
descriptor?: React.ReactNode
|
||||
@@ -36,7 +38,7 @@ export default function PortfolioRow({
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<PortfolioRowWrapper onClick={onClick}>
|
||||
<PortfolioRowWrapper data-testid={testId} onClick={onClick}>
|
||||
{left}
|
||||
<AutoColumn grow>
|
||||
{title}
|
||||
|
||||
@@ -13,7 +13,7 @@ function wasPending(previousTxs: { [hash: string]: TransactionDetails | undefine
|
||||
return previousTx && isTxPending(previousTx)
|
||||
}
|
||||
|
||||
function useHasUpdatedTx() {
|
||||
function useHasUpdatedTx(account: string | undefined) {
|
||||
// TODO: consider monitoring tx's on chains other than the wallet's current chain
|
||||
const currentChainTxs = useAllTransactions()
|
||||
|
||||
@@ -27,12 +27,12 @@ function useHasUpdatedTx() {
|
||||
const previousPendingTxs = usePrevious(pendingTxs)
|
||||
|
||||
return useMemo(() => {
|
||||
if (!previousPendingTxs) return false
|
||||
if (!previousPendingTxs || !account) return false
|
||||
return Object.values(currentChainTxs).some(
|
||||
(tx) => !isTxPending(tx) && wasPending(previousPendingTxs, tx),
|
||||
(tx) => tx.from === account && !isTxPending(tx) && wasPending(previousPendingTxs, tx),
|
||||
[currentChainTxs, previousPendingTxs]
|
||||
)
|
||||
}, [currentChainTxs, previousPendingTxs])
|
||||
}, [account, currentChainTxs, previousPendingTxs])
|
||||
}
|
||||
|
||||
/* Prefetches & caches portfolio balances when the wrapped component is hovered or the user completes a transaction */
|
||||
@@ -49,16 +49,22 @@ export default function PrefetchBalancesWrapper({ children }: PropsWithChildren)
|
||||
}
|
||||
}, [account, prefetchPortfolioBalances])
|
||||
|
||||
// TODO(cartcrom): add delay for refetching on optimism, as there is high latency in new balances being available
|
||||
const hasUpdatedTx = useHasUpdatedTx()
|
||||
// Listens for recently updated transactions to keep portfolio balances fresh in apollo cache
|
||||
useEffect(() => {
|
||||
if (!hasUpdatedTx) return
|
||||
const prevAccount = usePrevious(account)
|
||||
|
||||
// If the drawer is open, fetch balances immediately, else set a flag to fetch on next hover
|
||||
if (drawerOpen) fetchBalances()
|
||||
else setHasUnfetchedBalances(true)
|
||||
}, [drawerOpen, fetchBalances, hasUpdatedTx])
|
||||
// TODO(cartcrom): add delay for refetching on optimism, as there is high latency in new balances being available
|
||||
const hasUpdatedTx = useHasUpdatedTx(account)
|
||||
// Listens for account changes & recently updated transactions to keep portfolio balances fresh in apollo cache
|
||||
useEffect(() => {
|
||||
const accountChanged = prevAccount !== undefined && prevAccount !== account
|
||||
if (hasUpdatedTx || accountChanged) {
|
||||
// If the drawer is open, fetch balances immediately, else set a flag to fetch on next hover
|
||||
if (drawerOpen) {
|
||||
fetchBalances()
|
||||
} else {
|
||||
setHasUnfetchedBalances(true)
|
||||
}
|
||||
}
|
||||
}, [account, prevAccount, drawerOpen, fetchBalances, hasUpdatedTx])
|
||||
|
||||
const onHover = useCallback(() => {
|
||||
if (hasUnfetchedBalances) fetchBalances()
|
||||
|
||||
@@ -205,7 +205,7 @@ function AccountDrawer() {
|
||||
name={InterfaceEventName.MINI_PORTFOLIO_TOGGLED}
|
||||
properties={{ type: 'close' }}
|
||||
>
|
||||
<CloseDrawer onClick={toggleWalletDrawer}>
|
||||
<CloseDrawer onClick={toggleWalletDrawer} data-testid="close-account-drawer">
|
||||
<CloseIcon />
|
||||
</CloseDrawer>
|
||||
</TraceEvent>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// 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>
|
||||
`;
|
||||
@@ -1,20 +1,31 @@
|
||||
import { fireEvent, render, screen } from 'test-utils/render'
|
||||
import noop from 'utils/noop'
|
||||
|
||||
import Expand from './index'
|
||||
|
||||
describe('Expand', () => {
|
||||
it('renders correctly', () => {
|
||||
const { asFragment } = render(
|
||||
<Expand header={<span>Header</span>} button={<span>Button</span>}>
|
||||
it('does not render children when closed', () => {
|
||||
render(
|
||||
<Expand header={<span>Header</span>} isOpen={false} onToggle={noop} button={<span>Button</span>}>
|
||||
Body
|
||||
</Expand>
|
||||
)
|
||||
expect(asFragment()).toMatchSnapshot()
|
||||
expect(screen.queryByText('Body')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles children on button press', () => {
|
||||
it('renders children when open', () => {
|
||||
render(
|
||||
<Expand header={<span>Header</span>} button={<span>Button</span>}>
|
||||
<Expand header={<span>Header</span>} isOpen={true} onToggle={noop} button={<span>Button</span>}>
|
||||
Body
|
||||
</Expand>
|
||||
)
|
||||
expect(screen.queryByText('Body')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls `onToggle` when button is pressed', () => {
|
||||
const onToggle = jest.fn()
|
||||
render(
|
||||
<Expand header={<span>Header</span>} isOpen={false} onToggle={onToggle} button={<span>Button</span>}>
|
||||
Body
|
||||
</Expand>
|
||||
)
|
||||
@@ -22,9 +33,6 @@ describe('Expand', () => {
|
||||
const button = screen.getByText('Button')
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(screen.queryByText('Body')).not.toBeNull()
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(screen.queryByText('Body')).toBeNull()
|
||||
expect(onToggle).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Column from 'components/Column'
|
||||
import React, { PropsWithChildren, ReactElement, useState } from 'react'
|
||||
import React, { PropsWithChildren, ReactElement } from 'react'
|
||||
import { ChevronDown } from 'react-feather'
|
||||
import styled from 'styled-components/macro'
|
||||
|
||||
@@ -11,9 +11,9 @@ const ButtonContainer = styled(Row)`
|
||||
width: unset;
|
||||
`
|
||||
|
||||
const ExpandIcon = styled(ChevronDown)<{ $isExpanded: boolean }>`
|
||||
const ExpandIcon = styled(ChevronDown)<{ $isOpen: boolean }>`
|
||||
color: ${({ theme }) => theme.textSecondary};
|
||||
transform: ${({ $isExpanded }) => ($isExpanded ? 'rotate(180deg)' : 'rotate(0deg)')};
|
||||
transform: ${({ $isOpen }) => ($isOpen ? 'rotate(180deg)' : 'rotate(0deg)')};
|
||||
transition: transform ${({ theme }) => theme.transition.duration.medium};
|
||||
`
|
||||
|
||||
@@ -22,22 +22,25 @@ export default function Expand({
|
||||
button,
|
||||
children,
|
||||
testId,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: PropsWithChildren<{
|
||||
header: ReactElement
|
||||
button: ReactElement
|
||||
testId?: string
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
}>) {
|
||||
const [isExpanded, setExpanded] = useState(false)
|
||||
return (
|
||||
<Column gap="md">
|
||||
<RowBetween>
|
||||
{header}
|
||||
<ButtonContainer data-testid={testId} onClick={() => setExpanded(!isExpanded)} aria-expanded={isExpanded}>
|
||||
<ButtonContainer data-testid={testId} onClick={onToggle} aria-expanded={isOpen}>
|
||||
{button}
|
||||
<ExpandIcon $isExpanded={isExpanded} />
|
||||
<ExpandIcon $isOpen={isOpen} />
|
||||
</ButtonContainer>
|
||||
</RowBetween>
|
||||
{isExpanded && children}
|
||||
{isOpen && children}
|
||||
</Column>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseVariant, FeatureFlag, featureFlagSettings, useUpdateFlag } from 'featureFlags'
|
||||
import { DetailsV2Variant, useDetailsV2Flag } from 'featureFlags/flags/nftDetails'
|
||||
import { useWidgetRemovalFlag, WidgetRemovalVariant } from 'featureFlags/flags/removeWidgetTdp'
|
||||
import { TraceJsonRpcVariant, useTraceJsonRpcFlag } from 'featureFlags/flags/traceJsonRpc'
|
||||
import { UnifiedRouterVariant, useUnifiedRoutingAPIFlag } from 'featureFlags/flags/unifiedRouter'
|
||||
import { useUpdateAtom } from 'jotai/utils'
|
||||
import { Children, PropsWithChildren, ReactElement, ReactNode, useCallback, useState } from 'react'
|
||||
import { X } from 'react-feather'
|
||||
@@ -209,10 +209,10 @@ export default function FeatureFlagModal() {
|
||||
label="Use the new details page for nfts"
|
||||
/>
|
||||
<FeatureFlagOption
|
||||
variant={WidgetRemovalVariant}
|
||||
value={useWidgetRemovalFlag()}
|
||||
featureFlag={FeatureFlag.removeWidget}
|
||||
label="Swap Component on TDP"
|
||||
variant={UnifiedRouterVariant}
|
||||
value={useUnifiedRoutingAPIFlag()}
|
||||
featureFlag={FeatureFlag.uraEnabled}
|
||||
label="Enable the Unified Routing API"
|
||||
/>
|
||||
<FeatureFlagGroup name="Debug">
|
||||
<FeatureFlagOption
|
||||
|
||||
@@ -29,6 +29,7 @@ function TransactionPopupContent({ tx, chainId }: { tx: TransactionDetails; chai
|
||||
|
||||
return (
|
||||
<PortfolioRow
|
||||
data-testid="transaction-popup"
|
||||
left={
|
||||
success ? (
|
||||
<Column>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Box } from 'rebass/styled-components'
|
||||
import styled, { DefaultTheme } from 'styled-components/macro'
|
||||
|
||||
type Gap = keyof DefaultTheme['grids']
|
||||
import styled from 'styled-components/macro'
|
||||
import { Gap } from 'theme'
|
||||
|
||||
// TODO(WEB-3289):
|
||||
// Setting `width: 100%` by default prevents composability in complex flex layouts.
|
||||
@@ -14,7 +13,7 @@ const Row = styled(Box)<{
|
||||
padding?: string
|
||||
border?: string
|
||||
borderRadius?: string
|
||||
gap?: string
|
||||
gap?: Gap | string
|
||||
}>`
|
||||
width: ${({ width }) => width ?? '100%'};
|
||||
display: flex;
|
||||
|
||||
@@ -8,8 +8,12 @@ import MaxSlippageSettings from '.'
|
||||
|
||||
const AUTO_SLIPPAGE = new Percent(5, 10_000)
|
||||
|
||||
const renderAndExpandSlippageSettings = () => {
|
||||
const renderSlippageSettings = () => {
|
||||
render(<MaxSlippageSettings autoSlippage={AUTO_SLIPPAGE} />)
|
||||
}
|
||||
|
||||
const renderAndExpandSlippageSettings = () => {
|
||||
renderSlippageSettings()
|
||||
|
||||
// By default, the button to expand Slippage component and show `input` will have `Auto` label
|
||||
fireEvent.click(screen.getByText('Auto'))
|
||||
@@ -20,7 +24,7 @@ const switchToCustomSlippage = () => {
|
||||
fireEvent.click(screen.getByText('Custom'))
|
||||
}
|
||||
|
||||
const getSlippageInput = () => screen.getByTestId('slippage-input') as HTMLInputElement
|
||||
const getSlippageInput = () => screen.queryByTestId('slippage-input') as HTMLInputElement
|
||||
|
||||
describe('MaxSlippageSettings', () => {
|
||||
describe('input', () => {
|
||||
@@ -28,6 +32,15 @@ describe('MaxSlippageSettings', () => {
|
||||
beforeEach(() => {
|
||||
store.dispatch(updateUserSlippageTolerance({ userSlippageTolerance: SlippageTolerance.Auto }))
|
||||
})
|
||||
it('is not expanded by default', () => {
|
||||
renderSlippageSettings()
|
||||
expect(getSlippageInput()).not.toBeInTheDocument()
|
||||
})
|
||||
it('is expanded by default when custom slippage is set', () => {
|
||||
store.dispatch(updateUserSlippageTolerance({ userSlippageTolerance: 10 }))
|
||||
renderSlippageSettings()
|
||||
expect(getSlippageInput()).toBeInTheDocument()
|
||||
})
|
||||
it('does not render auto slippage as a value, but a placeholder', () => {
|
||||
renderAndExpandSlippageSettings()
|
||||
switchToCustomSlippage()
|
||||
|
||||
@@ -53,6 +53,9 @@ export default function MaxSlippageSettings({ autoSlippage }: { autoSlippage: Pe
|
||||
const [slippageInput, setSlippageInput] = useState(defaultSlippageInputValue)
|
||||
const [slippageError, setSlippageError] = useState<SlippageError | false>(false)
|
||||
|
||||
// If user has previously entered a custom slippage, we want to show the settings expanded by default.
|
||||
const [isOpen, setIsOpen] = useState(defaultSlippageInputValue.length > 0)
|
||||
|
||||
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)) {
|
||||
@@ -93,6 +96,8 @@ export default function MaxSlippageSettings({ autoSlippage }: { autoSlippage: Pe
|
||||
return (
|
||||
<Expand
|
||||
testId="max-slippage-settings"
|
||||
isOpen={isOpen}
|
||||
onToggle={() => setIsOpen(!isOpen)}
|
||||
header={
|
||||
<Row width="auto">
|
||||
<ThemedText.BodySecondary>
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -5,14 +5,18 @@ import { fireEvent, render, screen } from 'test-utils/render'
|
||||
|
||||
import TransactionDeadlineSettings from '.'
|
||||
|
||||
const renderAndExpandTransactionDeadlineSettings = () => {
|
||||
const renderTransactionDeadlineSettings = () => {
|
||||
render(<TransactionDeadlineSettings />)
|
||||
}
|
||||
|
||||
const renderAndExpandTransactionDeadlineSettings = () => {
|
||||
renderTransactionDeadlineSettings()
|
||||
|
||||
// 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
|
||||
const getDeadlineInput = () => screen.queryByTestId('deadline-input') as HTMLInputElement
|
||||
|
||||
describe('TransactionDeadlineSettings', () => {
|
||||
describe('input', () => {
|
||||
@@ -20,6 +24,15 @@ describe('TransactionDeadlineSettings', () => {
|
||||
beforeEach(() => {
|
||||
store.dispatch(updateUserDeadline({ userDeadline: DEFAULT_DEADLINE_FROM_NOW }))
|
||||
})
|
||||
it('is not expanded by default', () => {
|
||||
renderTransactionDeadlineSettings()
|
||||
expect(getDeadlineInput()).not.toBeInTheDocument()
|
||||
})
|
||||
it('is expanded by default when custom deadline is set', () => {
|
||||
store.dispatch(updateUserDeadline({ userDeadline: DEFAULT_DEADLINE_FROM_NOW * 2 }))
|
||||
renderTransactionDeadlineSettings()
|
||||
expect(getDeadlineInput()).toBeInTheDocument()
|
||||
})
|
||||
it('does not render default deadline as a value, but a placeholder', () => {
|
||||
renderAndExpandTransactionDeadlineSettings()
|
||||
expect(getDeadlineInput().value).toBe('')
|
||||
|
||||
@@ -26,6 +26,9 @@ export default function TransactionDeadlineSettings() {
|
||||
const [deadlineInput, setDeadlineInput] = useState(defaultInputValue)
|
||||
const [deadlineError, setDeadlineError] = useState<DeadlineError | false>(false)
|
||||
|
||||
// If user has previously entered a custom deadline, we want to show the settings expanded by default.
|
||||
const [isOpen, setIsOpen] = useState(defaultInputValue.length > 0)
|
||||
|
||||
function parseCustomDeadline(value: string) {
|
||||
// Do not allow non-numerical characters in the input field
|
||||
if (value.length > 0 && !NUMBERS_ONLY.test(value)) {
|
||||
@@ -56,6 +59,8 @@ export default function TransactionDeadlineSettings() {
|
||||
|
||||
return (
|
||||
<Expand
|
||||
isOpen={isOpen}
|
||||
onToggle={() => setIsOpen(!isOpen)}
|
||||
testId="transaction-deadline-settings"
|
||||
header={
|
||||
<Row width="auto">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { t } from '@lingui/macro'
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import { AutoColumn } from 'components/Column'
|
||||
@@ -7,82 +6,39 @@ import { L2_CHAIN_IDS } from 'constants/chains'
|
||||
import { useOnClickOutside } from 'hooks/useOnClickOutside'
|
||||
import { isSupportedChainId } from 'lib/hooks/routing/clientSideSmartOrderRouter'
|
||||
import { useRef } from 'react'
|
||||
import { Settings } from 'react-feather'
|
||||
import { useModalIsOpen, useToggleSettingsMenu } from 'state/application/hooks'
|
||||
import { ApplicationModal } from 'state/application/reducer'
|
||||
import styled from 'styled-components/macro'
|
||||
import { Divider } from 'theme'
|
||||
|
||||
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 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 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;
|
||||
`
|
||||
|
||||
const Divider = styled.div`
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
border-width: 0;
|
||||
margin: 0;
|
||||
background-color: ${({ theme }) => theme.backgroundOutline};
|
||||
gap: 16px;
|
||||
padding: 1rem;
|
||||
`
|
||||
|
||||
export default function SettingsTab({ autoSlippage }: { autoSlippage: Percent }) {
|
||||
@@ -90,37 +46,29 @@ export default function SettingsTab({ autoSlippage }: { autoSlippage: Percent })
|
||||
const showDeadlineSettings = Boolean(chainId && !L2_CHAIN_IDS.includes(chainId))
|
||||
|
||||
const node = useRef<HTMLDivElement | null>(null)
|
||||
const open = useModalIsOpen(ApplicationModal.SETTINGS)
|
||||
const isOpen = useModalIsOpen(ApplicationModal.SETTINGS)
|
||||
|
||||
const toggle = useToggleSettingsMenu()
|
||||
useOnClickOutside(node, open ? toggle : undefined)
|
||||
const toggleMenu = useToggleSettingsMenu()
|
||||
useOnClickOutside(node, isOpen ? toggleMenu : undefined)
|
||||
|
||||
const isSupportedChain = isSupportedChainId(chainId)
|
||||
|
||||
return (
|
||||
<StyledMenu ref={node}>
|
||||
<StyledMenuButton
|
||||
disabled={!isSupportedChainId(chainId)}
|
||||
onClick={toggle}
|
||||
id="open-settings-dialog-button"
|
||||
data-testid="open-settings-dialog-button"
|
||||
aria-label={t`Transaction Settings`}
|
||||
>
|
||||
<StyledMenuIcon data-testid="swap-settings-button" />
|
||||
</StyledMenuButton>
|
||||
{open && (
|
||||
<Menu ref={node}>
|
||||
<MenuButton disabled={!isSupportedChain} isActive={isOpen} onClick={toggleMenu} />
|
||||
{isOpen && (
|
||||
<MenuFlyout>
|
||||
<AutoColumn gap="16px" style={{ padding: '1rem' }}>
|
||||
{isSupportedChainId(chainId) && <RouterPreferenceSettings />}
|
||||
<Divider />
|
||||
<MaxSlippageSettings autoSlippage={autoSlippage} />
|
||||
{showDeadlineSettings && (
|
||||
<>
|
||||
<Divider />
|
||||
<TransactionDeadlineSettings />
|
||||
</>
|
||||
)}
|
||||
</AutoColumn>
|
||||
<RouterPreferenceSettings />
|
||||
<Divider />
|
||||
<MaxSlippageSettings autoSlippage={autoSlippage} />
|
||||
{showDeadlineSettings && (
|
||||
<>
|
||||
<Divider />
|
||||
<TransactionDeadlineSettings />
|
||||
</>
|
||||
)}
|
||||
</MenuFlyout>
|
||||
)}
|
||||
</StyledMenu>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 } from '@uniswap/widgets'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import CurrencyLogo from 'components/Logo/CurrencyLogo'
|
||||
import { AboutSection } from 'components/Tokens/TokenDetails/About'
|
||||
@@ -22,19 +21,14 @@ 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 { useWidgetRemovalEnabled } from 'featureFlags/flags/removeWidgetTdp'
|
||||
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'
|
||||
@@ -130,12 +124,10 @@ export default function TokenDetails({
|
||||
)
|
||||
|
||||
const { token: detailedToken, didFetchFromChain } = useRelevantToken(address, pageChainId, tokenQueryData)
|
||||
const { token: widgetInputToken } = useRelevantToken(inputTokenAddress, pageChainId, undefined)
|
||||
|
||||
const tokenWarning = address ? checkWarning(address) : null
|
||||
const isBlockedToken = tokenWarning?.canProceed === false
|
||||
const navigate = useNavigate()
|
||||
const widgetRemovalEnabled = useWidgetRemovalEnabled()
|
||||
|
||||
// Wrapping navigate in a transition prevents Suspense from unnecessarily showing fallbacks again.
|
||||
const [isPending, startTokenTransition] = useTransition()
|
||||
@@ -152,22 +144,6 @@ export default function TokenDetails({
|
||||
[address, crossChainMap, didFetchFromChain, navigate, detailedToken?.isNative]
|
||||
)
|
||||
useOnGlobalChainSwitch(navigateToTokenForChain)
|
||||
const navigateToWidgetSelectedToken = useCallback(
|
||||
(tokens: SwapTokens) => {
|
||||
const newDefaultToken = tokens[Field.OUTPUT] ?? tokens.default
|
||||
const address = newDefaultToken?.isNative ? NATIVE_CHAIN_ID : newDefaultToken?.address
|
||||
startTokenTransition(() =>
|
||||
navigate(
|
||||
getTokenDetailsURL({
|
||||
address,
|
||||
chain,
|
||||
inputAddress: tokens[Field.INPUT] ? getTokenAddress(tokens[Field.INPUT] as Currency) : null,
|
||||
})
|
||||
)
|
||||
)
|
||||
},
|
||||
[chain, navigate]
|
||||
)
|
||||
|
||||
const handleCurrencyChange = useCallback(
|
||||
(tokens: Pick<SwapState, Field.INPUT | Field.OUTPUT>) => {
|
||||
@@ -202,12 +178,6 @@ export default function TokenDetails({
|
||||
|
||||
const [openTokenSafetyModal, setOpenTokenSafetyModal] = useState(false)
|
||||
|
||||
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)
|
||||
@@ -268,26 +238,15 @@ export default function TokenDetails({
|
||||
|
||||
<RightPanel onClick={() => isBlockedToken && setOpenTokenSafetyModal(true)}>
|
||||
<div style={{ pointerEvents: isBlockedToken ? 'none' : 'auto' }}>
|
||||
{widgetRemovalEnabled ? (
|
||||
<Swap
|
||||
chainId={pageChainId}
|
||||
prefilledState={{
|
||||
[Field.INPUT]: { currencyId: inputTokenAddress },
|
||||
[Field.OUTPUT]: { currencyId: address === NATIVE_CHAIN_ID ? 'ETH' : address },
|
||||
}}
|
||||
onCurrencyChange={handleCurrencyChange}
|
||||
disableTokenInputs={pageChainId !== connectedChainId}
|
||||
/>
|
||||
) : (
|
||||
<Widget
|
||||
defaultTokens={{
|
||||
[Field.INPUT]: widgetInputToken ?? undefined,
|
||||
default: detailedToken ?? undefined,
|
||||
}}
|
||||
onDefaultTokenChange={navigateToWidgetSelectedToken}
|
||||
onReviewSwapClick={onReviewSwapClick}
|
||||
/>
|
||||
)}
|
||||
<Swap
|
||||
chainId={pageChainId}
|
||||
prefilledState={{
|
||||
[Field.INPUT]: { currencyId: inputTokenAddress },
|
||||
[Field.OUTPUT]: { currencyId: address === NATIVE_CHAIN_ID ? 'ETH' : address },
|
||||
}}
|
||||
onCurrencyChange={handleCurrencyChange}
|
||||
disableTokenInputs={pageChainId !== connectedChainId}
|
||||
/>
|
||||
</div>
|
||||
{tokenWarning && <TokenSafetyMessage tokenAddress={address} warning={tokenWarning} />}
|
||||
{detailedToken && <BalanceSummary token={detailedToken} />}
|
||||
|
||||
@@ -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,204 +0,0 @@
|
||||
import { sendAnalyticsEvent, useTrace } from '@uniswap/analytics'
|
||||
import {
|
||||
InterfaceEventName,
|
||||
InterfaceSectionName,
|
||||
SwapEventName,
|
||||
SwapPriceUpdateUserResponse,
|
||||
} from '@uniswap/analytics-events'
|
||||
import { Trade } from '@uniswap/router-sdk'
|
||||
import { Currency, TradeType } from '@uniswap/sdk-core'
|
||||
import {
|
||||
AddEthereumChainParameter,
|
||||
DialogAnimationType,
|
||||
EMPTY_TOKEN_LIST,
|
||||
OnReviewSwapClick,
|
||||
SwapWidget,
|
||||
SwapWidgetSkeleton,
|
||||
} from '@uniswap/widgets'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import { useToggleAccountDrawer } from 'components/AccountDrawer'
|
||||
import { useActiveLocale } from 'hooks/useActiveLocale'
|
||||
import {
|
||||
formatPercentInBasisPointsNumber,
|
||||
formatSwapQuoteReceivedEventProperties,
|
||||
formatToDecimal,
|
||||
getDurationFromDateMilliseconds,
|
||||
getPriceUpdateBasisPoints,
|
||||
getTokenAddress,
|
||||
} from 'lib/utils/analytics'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useIsDarkMode } from 'theme/components/ThemeToggle'
|
||||
import { computeRealizedPriceImpact } from 'utils/prices'
|
||||
import { switchChain } from 'utils/switchChain'
|
||||
|
||||
import { DefaultTokens, SwapTokens, useSyncWidgetInputs } from './inputs'
|
||||
import { useSyncWidgetSettings } from './settings'
|
||||
import { DARK_THEME, LIGHT_THEME } from './theme'
|
||||
import { useSyncWidgetTransactions } from './transactions'
|
||||
|
||||
export const DEFAULT_WIDGET_WIDTH = 360
|
||||
|
||||
const WIDGET_ROUTER_URL = 'https://api.uniswap.org/v1/'
|
||||
|
||||
function useWidgetTheme() {
|
||||
return useIsDarkMode() ? DARK_THEME : LIGHT_THEME
|
||||
}
|
||||
|
||||
interface WidgetProps {
|
||||
defaultTokens: DefaultTokens
|
||||
width?: number | string
|
||||
onDefaultTokenChange?: (tokens: SwapTokens) => void
|
||||
onReviewSwapClick?: OnReviewSwapClick
|
||||
}
|
||||
|
||||
// TODO: Remove this component once the TDP is fully migrated to the swap component.
|
||||
// eslint-disable-next-line import/no-unused-modules
|
||||
export default function Widget({
|
||||
defaultTokens,
|
||||
width = DEFAULT_WIDGET_WIDTH,
|
||||
onDefaultTokenChange,
|
||||
onReviewSwapClick,
|
||||
}: WidgetProps) {
|
||||
const { connector, provider, chainId } = useWeb3React()
|
||||
const locale = useActiveLocale()
|
||||
const theme = useWidgetTheme()
|
||||
const { inputs, tokenSelector } = useSyncWidgetInputs({
|
||||
defaultTokens,
|
||||
onDefaultTokenChange,
|
||||
})
|
||||
const { settings } = useSyncWidgetSettings()
|
||||
const { transactions } = useSyncWidgetTransactions()
|
||||
|
||||
const toggleWalletDrawer = useToggleAccountDrawer()
|
||||
const onConnectWalletClick = useCallback(() => {
|
||||
toggleWalletDrawer()
|
||||
return false // prevents the in-widget wallet modal from opening
|
||||
}, [toggleWalletDrawer])
|
||||
|
||||
const onSwitchChain = useCallback(
|
||||
// TODO(WEB-1757): Widget should not break if this rejects - upstream the catch to ignore it.
|
||||
({ chainId }: AddEthereumChainParameter) => switchChain(connector, Number(chainId)).catch(() => undefined),
|
||||
[connector]
|
||||
)
|
||||
|
||||
const trace = useTrace({ section: InterfaceSectionName.WIDGET })
|
||||
const [initialQuoteDate, setInitialQuoteDate] = useState<Date>()
|
||||
const onInitialSwapQuote = useCallback(
|
||||
(trade: Trade<Currency, Currency, TradeType>) => {
|
||||
setInitialQuoteDate(new Date())
|
||||
const eventProperties = {
|
||||
// TODO(1416): Include undefined values.
|
||||
...formatSwapQuoteReceivedEventProperties(
|
||||
trade,
|
||||
/* gasUseEstimateUSD= */ undefined,
|
||||
/* fetchingSwapQuoteStartTime= */ undefined
|
||||
),
|
||||
...trace,
|
||||
}
|
||||
sendAnalyticsEvent(SwapEventName.SWAP_QUOTE_RECEIVED, eventProperties)
|
||||
},
|
||||
[trace]
|
||||
)
|
||||
const onApproveToken = useCallback(() => {
|
||||
const input = inputs.value.INPUT
|
||||
if (!input) return
|
||||
const eventProperties = {
|
||||
chain_id: input.chainId,
|
||||
token_symbol: input.symbol,
|
||||
token_address: getTokenAddress(input),
|
||||
...trace,
|
||||
}
|
||||
sendAnalyticsEvent(InterfaceEventName.APPROVE_TOKEN_TXN_SUBMITTED, eventProperties)
|
||||
}, [inputs.value.INPUT, trace])
|
||||
const onExpandSwapDetails = useCallback(() => {
|
||||
sendAnalyticsEvent(SwapEventName.SWAP_DETAILS_EXPANDED, { ...trace })
|
||||
}, [trace])
|
||||
const onSwapPriceUpdateAck = useCallback(
|
||||
(stale: Trade<Currency, Currency, TradeType>, update: Trade<Currency, Currency, TradeType>) => {
|
||||
const eventProperties = {
|
||||
chain_id: update.inputAmount.currency.chainId,
|
||||
response: SwapPriceUpdateUserResponse.ACCEPTED,
|
||||
token_in_symbol: update.inputAmount.currency.symbol,
|
||||
token_out_symbol: update.outputAmount.currency.symbol,
|
||||
price_update_basis_points: getPriceUpdateBasisPoints(stale.executionPrice, update.executionPrice),
|
||||
...trace,
|
||||
}
|
||||
sendAnalyticsEvent(SwapEventName.SWAP_PRICE_UPDATE_ACKNOWLEDGED, eventProperties)
|
||||
},
|
||||
[trace]
|
||||
)
|
||||
const onSubmitSwapClick = useCallback(
|
||||
(trade: Trade<Currency, Currency, TradeType>) => {
|
||||
const eventProperties = {
|
||||
// TODO(1416): Include undefined values.
|
||||
estimated_network_fee_usd: undefined,
|
||||
transaction_deadline_seconds: undefined,
|
||||
token_in_address: getTokenAddress(trade.inputAmount.currency),
|
||||
token_out_address: getTokenAddress(trade.outputAmount.currency),
|
||||
token_in_symbol: trade.inputAmount.currency.symbol,
|
||||
token_out_symbol: trade.outputAmount.currency.symbol,
|
||||
token_in_amount: formatToDecimal(trade.inputAmount, trade.inputAmount.currency.decimals),
|
||||
token_out_amount: formatToDecimal(trade.outputAmount, trade.outputAmount.currency.decimals),
|
||||
token_in_amount_usd: undefined,
|
||||
token_out_amount_usd: undefined,
|
||||
price_impact_basis_points: formatPercentInBasisPointsNumber(computeRealizedPriceImpact(trade)),
|
||||
allowed_slippage_basis_points: undefined,
|
||||
is_auto_router_api: undefined,
|
||||
is_auto_slippage: undefined,
|
||||
chain_id: trade.inputAmount.currency.chainId,
|
||||
duration_from_first_quote_to_swap_submission_milliseconds: getDurationFromDateMilliseconds(initialQuoteDate),
|
||||
swap_quote_block_number: undefined,
|
||||
...trace,
|
||||
}
|
||||
sendAnalyticsEvent(SwapEventName.SWAP_SUBMITTED_BUTTON_CLICKED, eventProperties)
|
||||
},
|
||||
[initialQuoteDate, trace]
|
||||
)
|
||||
|
||||
if (!(inputs.value.INPUT || inputs.value.OUTPUT)) {
|
||||
return <WidgetSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ zIndex: 1, position: 'relative' }}>
|
||||
<SwapWidget
|
||||
hideConnectionUI
|
||||
brandedFooter={false}
|
||||
permit2
|
||||
routerUrl={WIDGET_ROUTER_URL}
|
||||
locale={locale}
|
||||
theme={theme}
|
||||
width={width}
|
||||
defaultChainId={chainId}
|
||||
onConnectWalletClick={onConnectWalletClick}
|
||||
provider={provider}
|
||||
onSwitchChain={onSwitchChain}
|
||||
tokenList={EMPTY_TOKEN_LIST} // prevents loading the default token list, as we use our own token selector UI
|
||||
{...inputs}
|
||||
{...settings}
|
||||
{...transactions}
|
||||
onExpandSwapDetails={onExpandSwapDetails}
|
||||
onReviewSwapClick={onReviewSwapClick}
|
||||
onSubmitSwapClick={onSubmitSwapClick}
|
||||
onSwapApprove={onApproveToken}
|
||||
onInitialSwapQuote={onInitialSwapQuote}
|
||||
onSwapPriceUpdateAck={onSwapPriceUpdateAck}
|
||||
dialogOptions={{
|
||||
pageCentered: true,
|
||||
animationType: DialogAnimationType.FADE,
|
||||
}}
|
||||
onError={(error, errorInfo) => {
|
||||
sendAnalyticsEvent(SwapEventName.SWAP_ERROR, { error, errorInfo, ...trace })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{tokenSelector}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function WidgetSkeleton({ width = DEFAULT_WIDGET_WIDTH }: { width?: number | string }) {
|
||||
const theme = useWidgetTheme()
|
||||
return <SwapWidgetSkeleton theme={theme} width={width} />
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { sendAnalyticsEvent, useTrace } from '@uniswap/analytics'
|
||||
import { InterfaceSectionName, SwapEventName } from '@uniswap/analytics-events'
|
||||
import { Currency, Field, SwapController, SwapEventHandlers, TradeType } from '@uniswap/widgets'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import CurrencySearchModal from 'components/SearchModal/CurrencySearchModal'
|
||||
import { isSupportedChain } from 'constants/chains'
|
||||
import usePrevious from 'hooks/usePrevious'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
const EMPTY_AMOUNT = ''
|
||||
|
||||
type SwapValue = Required<SwapController>['value']
|
||||
export type SwapTokens = Pick<SwapValue, Field.INPUT | Field.OUTPUT> & { default?: Currency }
|
||||
export type DefaultTokens = Partial<SwapTokens>
|
||||
|
||||
function missingDefaultToken(tokens: SwapTokens) {
|
||||
if (!tokens.default) return false
|
||||
return !tokens[Field.INPUT]?.equals(tokens.default) && !tokens[Field.OUTPUT]?.equals(tokens.default)
|
||||
}
|
||||
|
||||
function currenciesEqual(a: Currency | undefined, b: Currency | undefined) {
|
||||
if (a && b) {
|
||||
return a.equals(b)
|
||||
} else {
|
||||
return !a && !b
|
||||
}
|
||||
}
|
||||
|
||||
function tokensEqual(a: SwapTokens | undefined, b: SwapTokens | undefined) {
|
||||
if (!a || !b) {
|
||||
return !a && !b
|
||||
}
|
||||
return (
|
||||
currenciesEqual(a[Field.INPUT], b[Field.INPUT]) &&
|
||||
currenciesEqual(a[Field.OUTPUT], b[Field.OUTPUT]) &&
|
||||
currenciesEqual(a.default, b.default)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrates the Widget's inputs.
|
||||
* Treats the Widget as a controlled component, using the app's own token selector for selection.
|
||||
* Enforces that token is a part of the returned value.
|
||||
*/
|
||||
export function useSyncWidgetInputs({
|
||||
defaultTokens,
|
||||
onDefaultTokenChange,
|
||||
}: {
|
||||
defaultTokens: DefaultTokens
|
||||
onDefaultTokenChange?: (tokens: SwapTokens) => void
|
||||
}) {
|
||||
const trace = useTrace({ section: InterfaceSectionName.WIDGET })
|
||||
|
||||
const { chainId } = useWeb3React()
|
||||
const previousChainId = usePrevious(chainId)
|
||||
|
||||
const [type, setType] = useState<SwapValue['type']>(TradeType.EXACT_INPUT)
|
||||
const [amount, setAmount] = useState<SwapValue['amount']>(EMPTY_AMOUNT)
|
||||
const [tokens, setTokens] = useState<SwapTokens>({
|
||||
...defaultTokens,
|
||||
[Field.OUTPUT]: defaultTokens[Field.OUTPUT] ?? defaultTokens.default,
|
||||
})
|
||||
|
||||
// The most recent set of defaults, which can be used to check when the defaults are actually changing.
|
||||
const baseTokens = usePrevious(defaultTokens)
|
||||
useEffect(() => {
|
||||
if (!tokensEqual(baseTokens, defaultTokens)) {
|
||||
const input = defaultTokens[Field.INPUT]
|
||||
const output = defaultTokens[Field.OUTPUT] ?? defaultTokens.default
|
||||
setTokens({
|
||||
...defaultTokens,
|
||||
[Field.OUTPUT]: currenciesEqual(output, input) ? undefined : output,
|
||||
})
|
||||
}
|
||||
}, [baseTokens, defaultTokens])
|
||||
|
||||
/**
|
||||
* Clear the tokens if the chain changes.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (chainId !== previousChainId && !!previousChainId && isSupportedChain(chainId)) {
|
||||
setTokens({
|
||||
...defaultTokens,
|
||||
[Field.OUTPUT]: defaultTokens[Field.OUTPUT] ?? defaultTokens.default,
|
||||
})
|
||||
setAmount(EMPTY_AMOUNT)
|
||||
}
|
||||
}, [chainId, defaultTokens, previousChainId, tokens])
|
||||
|
||||
const onAmountChange = useCallback(
|
||||
(field: Field, amount: string, origin?: 'max') => {
|
||||
if (origin === 'max') {
|
||||
sendAnalyticsEvent(SwapEventName.SWAP_MAX_TOKEN_AMOUNT_SELECTED, { ...trace })
|
||||
}
|
||||
setType(field === Field.INPUT ? TradeType.EXACT_INPUT : TradeType.EXACT_OUTPUT)
|
||||
setAmount(amount)
|
||||
},
|
||||
[trace]
|
||||
)
|
||||
|
||||
const onSwitchTokens = useCallback(() => {
|
||||
sendAnalyticsEvent(SwapEventName.SWAP_TOKENS_REVERSED, { ...trace })
|
||||
setType((type) => invertTradeType(type))
|
||||
setTokens((tokens) => ({
|
||||
[Field.INPUT]: tokens[Field.OUTPUT],
|
||||
[Field.OUTPUT]: tokens[Field.INPUT],
|
||||
default: tokens.default,
|
||||
}))
|
||||
}, [trace])
|
||||
|
||||
const [selectingField, setSelectingField] = useState<Field>()
|
||||
const onTokenSelectorClick = useCallback((field: Field) => {
|
||||
setSelectingField(field)
|
||||
return false
|
||||
}, [])
|
||||
|
||||
const onTokenSelect = useCallback(
|
||||
(selectingToken: Currency) => {
|
||||
if (selectingField === undefined) return
|
||||
|
||||
const otherField = invertField(selectingField)
|
||||
const isFlip = tokens[otherField]?.equals(selectingToken)
|
||||
const update: SwapTokens = {
|
||||
[selectingField]: selectingToken,
|
||||
[otherField]: isFlip ? tokens[selectingField] : tokens[otherField],
|
||||
default: tokens.default,
|
||||
}
|
||||
|
||||
setType((type) => {
|
||||
// If flipping the tokens, also flip the type/amount.
|
||||
if (isFlip) {
|
||||
return invertTradeType(type)
|
||||
}
|
||||
|
||||
// Setting a new token should clear its amount, if it is set.
|
||||
const activeField = type === TradeType.EXACT_INPUT ? Field.INPUT : Field.OUTPUT
|
||||
if (selectingField === activeField) {
|
||||
setAmount(() => EMPTY_AMOUNT)
|
||||
}
|
||||
|
||||
return type
|
||||
})
|
||||
|
||||
if (missingDefaultToken(update)) {
|
||||
onDefaultTokenChange?.({
|
||||
...update,
|
||||
default: update[Field.OUTPUT] ?? selectingToken,
|
||||
})
|
||||
return
|
||||
}
|
||||
setTokens(update)
|
||||
},
|
||||
[onDefaultTokenChange, selectingField, tokens]
|
||||
)
|
||||
|
||||
const tokenSelector = (
|
||||
<CurrencySearchModal
|
||||
isOpen={selectingField !== undefined}
|
||||
onDismiss={() => setSelectingField(undefined)}
|
||||
selectedCurrency={selectingField && tokens[selectingField]}
|
||||
otherSelectedCurrency={selectingField && tokens[invertField(selectingField)]}
|
||||
onCurrencySelect={onTokenSelect}
|
||||
showCommonBases
|
||||
/>
|
||||
)
|
||||
|
||||
const value: SwapValue = useMemo(
|
||||
() => ({
|
||||
type,
|
||||
amount,
|
||||
// If the initial state has not yet been set, preemptively disable the widget by passing no tokens. Effectively,
|
||||
// this resets the widget - avoiding rendering stale state - because with no tokens the skeleton will be rendered.
|
||||
...(tokens[Field.INPUT] || tokens[Field.OUTPUT] ? tokens : undefined),
|
||||
}),
|
||||
[amount, tokens, type]
|
||||
)
|
||||
const valueHandlers: SwapEventHandlers = useMemo(
|
||||
() => ({ onAmountChange, onSwitchTokens, onTokenSelectorClick }),
|
||||
[onAmountChange, onSwitchTokens, onTokenSelectorClick]
|
||||
)
|
||||
return { inputs: { value, ...valueHandlers }, tokenSelector }
|
||||
}
|
||||
|
||||
// TODO(zzmp): Move to @uniswap/widgets.
|
||||
function invertField(field: Field) {
|
||||
switch (field) {
|
||||
case Field.INPUT:
|
||||
return Field.OUTPUT
|
||||
case Field.OUTPUT:
|
||||
return Field.INPUT
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(zzmp): Include in @uniswap/sdk-core (on TradeType, if possible).
|
||||
function invertTradeType(tradeType: TradeType) {
|
||||
switch (tradeType) {
|
||||
case TradeType.EXACT_INPUT:
|
||||
return TradeType.EXACT_OUTPUT
|
||||
case TradeType.EXACT_OUTPUT:
|
||||
return TradeType.EXACT_INPUT
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
import { RouterPreference, Slippage, SwapController, SwapEventHandlers } from '@uniswap/widgets'
|
||||
import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useUserSlippageTolerance, useUserTransactionTTL } from 'state/user/hooks'
|
||||
import { SlippageTolerance } from 'state/user/types'
|
||||
|
||||
/**
|
||||
* Integrates the Widget's settings, keeping the widget and app settings in sync.
|
||||
* NB: This acts as an integration layer, so certain values are duplicated in order to translate
|
||||
* between app and widget representations.
|
||||
*/
|
||||
export function useSyncWidgetSettings() {
|
||||
const [appTtl, setAppTtl] = useUserTransactionTTL()
|
||||
const [widgetTtl, setWidgetTtl] = useState<number | undefined>(appTtl / 60)
|
||||
const onTransactionDeadlineChange = useCallback(
|
||||
(widgetTtl: number | undefined) => {
|
||||
setWidgetTtl(widgetTtl)
|
||||
const appTtl = widgetTtl === undefined ? widgetTtl : widgetTtl * 60
|
||||
setAppTtl(appTtl ?? DEFAULT_DEADLINE_FROM_NOW)
|
||||
},
|
||||
[setAppTtl]
|
||||
)
|
||||
|
||||
const [appSlippage, setAppSlippage] = useUserSlippageTolerance()
|
||||
const [widgetSlippage, setWidgetSlippage] = useState<string | undefined>(
|
||||
appSlippage === SlippageTolerance.Auto ? undefined : appSlippage.toFixed(2)
|
||||
)
|
||||
const onSlippageChange = useCallback(
|
||||
(widgetSlippage: Slippage) => {
|
||||
setWidgetSlippage(widgetSlippage.max)
|
||||
if (widgetSlippage.auto || !widgetSlippage.max) {
|
||||
setAppSlippage(SlippageTolerance.Auto)
|
||||
} else {
|
||||
setAppSlippage(new Percent(Math.floor(Number(widgetSlippage.max) * 100), 10_000))
|
||||
}
|
||||
},
|
||||
[setAppSlippage]
|
||||
)
|
||||
|
||||
const [routerPreference, onRouterPreferenceChange] = useState(RouterPreference.API)
|
||||
|
||||
const onSettingsReset = useCallback(() => {
|
||||
setWidgetTtl(undefined)
|
||||
setAppTtl(DEFAULT_DEADLINE_FROM_NOW)
|
||||
setWidgetSlippage(undefined)
|
||||
setAppSlippage(SlippageTolerance.Auto)
|
||||
}, [setAppSlippage, setAppTtl])
|
||||
|
||||
const settings: SwapController['settings'] = useMemo(() => {
|
||||
const auto = appSlippage === SlippageTolerance.Auto
|
||||
return {
|
||||
slippage: { auto, max: widgetSlippage },
|
||||
transactionTtl: widgetTtl,
|
||||
routerPreference,
|
||||
}
|
||||
}, [appSlippage, widgetSlippage, widgetTtl, routerPreference])
|
||||
const settingsHandlers: SwapEventHandlers = useMemo(
|
||||
() => ({ onSettingsReset, onSlippageChange, onTransactionDeadlineChange, onRouterPreferenceChange }),
|
||||
[onSettingsReset, onSlippageChange, onTransactionDeadlineChange, onRouterPreferenceChange]
|
||||
)
|
||||
|
||||
return { settings: { settings, ...settingsHandlers } }
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Theme } from '@uniswap/widgets'
|
||||
import { darkTheme, lightTheme } from 'theme/colors'
|
||||
import { Z_INDEX } from 'theme/zIndex'
|
||||
|
||||
const zIndex = {
|
||||
modal: Z_INDEX.modal,
|
||||
}
|
||||
|
||||
const fonts = {
|
||||
fontFamily: 'Inter custom',
|
||||
}
|
||||
|
||||
export const LIGHT_THEME: Theme = {
|
||||
// surface
|
||||
accent: lightTheme.accentAction,
|
||||
accentSoft: lightTheme.accentActionSoft,
|
||||
container: lightTheme.backgroundSurface,
|
||||
module: lightTheme.backgroundModule,
|
||||
interactive: lightTheme.backgroundInteractive,
|
||||
outline: lightTheme.backgroundOutline,
|
||||
dialog: lightTheme.backgroundBackdrop,
|
||||
scrim: lightTheme.backgroundScrim,
|
||||
// text
|
||||
onAccent: lightTheme.white,
|
||||
primary: lightTheme.textPrimary,
|
||||
secondary: lightTheme.textSecondary,
|
||||
hint: lightTheme.textTertiary,
|
||||
onInteractive: lightTheme.accentTextDarkPrimary,
|
||||
// shadow
|
||||
deepShadow: lightTheme.deepShadow,
|
||||
networkDefaultShadow: lightTheme.networkDefaultShadow,
|
||||
|
||||
// state
|
||||
success: lightTheme.accentSuccess,
|
||||
warning: lightTheme.accentWarning,
|
||||
error: lightTheme.accentCritical,
|
||||
|
||||
...fonts,
|
||||
zIndex,
|
||||
}
|
||||
|
||||
export const DARK_THEME: Theme = {
|
||||
// surface
|
||||
accent: darkTheme.accentAction,
|
||||
accentSoft: darkTheme.accentActionSoft,
|
||||
container: darkTheme.backgroundSurface,
|
||||
module: darkTheme.backgroundModule,
|
||||
interactive: darkTheme.backgroundInteractive,
|
||||
outline: darkTheme.backgroundOutline,
|
||||
dialog: darkTheme.backgroundBackdrop,
|
||||
scrim: darkTheme.backgroundScrim,
|
||||
// text
|
||||
onAccent: darkTheme.white,
|
||||
primary: darkTheme.textPrimary,
|
||||
secondary: darkTheme.textSecondary,
|
||||
hint: darkTheme.textTertiary,
|
||||
onInteractive: darkTheme.accentTextLightPrimary,
|
||||
// shadow
|
||||
deepShadow: darkTheme.deepShadow,
|
||||
networkDefaultShadow: darkTheme.networkDefaultShadow,
|
||||
// state
|
||||
success: darkTheme.accentSuccess,
|
||||
warning: darkTheme.accentWarning,
|
||||
error: darkTheme.accentCritical,
|
||||
|
||||
...fonts,
|
||||
zIndex,
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { sendAnalyticsEvent, useTrace } from '@uniswap/analytics'
|
||||
import { InterfaceEventName, InterfaceSectionName, SwapEventName } from '@uniswap/analytics-events'
|
||||
import { Trade } from '@uniswap/router-sdk'
|
||||
import { Currency, Percent } from '@uniswap/sdk-core'
|
||||
import {
|
||||
OnTxSuccess,
|
||||
TradeType,
|
||||
Transaction,
|
||||
TransactionEventHandlers,
|
||||
TransactionInfo,
|
||||
TransactionType,
|
||||
TransactionType as WidgetTransactionType,
|
||||
} from '@uniswap/widgets'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import {
|
||||
formatPercentInBasisPointsNumber,
|
||||
formatSwapSignedAnalyticsEventProperties,
|
||||
formatToDecimal,
|
||||
getTokenAddress,
|
||||
} from 'lib/utils/analytics'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTransactionAdder } from 'state/transactions/hooks'
|
||||
import {
|
||||
ExactInputSwapTransactionInfo,
|
||||
ExactOutputSwapTransactionInfo,
|
||||
TransactionType as AppTransactionType,
|
||||
WrapTransactionInfo,
|
||||
} from 'state/transactions/types'
|
||||
import { currencyId } from 'utils/currencyId'
|
||||
import { computeRealizedPriceImpact } from 'utils/prices'
|
||||
|
||||
interface AnalyticsEventProps {
|
||||
trade: Trade<Currency, Currency, TradeType>
|
||||
gasUsed: string | undefined
|
||||
blockNumber: number | undefined
|
||||
hash: string | undefined
|
||||
allowedSlippage: Percent
|
||||
succeeded: boolean
|
||||
}
|
||||
|
||||
const formatAnalyticsEventProperties = ({
|
||||
trade,
|
||||
hash,
|
||||
allowedSlippage,
|
||||
succeeded,
|
||||
gasUsed,
|
||||
blockNumber,
|
||||
}: AnalyticsEventProps) => ({
|
||||
estimated_network_fee_usd: gasUsed,
|
||||
transaction_hash: hash,
|
||||
token_in_address: getTokenAddress(trade.inputAmount.currency),
|
||||
token_out_address: getTokenAddress(trade.outputAmount.currency),
|
||||
token_in_symbol: trade.inputAmount.currency.symbol,
|
||||
token_out_symbol: trade.outputAmount.currency.symbol,
|
||||
token_in_amount: formatToDecimal(trade.inputAmount, trade.inputAmount.currency.decimals),
|
||||
token_out_amount: formatToDecimal(trade.outputAmount, trade.outputAmount.currency.decimals),
|
||||
price_impact_basis_points: formatPercentInBasisPointsNumber(computeRealizedPriceImpact(trade)),
|
||||
allowed_slippage_basis_points: formatPercentInBasisPointsNumber(allowedSlippage),
|
||||
chain_id:
|
||||
trade.inputAmount.currency.chainId === trade.outputAmount.currency.chainId
|
||||
? trade.inputAmount.currency.chainId
|
||||
: undefined,
|
||||
swap_quote_block_number: blockNumber,
|
||||
succeeded,
|
||||
})
|
||||
|
||||
/** Integrates the Widget's transactions, showing the widget's transactions in the app. */
|
||||
export function useSyncWidgetTransactions() {
|
||||
const trace = useTrace({ section: InterfaceSectionName.WIDGET })
|
||||
|
||||
const { chainId } = useWeb3React()
|
||||
const addTransaction = useTransactionAdder()
|
||||
|
||||
const onTxSubmit = useCallback(
|
||||
(_hash: string, transaction: Transaction<TransactionInfo>) => {
|
||||
const { type, response } = transaction.info
|
||||
|
||||
if (!type || !response) {
|
||||
return
|
||||
} else if (type === WidgetTransactionType.WRAP || type === WidgetTransactionType.UNWRAP) {
|
||||
const { type, amount: transactionAmount } = transaction.info
|
||||
|
||||
const eventProperties = {
|
||||
// get this info from widget handlers
|
||||
token_in_address: getTokenAddress(transactionAmount.currency),
|
||||
token_out_address: getTokenAddress(transactionAmount.currency.wrapped),
|
||||
token_in_symbol: transactionAmount.currency.symbol,
|
||||
token_out_symbol: transactionAmount.currency.wrapped.symbol,
|
||||
chain_id: transactionAmount.currency.chainId,
|
||||
amount: transactionAmount
|
||||
? formatToDecimal(transactionAmount, transactionAmount?.currency.decimals)
|
||||
: undefined,
|
||||
type: type === WidgetTransactionType.WRAP ? TransactionType.WRAP : TransactionType.UNWRAP,
|
||||
...trace,
|
||||
}
|
||||
sendAnalyticsEvent(InterfaceEventName.WRAP_TOKEN_TXN_SUBMITTED, eventProperties)
|
||||
const { amount } = transaction.info
|
||||
addTransaction(response, {
|
||||
type: AppTransactionType.WRAP,
|
||||
unwrapped: type === WidgetTransactionType.UNWRAP,
|
||||
currencyAmountRaw: amount.quotient.toString(),
|
||||
chainId,
|
||||
} as WrapTransactionInfo)
|
||||
} else if (type === WidgetTransactionType.SWAP) {
|
||||
const { slippageTolerance, trade, tradeType } = transaction.info
|
||||
|
||||
const eventProperties = {
|
||||
...formatSwapSignedAnalyticsEventProperties({
|
||||
trade,
|
||||
// TODO: add once Widgets adds fiat values to callback
|
||||
fiatValues: { amountIn: undefined, amountOut: undefined },
|
||||
txHash: transaction.receipt?.transactionHash ?? '',
|
||||
}),
|
||||
...trace,
|
||||
}
|
||||
sendAnalyticsEvent(SwapEventName.SWAP_SIGNED, eventProperties)
|
||||
const baseTxInfo = {
|
||||
type: AppTransactionType.SWAP,
|
||||
tradeType,
|
||||
inputCurrencyId: currencyId(trade.inputAmount.currency),
|
||||
outputCurrencyId: currencyId(trade.outputAmount.currency),
|
||||
}
|
||||
if (tradeType === TradeType.EXACT_OUTPUT) {
|
||||
addTransaction(response, {
|
||||
...baseTxInfo,
|
||||
maximumInputCurrencyAmountRaw: trade.maximumAmountIn(slippageTolerance).quotient.toString(),
|
||||
outputCurrencyAmountRaw: trade.outputAmount.quotient.toString(),
|
||||
expectedInputCurrencyAmountRaw: trade.inputAmount.quotient.toString(),
|
||||
} as ExactOutputSwapTransactionInfo)
|
||||
} else {
|
||||
addTransaction(response, {
|
||||
...baseTxInfo,
|
||||
inputCurrencyAmountRaw: trade.inputAmount.quotient.toString(),
|
||||
expectedOutputCurrencyAmountRaw: trade.outputAmount.quotient.toString(),
|
||||
minimumOutputCurrencyAmountRaw: trade.minimumAmountOut(slippageTolerance).quotient.toString(),
|
||||
} as ExactInputSwapTransactionInfo)
|
||||
}
|
||||
}
|
||||
},
|
||||
[addTransaction, chainId, trace]
|
||||
)
|
||||
|
||||
const onTxSuccess: OnTxSuccess = useCallback((hash: string, tx) => {
|
||||
if (tx.info.type === TransactionType.SWAP) {
|
||||
const { trade, slippageTolerance } = tx.info
|
||||
sendAnalyticsEvent(
|
||||
SwapEventName.SWAP_TRANSACTION_COMPLETED,
|
||||
formatAnalyticsEventProperties({
|
||||
trade,
|
||||
hash,
|
||||
gasUsed: tx.receipt?.gasUsed?.toString(),
|
||||
blockNumber: tx.receipt?.blockNumber,
|
||||
allowedSlippage: slippageTolerance,
|
||||
succeeded: tx.receipt?.status === 1,
|
||||
})
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const txHandlers: TransactionEventHandlers = useMemo(() => ({ onTxSubmit, onTxSuccess }), [onTxSubmit, onTxSuccess])
|
||||
|
||||
return { transactions: { ...txHandlers } }
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {
|
||||
TEST_ALLOWED_SLIPPAGE,
|
||||
TEST_TOKEN_1,
|
||||
TEST_TRADE_EXACT_INPUT,
|
||||
TEST_TRADE_EXACT_OUTPUT,
|
||||
toCurrencyAmount,
|
||||
} from 'test-utils/constants'
|
||||
import { TEST_ALLOWED_SLIPPAGE, TEST_TRADE_EXACT_INPUT, TEST_TRADE_EXACT_OUTPUT } from 'test-utils/constants'
|
||||
import { act, render, screen } from 'test-utils/render'
|
||||
|
||||
import { AdvancedSwapDetails } from './AdvancedSwapDetails'
|
||||
@@ -27,9 +21,9 @@ describe('AdvancedSwapDetails.tsx', () => {
|
||||
})
|
||||
|
||||
it('renders correct tooltips for test trade with exact output and gas use estimate USD', async () => {
|
||||
TEST_TRADE_EXACT_OUTPUT.gasUseEstimateUSD = toCurrencyAmount(TEST_TOKEN_1, 1)
|
||||
TEST_TRADE_EXACT_OUTPUT.gasUseEstimateUSD = '1.00'
|
||||
render(<AdvancedSwapDetails trade={TEST_TRADE_EXACT_OUTPUT} allowedSlippage={TEST_ALLOWED_SLIPPAGE} />)
|
||||
await act(() => userEvent.hover(screen.getByText(/Minimum output/i)))
|
||||
await act(() => userEvent.hover(screen.getByText(/Maximum input/i)))
|
||||
expect(await screen.getByText(/The minimum amount you are guaranteed to receive./i)).toBeVisible()
|
||||
await act(() => userEvent.hover(screen.getByText('Network fee')))
|
||||
expect(await screen.getByText(/The fee paid to miners who process your transaction./i)).toBeVisible()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { sendAnalyticsEvent } from '@uniswap/analytics'
|
||||
import { InterfaceElementName, SwapEventName } from '@uniswap/analytics-events'
|
||||
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
|
||||
import { Percent, TradeType } from '@uniswap/sdk-core'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import { LoadingRows } from 'components/Loader/styled'
|
||||
import { SUPPORTED_GAS_ESTIMATE_CHAIN_IDS } from 'constants/chains'
|
||||
import useNativeCurrency from 'lib/hooks/useNativeCurrency'
|
||||
import { InterfaceTrade } from 'state/routing/types'
|
||||
import formatPriceImpact from 'utils/formatPriceImpact'
|
||||
|
||||
import { Separator, ThemedText } from '../../theme'
|
||||
import Column from '../Column'
|
||||
@@ -16,7 +17,7 @@ import RouterLabel from './RouterLabel'
|
||||
import SwapRoute from './SwapRoute'
|
||||
|
||||
interface AdvancedSwapDetailsProps {
|
||||
trade: InterfaceTrade<Currency, Currency, TradeType>
|
||||
trade: InterfaceTrade
|
||||
allowedSlippage: Percent
|
||||
syncing?: boolean
|
||||
}
|
||||
@@ -60,10 +61,20 @@ export function AdvancedSwapDetails({ trade, allowedSlippage, syncing = false }:
|
||||
</ThemedText.BodySmall>
|
||||
</MouseoverTooltip>
|
||||
<TextWithLoadingPlaceholder syncing={syncing} width={50}>
|
||||
<ThemedText.BodySmall>~${trade.gasUseEstimateUSD.toFixed(2)}</ThemedText.BodySmall>
|
||||
<ThemedText.BodySmall>~${trade.gasUseEstimateUSD}</ThemedText.BodySmall>
|
||||
</TextWithLoadingPlaceholder>
|
||||
</RowBetween>
|
||||
)}
|
||||
<RowBetween>
|
||||
<MouseoverTooltip text={<Trans>The impact your trade has on the market price of this pool.</Trans>}>
|
||||
<ThemedText.BodySmall color="textSecondary">
|
||||
<Trans>Price Impact</Trans>
|
||||
</ThemedText.BodySmall>
|
||||
</MouseoverTooltip>
|
||||
<TextWithLoadingPlaceholder syncing={syncing} width={50}>
|
||||
<ThemedText.BodySmall>{formatPriceImpact(trade.priceImpact)}</ThemedText.BodySmall>
|
||||
</TextWithLoadingPlaceholder>
|
||||
</RowBetween>
|
||||
<RowBetween>
|
||||
<RowFixed>
|
||||
<MouseoverTooltip
|
||||
@@ -75,7 +86,7 @@ export function AdvancedSwapDetails({ trade, allowedSlippage, syncing = false }:
|
||||
}
|
||||
>
|
||||
<ThemedText.BodySmall color="textSecondary">
|
||||
<Trans>Minimum output</Trans>
|
||||
{trade.tradeType === TradeType.EXACT_INPUT ? <Trans>Minimum output</Trans> : <Trans>Maximum input</Trans>}
|
||||
</ThemedText.BodySmall>
|
||||
</MouseoverTooltip>
|
||||
</RowFixed>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { Trace } from '@uniswap/analytics'
|
||||
import { InterfaceModalName } from '@uniswap/analytics-events'
|
||||
import { Trade } from '@uniswap/router-sdk'
|
||||
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
|
||||
import { ReactNode, useCallback, useMemo, useState } from 'react'
|
||||
import { sendAnalyticsEvent, Trace } from '@uniswap/analytics'
|
||||
import { InterfaceModalName, SwapEventName, SwapPriceUpdateUserResponse } from '@uniswap/analytics-events'
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
import { getPriceUpdateBasisPoints } from 'lib/utils/analytics'
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { InterfaceTrade } from 'state/routing/types'
|
||||
import { formatSwapPriceUpdatedEventProperties } from 'utils/loggingFormatters'
|
||||
import { tradeMeaningfullyDiffers } from 'utils/tradeMeaningFullyDiffer'
|
||||
|
||||
import TransactionConfirmationModal, {
|
||||
@@ -21,21 +22,17 @@ export default function ConfirmSwapModal({
|
||||
allowedSlippage,
|
||||
onConfirm,
|
||||
onDismiss,
|
||||
recipient,
|
||||
swapErrorMessage,
|
||||
isOpen,
|
||||
attemptingTxn,
|
||||
txHash,
|
||||
swapQuoteReceivedDate,
|
||||
fiatValueInput,
|
||||
fiatValueOutput,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined
|
||||
originalTrade: Trade<Currency, Currency, TradeType> | undefined
|
||||
trade: InterfaceTrade
|
||||
originalTrade: InterfaceTrade | undefined
|
||||
attemptingTxn: boolean
|
||||
txHash: string | undefined
|
||||
recipient: string | null
|
||||
allowedSlippage: Percent
|
||||
onAcceptChanges: () => void
|
||||
onConfirm: () => void
|
||||
@@ -45,35 +42,34 @@ export default function ConfirmSwapModal({
|
||||
fiatValueInput: { data?: number; isLoading: boolean }
|
||||
fiatValueOutput: { data?: number; isLoading: boolean }
|
||||
}) {
|
||||
// shouldLogModalCloseEvent lets the child SwapModalHeader component know when modal has been closed
|
||||
// and an event triggered by modal closing should be logged.
|
||||
const [shouldLogModalCloseEvent, setShouldLogModalCloseEvent] = useState(false)
|
||||
const showAcceptChanges = useMemo(
|
||||
() => Boolean(trade && originalTrade && tradeMeaningfullyDiffers(trade, originalTrade)),
|
||||
() => Boolean(originalTrade && tradeMeaningfullyDiffers(trade, originalTrade)),
|
||||
[originalTrade, trade]
|
||||
)
|
||||
|
||||
const [lastExecutionPrice, setLastExecutionPrice] = useState(trade?.executionPrice)
|
||||
const [priceUpdate, setPriceUpdate] = useState<number>()
|
||||
useEffect(() => {
|
||||
if (lastExecutionPrice && !trade.executionPrice.equalTo(lastExecutionPrice)) {
|
||||
setPriceUpdate(getPriceUpdateBasisPoints(lastExecutionPrice, trade.executionPrice))
|
||||
setLastExecutionPrice(trade.executionPrice)
|
||||
}
|
||||
}, [lastExecutionPrice, setLastExecutionPrice, trade])
|
||||
|
||||
const onModalDismiss = useCallback(() => {
|
||||
if (isOpen) setShouldLogModalCloseEvent(true)
|
||||
sendAnalyticsEvent(
|
||||
SwapEventName.SWAP_PRICE_UPDATE_ACKNOWLEDGED,
|
||||
formatSwapPriceUpdatedEventProperties(trade, priceUpdate, SwapPriceUpdateUserResponse.REJECTED)
|
||||
)
|
||||
onDismiss()
|
||||
}, [isOpen, onDismiss])
|
||||
}, [onDismiss, priceUpdate, trade])
|
||||
|
||||
const modalHeader = useCallback(() => {
|
||||
return trade ? (
|
||||
<SwapModalHeader
|
||||
trade={trade}
|
||||
shouldLogModalCloseEvent={shouldLogModalCloseEvent}
|
||||
setShouldLogModalCloseEvent={setShouldLogModalCloseEvent}
|
||||
allowedSlippage={allowedSlippage}
|
||||
recipient={recipient}
|
||||
showAcceptChanges={showAcceptChanges}
|
||||
onAcceptChanges={onAcceptChanges}
|
||||
/>
|
||||
) : null
|
||||
}, [allowedSlippage, onAcceptChanges, recipient, showAcceptChanges, trade, shouldLogModalCloseEvent])
|
||||
return <SwapModalHeader trade={trade} allowedSlippage={allowedSlippage} />
|
||||
}, [allowedSlippage, trade])
|
||||
|
||||
const modalBottom = useCallback(() => {
|
||||
return trade ? (
|
||||
return (
|
||||
<SwapModalFooter
|
||||
onConfirm={onConfirm}
|
||||
trade={trade}
|
||||
@@ -84,25 +80,28 @@ export default function ConfirmSwapModal({
|
||||
swapQuoteReceivedDate={swapQuoteReceivedDate}
|
||||
fiatValueInput={fiatValueInput}
|
||||
fiatValueOutput={fiatValueOutput}
|
||||
showAcceptChanges={showAcceptChanges}
|
||||
onAcceptChanges={onAcceptChanges}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
}, [
|
||||
trade,
|
||||
onConfirm,
|
||||
txHash,
|
||||
allowedSlippage,
|
||||
showAcceptChanges,
|
||||
swapErrorMessage,
|
||||
trade,
|
||||
allowedSlippage,
|
||||
txHash,
|
||||
swapQuoteReceivedDate,
|
||||
fiatValueInput,
|
||||
fiatValueOutput,
|
||||
onAcceptChanges,
|
||||
])
|
||||
|
||||
// text to show while loading
|
||||
const pendingText = (
|
||||
<Trans>
|
||||
Swapping {trade?.inputAmount?.toSignificant(6)} {trade?.inputAmount?.currency?.symbol} for{' '}
|
||||
{trade?.outputAmount?.toSignificant(6)} {trade?.outputAmount?.currency?.symbol}
|
||||
Swapping {trade.inputAmount.toSignificant(6)} {trade.inputAmount.currency?.symbol} for{' '}
|
||||
{trade.outputAmount.toSignificant(6)} {trade.outputAmount.currency?.symbol}
|
||||
</Trans>
|
||||
)
|
||||
|
||||
@@ -112,7 +111,7 @@ export default function ConfirmSwapModal({
|
||||
<TransactionErrorContent onDismiss={onModalDismiss} message={swapErrorMessage} />
|
||||
) : (
|
||||
<ConfirmationModalContent
|
||||
title={<Trans>Confirm Swap</Trans>}
|
||||
title={<Trans>Review Swap</Trans>}
|
||||
onDismiss={onModalDismiss}
|
||||
topContent={modalHeader}
|
||||
bottomContent={modalBottom}
|
||||
@@ -124,13 +123,13 @@ export default function ConfirmSwapModal({
|
||||
return (
|
||||
<Trace modal={InterfaceModalName.CONFIRM_SWAP}>
|
||||
<TransactionConfirmationModal
|
||||
isOpen={isOpen}
|
||||
isOpen
|
||||
onDismiss={onModalDismiss}
|
||||
attemptingTxn={attemptingTxn}
|
||||
hash={txHash}
|
||||
content={confirmationContent}
|
||||
pendingText={pendingText}
|
||||
currencyToAdd={trade?.outputAmount.currency}
|
||||
currencyToAdd={trade.outputAmount.currency}
|
||||
/>
|
||||
</Trace>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { sendAnalyticsEvent } from '@uniswap/analytics'
|
||||
import { InterfaceElementName, SwapEventName } from '@uniswap/analytics-events'
|
||||
import { Currency, TradeType } from '@uniswap/sdk-core'
|
||||
import { LoadingOpacityContainer } from 'components/Loader/styled'
|
||||
import { RowFixed } from 'components/Row'
|
||||
import { MouseoverTooltip, TooltipSize } from 'components/Tooltip'
|
||||
@@ -26,14 +25,14 @@ export default function GasEstimateTooltip({
|
||||
loading,
|
||||
disabled,
|
||||
}: {
|
||||
trade: InterfaceTrade<Currency, Currency, TradeType> // dollar amount in active chain's stablecoin
|
||||
trade: InterfaceTrade // dollar amount in active chain's stablecoin
|
||||
loading: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const formattedGasPriceString = trade?.gasUseEstimateUSD
|
||||
? trade.gasUseEstimateUSD.toFixed(2) === '0.00'
|
||||
? trade.gasUseEstimateUSD === '0.00'
|
||||
? '<$0.01'
|
||||
: '$' + trade.gasUseEstimateUSD.toFixed(2)
|
||||
: '$' + trade.gasUseEstimateUSD
|
||||
: undefined
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Percent } from '@uniswap/sdk-core'
|
||||
import { OutlineCard } from 'components/Card'
|
||||
import styled, { useTheme } from 'styled-components/macro'
|
||||
import { opacify } from 'theme/utils'
|
||||
import formatPriceImpact from 'utils/formatPriceImpact'
|
||||
|
||||
import { ThemedText } from '../../theme'
|
||||
import { AutoColumn } from '../Column'
|
||||
@@ -18,8 +19,6 @@ interface PriceImpactWarningProps {
|
||||
priceImpact: Percent
|
||||
}
|
||||
|
||||
const formatPriceImpact = (priceImpact: Percent) => `${priceImpact.multiply(-1).toFixed(2)}%`
|
||||
|
||||
export default function PriceImpactWarning({ priceImpact }: PriceImpactWarningProps) {
|
||||
const theme = useTheme()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { TEST_ALLOWED_SLIPPAGE, TEST_TOKEN_1, TEST_TRADE_EXACT_INPUT, toCurrencyAmount } from 'test-utils/constants'
|
||||
import { TEST_ALLOWED_SLIPPAGE, TEST_TRADE_EXACT_INPUT } from 'test-utils/constants'
|
||||
import { act, render, screen } from 'test-utils/render'
|
||||
|
||||
import SwapDetailsDropdown from './SwapDetailsDropdown'
|
||||
@@ -25,7 +25,7 @@ describe('SwapDetailsDropdown.tsx', () => {
|
||||
})
|
||||
|
||||
it('is interactive once loaded', async () => {
|
||||
TEST_TRADE_EXACT_INPUT.gasUseEstimateUSD = toCurrencyAmount(TEST_TOKEN_1, 1)
|
||||
TEST_TRADE_EXACT_INPUT.gasUseEstimateUSD = '1.00'
|
||||
render(
|
||||
<SwapDetailsDropdown
|
||||
trade={TEST_TRADE_EXACT_INPUT}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { TraceEvent } from '@uniswap/analytics'
|
||||
import { BrowserEvent, InterfaceElementName, SwapEventName } from '@uniswap/analytics-events'
|
||||
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import AnimatedDropdown from 'components/AnimatedDropdown'
|
||||
import Column from 'components/Column'
|
||||
@@ -92,7 +92,7 @@ const Wrapper = styled(Column)`
|
||||
`
|
||||
|
||||
interface SwapDetailsInlineProps {
|
||||
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined
|
||||
trade: InterfaceTrade | undefined
|
||||
syncing: boolean
|
||||
loading: boolean
|
||||
allowedSlippage: Percent
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { Percent } from '@uniswap/sdk-core'
|
||||
import { useFiatOnRampButtonEnabled } from 'featureFlags/flags/fiatOnRampButton'
|
||||
import { subhead } from 'nft/css/common.css'
|
||||
import styled from 'styled-components/macro'
|
||||
import { ThemedText } from 'theme'
|
||||
|
||||
import { RowBetween, RowFixed } from '../Row'
|
||||
import SettingsTab from '../Settings'
|
||||
import SwapBuyFiatButton from './SwapBuyFiatButton'
|
||||
|
||||
const StyledSwapHeader = styled.div`
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
const StyledSwapHeader = styled(RowBetween)`
|
||||
margin-bottom: 10px;
|
||||
color: ${({ theme }) => theme.textSecondary};
|
||||
`
|
||||
|
||||
const TextHeader = styled.div`
|
||||
color: ${({ theme }) => theme.textPrimary};
|
||||
margin-right: 8px;
|
||||
display: flex;
|
||||
line-height: 20px;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
const HeaderButtonContainer = styled(RowFixed)`
|
||||
padding: 0 12px;
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
export default function SwapHeader({ autoSlippage }: { autoSlippage: Percent }) {
|
||||
@@ -30,17 +23,15 @@ export default function SwapHeader({ autoSlippage }: { autoSlippage: Percent })
|
||||
|
||||
return (
|
||||
<StyledSwapHeader>
|
||||
<RowBetween>
|
||||
<RowFixed style={{ gap: '8px' }}>
|
||||
<TextHeader className={subhead}>
|
||||
<Trans>Swap</Trans>
|
||||
</TextHeader>
|
||||
{fiatOnRampButtonEnabled && <SwapBuyFiatButton />}
|
||||
</RowFixed>
|
||||
<RowFixed>
|
||||
<SettingsTab autoSlippage={autoSlippage} />
|
||||
</RowFixed>
|
||||
</RowBetween>
|
||||
<HeaderButtonContainer>
|
||||
<ThemedText.SubHeader>
|
||||
<Trans>Swap</Trans>
|
||||
</ThemedText.SubHeader>
|
||||
{fiatOnRampButtonEnabled && <SwapBuyFiatButton />}
|
||||
</HeaderButtonContainer>
|
||||
<RowFixed>
|
||||
<SettingsTab autoSlippage={autoSlippage} />
|
||||
</RowFixed>
|
||||
</StyledSwapHeader>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,103 @@
|
||||
import { TEST_ALLOWED_SLIPPAGE, TEST_TRADE_EXACT_INPUT } from 'test-utils/constants'
|
||||
import { render, screen } from 'test-utils/render'
|
||||
import { TEST_ALLOWED_SLIPPAGE, TEST_TRADE_EXACT_INPUT, TEST_TRADE_EXACT_OUTPUT } from 'test-utils/constants'
|
||||
import { render, screen, within } from 'test-utils/render'
|
||||
|
||||
import SwapModalFooter from './SwapModalFooter'
|
||||
|
||||
const swapErrorMessage = 'swap error'
|
||||
const fiatValue = { data: 123, isLoading: false }
|
||||
|
||||
describe('SwapModalFooter.tsx', () => {
|
||||
it('renders with a disabled button with no account', () => {
|
||||
it('matches base snapshot, test trade exact input', () => {
|
||||
const { asFragment } = render(
|
||||
<SwapModalFooter
|
||||
trade={TEST_TRADE_EXACT_INPUT}
|
||||
allowedSlippage={TEST_ALLOWED_SLIPPAGE}
|
||||
hash={undefined}
|
||||
onConfirm={() => null}
|
||||
disabledConfirm
|
||||
swapErrorMessage={swapErrorMessage}
|
||||
onConfirm={jest.fn()}
|
||||
swapErrorMessage={undefined}
|
||||
disabledConfirm={false}
|
||||
swapQuoteReceivedDate={undefined}
|
||||
fiatValueInput={fiatValue}
|
||||
fiatValueOutput={fiatValue}
|
||||
fiatValueInput={{
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
}}
|
||||
fiatValueOutput={{
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
}}
|
||||
showAcceptChanges={false}
|
||||
onAcceptChanges={jest.fn()}
|
||||
/>
|
||||
)
|
||||
expect(asFragment()).toMatchSnapshot()
|
||||
expect(screen.getByTestId('confirm-swap-button')).toBeDisabled()
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The minimum amount you are guaranteed to receive. If the price slips any further, your transaction will revert.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('The fee paid to miners who process your transaction. This must be paid in $ETH.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('The impact your trade has on the market price of this pool.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows accept changes section when available', () => {
|
||||
const mockAcceptChanges = jest.fn()
|
||||
render(
|
||||
<SwapModalFooter
|
||||
trade={TEST_TRADE_EXACT_INPUT}
|
||||
allowedSlippage={TEST_ALLOWED_SLIPPAGE}
|
||||
hash={undefined}
|
||||
onConfirm={jest.fn()}
|
||||
swapErrorMessage={undefined}
|
||||
disabledConfirm={false}
|
||||
swapQuoteReceivedDate={undefined}
|
||||
fiatValueInput={{
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
}}
|
||||
fiatValueOutput={{
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
}}
|
||||
showAcceptChanges={true}
|
||||
onAcceptChanges={mockAcceptChanges}
|
||||
/>
|
||||
)
|
||||
const showAcceptChanges = screen.getByTestId('show-accept-changes')
|
||||
expect(showAcceptChanges).toBeInTheDocument()
|
||||
expect(within(showAcceptChanges).getByText('Price updated')).toBeVisible()
|
||||
expect(within(showAcceptChanges).getByText('Accept')).toBeVisible()
|
||||
})
|
||||
|
||||
it('test trade exact output, no recipient', () => {
|
||||
render(
|
||||
<SwapModalFooter
|
||||
trade={TEST_TRADE_EXACT_OUTPUT}
|
||||
allowedSlippage={TEST_ALLOWED_SLIPPAGE}
|
||||
hash={undefined}
|
||||
onConfirm={jest.fn()}
|
||||
swapErrorMessage={undefined}
|
||||
disabledConfirm={false}
|
||||
swapQuoteReceivedDate={undefined}
|
||||
fiatValueInput={{
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
}}
|
||||
fiatValueOutput={{
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
}}
|
||||
showAcceptChanges={true}
|
||||
onAcceptChanges={jest.fn()}
|
||||
/>
|
||||
)
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The maximum amount you are guaranteed to spend. If the price slips any further, your transaction will revert.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('The fee paid to miners who process your transaction. This must be paid in $ETH.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('The impact your trade has on the market price of this pool.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,105 +1,48 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { TraceEvent } from '@uniswap/analytics'
|
||||
import { BrowserEvent, InterfaceElementName, SwapEventName } from '@uniswap/analytics-events'
|
||||
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
|
||||
import { formatPriceImpact } from '@uniswap/conedison/format'
|
||||
import { Percent, TradeType } from '@uniswap/sdk-core'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import Column from 'components/Column'
|
||||
import { MouseoverTooltip } from 'components/Tooltip'
|
||||
import useTransactionDeadline from 'hooks/useTransactionDeadline'
|
||||
import {
|
||||
formatPercentInBasisPointsNumber,
|
||||
formatPercentNumber,
|
||||
formatToDecimal,
|
||||
getDurationFromDateMilliseconds,
|
||||
getDurationUntilTimestampSeconds,
|
||||
getTokenAddress,
|
||||
} from 'lib/utils/analytics'
|
||||
import useNativeCurrency from 'lib/hooks/useNativeCurrency'
|
||||
import { ReactNode } from 'react'
|
||||
import { Text } from 'rebass'
|
||||
import { AlertTriangle } from 'react-feather'
|
||||
import { RouterPreference } from 'state/routing/slice'
|
||||
import { InterfaceTrade } from 'state/routing/types'
|
||||
import { useRouterPreference, useUserSlippageTolerance } from 'state/user/hooks'
|
||||
import getRoutingDiagramEntries, { RoutingDiagramEntry } from 'utils/getRoutingDiagramEntries'
|
||||
import { computeRealizedPriceImpact } from 'utils/prices'
|
||||
import styled, { useTheme } from 'styled-components/macro'
|
||||
import { ThemedText } from 'theme'
|
||||
import { formatTransactionAmount, priceToPreciseFloat } from 'utils/formatNumbers'
|
||||
import getRoutingDiagramEntries from 'utils/getRoutingDiagramEntries'
|
||||
import { formatSwapButtonClickEventProperties } from 'utils/loggingFormatters'
|
||||
import { getPriceImpactWarning } from 'utils/prices'
|
||||
|
||||
import { ButtonError } from '../Button'
|
||||
import { AutoRow } from '../Row'
|
||||
import { SwapCallbackError } from './styleds'
|
||||
import { ButtonError, SmallButtonPrimary } from '../Button'
|
||||
import Row, { AutoRow, RowBetween, RowFixed } from '../Row'
|
||||
import { SwapCallbackError, SwapShowAcceptChanges } from './styleds'
|
||||
import { Label } from './SwapModalHeaderAmount'
|
||||
|
||||
interface AnalyticsEventProps {
|
||||
trade: InterfaceTrade<Currency, Currency, TradeType>
|
||||
hash: string | undefined
|
||||
allowedSlippage: Percent
|
||||
transactionDeadlineSecondsSinceEpoch: number | undefined
|
||||
isAutoSlippage: boolean
|
||||
isAutoRouterApi: boolean
|
||||
swapQuoteReceivedDate: Date | undefined
|
||||
routes: RoutingDiagramEntry[]
|
||||
fiatValueInput?: number
|
||||
fiatValueOutput?: number
|
||||
}
|
||||
const DetailsContainer = styled(Column)`
|
||||
padding: 0 8px;
|
||||
`
|
||||
|
||||
const formatRoutesEventProperties = (routes: RoutingDiagramEntry[]) => {
|
||||
const routesEventProperties: Record<string, any[]> = {
|
||||
routes_percentages: [],
|
||||
routes_protocols: [],
|
||||
}
|
||||
const StyledAlertTriangle = styled(AlertTriangle)`
|
||||
margin-right: 8px;
|
||||
min-width: 24px;
|
||||
`
|
||||
|
||||
routes.forEach((route, index) => {
|
||||
routesEventProperties['routes_percentages'].push(formatPercentNumber(route.percent))
|
||||
routesEventProperties['routes_protocols'].push(route.protocol)
|
||||
routesEventProperties[`route_${index}_input_currency_symbols`] = route.path.map(
|
||||
(pathStep) => pathStep[0].symbol ?? ''
|
||||
)
|
||||
routesEventProperties[`route_${index}_output_currency_symbols`] = route.path.map(
|
||||
(pathStep) => pathStep[1].symbol ?? ''
|
||||
)
|
||||
routesEventProperties[`route_${index}_input_currency_addresses`] = route.path.map((pathStep) =>
|
||||
getTokenAddress(pathStep[0])
|
||||
)
|
||||
routesEventProperties[`route_${index}_output_currency_addresses`] = route.path.map((pathStep) =>
|
||||
getTokenAddress(pathStep[1])
|
||||
)
|
||||
routesEventProperties[`route_${index}_fee_amounts_hundredths_of_bps`] = route.path.map((pathStep) => pathStep[2])
|
||||
})
|
||||
const ConfirmButton = styled(ButtonError)`
|
||||
height: 56px;
|
||||
margin-top: 10px;
|
||||
`
|
||||
|
||||
return routesEventProperties
|
||||
}
|
||||
|
||||
const formatAnalyticsEventProperties = ({
|
||||
trade,
|
||||
hash,
|
||||
allowedSlippage,
|
||||
transactionDeadlineSecondsSinceEpoch,
|
||||
isAutoSlippage,
|
||||
isAutoRouterApi,
|
||||
swapQuoteReceivedDate,
|
||||
routes,
|
||||
fiatValueInput,
|
||||
fiatValueOutput,
|
||||
}: AnalyticsEventProps) => ({
|
||||
estimated_network_fee_usd: trade.gasUseEstimateUSD ? formatToDecimal(trade.gasUseEstimateUSD, 2) : undefined,
|
||||
transaction_hash: hash,
|
||||
transaction_deadline_seconds: getDurationUntilTimestampSeconds(transactionDeadlineSecondsSinceEpoch),
|
||||
token_in_address: getTokenAddress(trade.inputAmount.currency),
|
||||
token_out_address: getTokenAddress(trade.outputAmount.currency),
|
||||
token_in_symbol: trade.inputAmount.currency.symbol,
|
||||
token_out_symbol: trade.outputAmount.currency.symbol,
|
||||
token_in_amount: formatToDecimal(trade.inputAmount, trade.inputAmount.currency.decimals),
|
||||
token_out_amount: formatToDecimal(trade.outputAmount, trade.outputAmount.currency.decimals),
|
||||
token_in_amount_usd: fiatValueInput,
|
||||
token_out_amount_usd: fiatValueOutput,
|
||||
price_impact_basis_points: formatPercentInBasisPointsNumber(computeRealizedPriceImpact(trade)),
|
||||
allowed_slippage_basis_points: formatPercentInBasisPointsNumber(allowedSlippage),
|
||||
is_auto_router_api: isAutoRouterApi,
|
||||
is_auto_slippage: isAutoSlippage,
|
||||
chain_id:
|
||||
trade.inputAmount.currency.chainId === trade.outputAmount.currency.chainId
|
||||
? trade.inputAmount.currency.chainId
|
||||
: undefined,
|
||||
duration_from_first_quote_to_swap_submission_milliseconds: swapQuoteReceivedDate
|
||||
? getDurationFromDateMilliseconds(swapQuoteReceivedDate)
|
||||
: undefined,
|
||||
swap_quote_block_number: trade.blockNumber,
|
||||
...formatRoutesEventProperties(routes),
|
||||
})
|
||||
const DetailRowValue = styled(ThemedText.BodySmall)`
|
||||
text-align: right;
|
||||
overflow-wrap: break-word;
|
||||
`
|
||||
|
||||
export default function SwapModalFooter({
|
||||
trade,
|
||||
@@ -111,8 +54,10 @@ export default function SwapModalFooter({
|
||||
swapQuoteReceivedDate,
|
||||
fiatValueInput,
|
||||
fiatValueOutput,
|
||||
showAcceptChanges,
|
||||
onAcceptChanges,
|
||||
}: {
|
||||
trade: InterfaceTrade<Currency, Currency, TradeType>
|
||||
trade: InterfaceTrade
|
||||
hash: string | undefined
|
||||
allowedSlippage: Percent
|
||||
onConfirm: () => void
|
||||
@@ -121,46 +66,142 @@ export default function SwapModalFooter({
|
||||
swapQuoteReceivedDate: Date | undefined
|
||||
fiatValueInput: { data?: number; isLoading: boolean }
|
||||
fiatValueOutput: { data?: number; isLoading: boolean }
|
||||
showAcceptChanges: boolean
|
||||
onAcceptChanges: () => void
|
||||
}) {
|
||||
const transactionDeadlineSecondsSinceEpoch = useTransactionDeadline()?.toNumber() // in seconds since epoch
|
||||
const isAutoSlippage = useUserSlippageTolerance()[0] === 'auto'
|
||||
const [routerPreference] = useRouterPreference()
|
||||
const routes = getRoutingDiagramEntries(trade)
|
||||
const theme = useTheme()
|
||||
const { chainId } = useWeb3React()
|
||||
const nativeCurrency = useNativeCurrency(chainId)
|
||||
|
||||
const label = `${trade.executionPrice.baseCurrency?.symbol} `
|
||||
const labelInverted = `${trade.executionPrice.quoteCurrency?.symbol}`
|
||||
const formattedPrice = formatTransactionAmount(priceToPreciseFloat(trade.executionPrice))
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutoRow>
|
||||
<TraceEvent
|
||||
events={[BrowserEvent.onClick]}
|
||||
element={InterfaceElementName.CONFIRM_SWAP_BUTTON}
|
||||
name={SwapEventName.SWAP_SUBMITTED_BUTTON_CLICKED}
|
||||
properties={formatAnalyticsEventProperties({
|
||||
trade,
|
||||
hash,
|
||||
allowedSlippage,
|
||||
transactionDeadlineSecondsSinceEpoch,
|
||||
isAutoSlippage,
|
||||
isAutoRouterApi: routerPreference === RouterPreference.AUTO || routerPreference === RouterPreference.API,
|
||||
swapQuoteReceivedDate,
|
||||
routes,
|
||||
fiatValueInput: fiatValueInput.data,
|
||||
fiatValueOutput: fiatValueOutput.data,
|
||||
})}
|
||||
>
|
||||
<ButtonError
|
||||
data-testid="confirm-swap-button"
|
||||
onClick={onConfirm}
|
||||
disabled={disabledConfirm}
|
||||
style={{ margin: '10px 0 0 0' }}
|
||||
id={InterfaceElementName.CONFIRM_SWAP_BUTTON}
|
||||
<DetailsContainer gap="md">
|
||||
<ThemedText.BodySmall>
|
||||
<Row align="flex-start" justify="space-between" gap="sm">
|
||||
<Label>
|
||||
<Trans>Exchange rate</Trans>
|
||||
</Label>
|
||||
<DetailRowValue>{`1 ${labelInverted} = ${formattedPrice ?? '-'} ${label}`}</DetailRowValue>
|
||||
</Row>
|
||||
</ThemedText.BodySmall>
|
||||
<ThemedText.BodySmall>
|
||||
<Row align="flex-start" justify="space-between" gap="sm">
|
||||
<MouseoverTooltip
|
||||
text={
|
||||
<Trans>
|
||||
The fee paid to miners who process your transaction. This must be paid in ${nativeCurrency.symbol}.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Label cursor="help">
|
||||
<Trans>Network fee</Trans>
|
||||
</Label>
|
||||
</MouseoverTooltip>
|
||||
<DetailRowValue>{trade.gasUseEstimateUSD ? `~$${trade.gasUseEstimateUSD}` : '-'}</DetailRowValue>
|
||||
</Row>
|
||||
</ThemedText.BodySmall>
|
||||
<ThemedText.BodySmall>
|
||||
<Row align="flex-start" justify="space-between" gap="sm">
|
||||
<MouseoverTooltip text={<Trans>The impact your trade has on the market price of this pool.</Trans>}>
|
||||
<Label cursor="help">
|
||||
<Trans>Price impact</Trans>
|
||||
</Label>
|
||||
</MouseoverTooltip>
|
||||
<DetailRowValue color={getPriceImpactWarning(trade.priceImpact)}>
|
||||
{trade.priceImpact ? formatPriceImpact(trade.priceImpact) : '-'}
|
||||
</DetailRowValue>
|
||||
</Row>
|
||||
</ThemedText.BodySmall>
|
||||
<ThemedText.BodySmall>
|
||||
<Row align="flex-start" justify="space-between" gap="sm">
|
||||
<MouseoverTooltip
|
||||
text={
|
||||
trade.tradeType === TradeType.EXACT_INPUT ? (
|
||||
<Trans>
|
||||
The minimum amount you are guaranteed to receive. If the price slips any further, your transaction
|
||||
will revert.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
The maximum amount you are guaranteed to spend. If the price slips any further, your transaction
|
||||
will revert.
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Label cursor="help">
|
||||
{trade.tradeType === TradeType.EXACT_INPUT ? (
|
||||
<Trans>Minimum received</Trans>
|
||||
) : (
|
||||
<Trans>Maximum sent</Trans>
|
||||
)}
|
||||
</Label>
|
||||
</MouseoverTooltip>
|
||||
<DetailRowValue>
|
||||
{trade.tradeType === TradeType.EXACT_INPUT
|
||||
? `${trade.minimumAmountOut(allowedSlippage).toSignificant(6)} ${trade.outputAmount.currency.symbol}`
|
||||
: `${trade.maximumAmountIn(allowedSlippage).toSignificant(6)} ${trade.inputAmount.currency.symbol}`}
|
||||
</DetailRowValue>
|
||||
</Row>
|
||||
</ThemedText.BodySmall>
|
||||
</DetailsContainer>
|
||||
{showAcceptChanges ? (
|
||||
<SwapShowAcceptChanges data-testid="show-accept-changes">
|
||||
<RowBetween>
|
||||
<RowFixed>
|
||||
<StyledAlertTriangle size={20} />
|
||||
<ThemedText.DeprecatedMain color={theme.accentAction}>
|
||||
<Trans>Price updated</Trans>
|
||||
</ThemedText.DeprecatedMain>
|
||||
</RowFixed>
|
||||
<SmallButtonPrimary onClick={onAcceptChanges}>
|
||||
<Trans>Accept</Trans>
|
||||
</SmallButtonPrimary>
|
||||
</RowBetween>
|
||||
</SwapShowAcceptChanges>
|
||||
) : (
|
||||
<AutoRow>
|
||||
<TraceEvent
|
||||
events={[BrowserEvent.onClick]}
|
||||
element={InterfaceElementName.CONFIRM_SWAP_BUTTON}
|
||||
name={SwapEventName.SWAP_SUBMITTED_BUTTON_CLICKED}
|
||||
properties={formatSwapButtonClickEventProperties({
|
||||
trade,
|
||||
hash,
|
||||
allowedSlippage,
|
||||
transactionDeadlineSecondsSinceEpoch,
|
||||
isAutoSlippage,
|
||||
isAutoRouterApi: routerPreference === RouterPreference.AUTO || routerPreference === RouterPreference.API,
|
||||
swapQuoteReceivedDate,
|
||||
routes,
|
||||
fiatValueInput: fiatValueInput.data,
|
||||
fiatValueOutput: fiatValueOutput.data,
|
||||
})}
|
||||
>
|
||||
<Text fontSize={20} fontWeight={500}>
|
||||
<Trans>Confirm Swap</Trans>
|
||||
</Text>
|
||||
</ButtonError>
|
||||
</TraceEvent>
|
||||
{swapErrorMessage ? <SwapCallbackError error={swapErrorMessage} /> : null}
|
||||
</AutoRow>
|
||||
<ConfirmButton
|
||||
data-testid="confirm-swap-button"
|
||||
onClick={onConfirm}
|
||||
disabled={disabledConfirm}
|
||||
$borderRadius="12px"
|
||||
id={InterfaceElementName.CONFIRM_SWAP_BUTTON}
|
||||
>
|
||||
<ThemedText.HeadlineSmall color="accentTextLightPrimary">
|
||||
<Trans>Swap</Trans>
|
||||
</ThemedText.HeadlineSmall>
|
||||
</ConfirmButton>
|
||||
</TraceEvent>
|
||||
|
||||
{swapErrorMessage ? <SwapCallbackError error={swapErrorMessage} /> : null}
|
||||
</AutoRow>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,83 +1,44 @@
|
||||
import { sendAnalyticsEvent } from '@uniswap/analytics'
|
||||
import {
|
||||
TEST_ALLOWED_SLIPPAGE,
|
||||
TEST_RECIPIENT_ADDRESS,
|
||||
TEST_TRADE_EXACT_INPUT,
|
||||
TEST_TRADE_EXACT_OUTPUT,
|
||||
} from 'test-utils/constants'
|
||||
import { render, screen, within } from 'test-utils/render'
|
||||
import noop from 'utils/noop'
|
||||
import { formatCurrencyAmount, NumberType } from '@uniswap/conedison/format'
|
||||
import { TEST_ALLOWED_SLIPPAGE, TEST_TRADE_EXACT_INPUT, TEST_TRADE_EXACT_OUTPUT } from 'test-utils/constants'
|
||||
import { render, screen } from 'test-utils/render'
|
||||
|
||||
import SwapModalHeader from './SwapModalHeader'
|
||||
|
||||
jest.mock('@uniswap/analytics')
|
||||
const mockSendAnalyticsEvent = sendAnalyticsEvent as jest.MockedFunction<typeof sendAnalyticsEvent>
|
||||
|
||||
describe('SwapModalHeader.tsx', () => {
|
||||
let sendAnalyticsEventMock: jest.Mock<any, any>
|
||||
|
||||
beforeAll(() => {
|
||||
sendAnalyticsEventMock = jest.fn()
|
||||
})
|
||||
|
||||
it('matches base snapshot for test trade with exact input', () => {
|
||||
it('matches base snapshot, test trade exact input', () => {
|
||||
const { asFragment } = render(
|
||||
<SwapModalHeader
|
||||
trade={TEST_TRADE_EXACT_INPUT}
|
||||
allowedSlippage={TEST_ALLOWED_SLIPPAGE}
|
||||
shouldLogModalCloseEvent={false}
|
||||
showAcceptChanges={false}
|
||||
setShouldLogModalCloseEvent={noop}
|
||||
onAcceptChanges={noop}
|
||||
recipient={TEST_RECIPIENT_ADDRESS}
|
||||
/>
|
||||
<SwapModalHeader trade={TEST_TRADE_EXACT_INPUT} allowedSlippage={TEST_ALLOWED_SLIPPAGE} />
|
||||
)
|
||||
expect(asFragment()).toMatchSnapshot()
|
||||
expect(screen.getByText(/Output is estimated. You will receive at least /i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('INPUT-amount')).toHaveTextContent(
|
||||
`${formatCurrencyAmount(TEST_TRADE_EXACT_INPUT.inputAmount, NumberType.TokenTx)} ${
|
||||
TEST_TRADE_EXACT_INPUT.inputAmount.currency.symbol ?? ''
|
||||
}`
|
||||
)
|
||||
expect(screen.getByTestId('OUTPUT-amount')).toHaveTextContent(
|
||||
`${formatCurrencyAmount(TEST_TRADE_EXACT_INPUT.outputAmount, NumberType.TokenTx)} ${
|
||||
TEST_TRADE_EXACT_INPUT.outputAmount.currency.symbol ?? ''
|
||||
}`
|
||||
)
|
||||
})
|
||||
|
||||
it('shows accept changes section and logs amplitude event', () => {
|
||||
const setShouldLogModalCloseEventFn = jest.fn()
|
||||
mockSendAnalyticsEvent.mockImplementation(sendAnalyticsEventMock)
|
||||
render(
|
||||
<SwapModalHeader
|
||||
trade={TEST_TRADE_EXACT_INPUT}
|
||||
allowedSlippage={TEST_ALLOWED_SLIPPAGE}
|
||||
shouldLogModalCloseEvent
|
||||
showAcceptChanges
|
||||
setShouldLogModalCloseEvent={setShouldLogModalCloseEventFn}
|
||||
onAcceptChanges={noop}
|
||||
recipient={TEST_RECIPIENT_ADDRESS}
|
||||
/>
|
||||
it('test trade exact output, no recipient', () => {
|
||||
const { asFragment } = render(
|
||||
<SwapModalHeader trade={TEST_TRADE_EXACT_OUTPUT} allowedSlippage={TEST_ALLOWED_SLIPPAGE} />
|
||||
)
|
||||
expect(setShouldLogModalCloseEventFn).toHaveBeenCalledWith(false)
|
||||
const showAcceptChanges = screen.getByTestId('show-accept-changes')
|
||||
expect(showAcceptChanges).toBeInTheDocument()
|
||||
expect(within(showAcceptChanges).getByText('Price Updated')).toBeVisible()
|
||||
expect(within(showAcceptChanges).getByText('Accept')).toBeVisible()
|
||||
expect(sendAnalyticsEventMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders correctly for test trade with exact output and no recipient', () => {
|
||||
const rendered = render(
|
||||
<SwapModalHeader
|
||||
trade={TEST_TRADE_EXACT_OUTPUT}
|
||||
allowedSlippage={TEST_ALLOWED_SLIPPAGE}
|
||||
shouldLogModalCloseEvent={false}
|
||||
showAcceptChanges={false}
|
||||
setShouldLogModalCloseEvent={noop}
|
||||
onAcceptChanges={noop}
|
||||
recipient={null}
|
||||
/>
|
||||
)
|
||||
expect(rendered.queryByTestId('recipient-info')).toBeNull()
|
||||
expect(asFragment()).toMatchSnapshot()
|
||||
expect(screen.getByText(/Input is estimated. You will sell at most/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('input-symbol')).toHaveTextContent(
|
||||
TEST_TRADE_EXACT_OUTPUT.inputAmount.currency.symbol ?? ''
|
||||
|
||||
expect(screen.getByTestId('INPUT-amount')).toHaveTextContent(
|
||||
`${formatCurrencyAmount(TEST_TRADE_EXACT_OUTPUT.inputAmount, NumberType.TokenTx)} ${
|
||||
TEST_TRADE_EXACT_OUTPUT.inputAmount.currency.symbol ?? ''
|
||||
}`
|
||||
)
|
||||
expect(screen.getByTestId('output-symbol')).toHaveTextContent(
|
||||
TEST_TRADE_EXACT_OUTPUT.outputAmount.currency.symbol ?? ''
|
||||
expect(screen.getByTestId('OUTPUT-amount')).toHaveTextContent(
|
||||
`${formatCurrencyAmount(TEST_TRADE_EXACT_OUTPUT.outputAmount, NumberType.TokenTx)} ${
|
||||
TEST_TRADE_EXACT_OUTPUT.outputAmount.currency.symbol ?? ''
|
||||
}`
|
||||
)
|
||||
expect(screen.getByTestId('input-amount')).toHaveTextContent(TEST_TRADE_EXACT_OUTPUT.inputAmount.toExact())
|
||||
expect(screen.getByTestId('output-amount')).toHaveTextContent(TEST_TRADE_EXACT_OUTPUT.outputAmount.toExact())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,216 +1,72 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { sendAnalyticsEvent } from '@uniswap/analytics'
|
||||
import { SwapEventName, SwapPriceUpdateUserResponse } from '@uniswap/analytics-events'
|
||||
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
|
||||
import { Percent, TradeType } from '@uniswap/sdk-core'
|
||||
import Column, { AutoColumn } from 'components/Column'
|
||||
import { useUSDPrice } from 'hooks/useUSDPrice'
|
||||
import { getPriceUpdateBasisPoints } from 'lib/utils/analytics'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertTriangle, ArrowDown } from 'react-feather'
|
||||
import { Text } from 'rebass'
|
||||
import { InterfaceTrade } from 'state/routing/types'
|
||||
import styled, { useTheme } from 'styled-components/macro'
|
||||
import { Field } from 'state/swap/actions'
|
||||
import styled from 'styled-components/macro'
|
||||
import { Divider, ThemedText } from 'theme'
|
||||
|
||||
import { ThemedText } from '../../theme'
|
||||
import { isAddress, shortenAddress } from '../../utils'
|
||||
import { computeFiatValuePriceImpact } from '../../utils/computeFiatValuePriceImpact'
|
||||
import { ButtonPrimary } from '../Button'
|
||||
import { LightCard } from '../Card'
|
||||
import { AutoColumn } from '../Column'
|
||||
import { FiatValue } from '../CurrencyInputPanel/FiatValue'
|
||||
import CurrencyLogo from '../Logo/CurrencyLogo'
|
||||
import { RowBetween, RowFixed } from '../Row'
|
||||
import TradePrice from '../swap/TradePrice'
|
||||
import { AdvancedSwapDetails } from './AdvancedSwapDetails'
|
||||
import { SwapShowAcceptChanges, TruncatedText } from './styleds'
|
||||
import { SwapModalHeaderAmount } from './SwapModalHeaderAmount'
|
||||
|
||||
const ArrowWrapper = styled.div`
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
position: relative;
|
||||
margin-top: -18px;
|
||||
margin-bottom: -18px;
|
||||
left: calc(50% - 16px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: ${({ theme }) => theme.backgroundSurface};
|
||||
border: 4px solid;
|
||||
border-color: ${({ theme }) => theme.backgroundModule};
|
||||
z-index: 2;
|
||||
const Rule = styled(Divider)`
|
||||
margin: 16px 2px 24px 2px;
|
||||
`
|
||||
|
||||
const formatAnalyticsEventProperties = (
|
||||
trade: InterfaceTrade<Currency, Currency, TradeType>,
|
||||
priceUpdate: number | undefined,
|
||||
response: SwapPriceUpdateUserResponse
|
||||
) => ({
|
||||
chain_id:
|
||||
trade.inputAmount.currency.chainId === trade.outputAmount.currency.chainId
|
||||
? trade.inputAmount.currency.chainId
|
||||
: undefined,
|
||||
response,
|
||||
token_in_symbol: trade.inputAmount.currency.symbol,
|
||||
token_out_symbol: trade.outputAmount.currency.symbol,
|
||||
price_update_basis_points: priceUpdate,
|
||||
})
|
||||
const HeaderContainer = styled(AutoColumn)`
|
||||
margin-top: 16px;
|
||||
`
|
||||
|
||||
export default function SwapModalHeader({
|
||||
trade,
|
||||
shouldLogModalCloseEvent,
|
||||
setShouldLogModalCloseEvent,
|
||||
allowedSlippage,
|
||||
recipient,
|
||||
showAcceptChanges,
|
||||
onAcceptChanges,
|
||||
}: {
|
||||
trade: InterfaceTrade<Currency, Currency, TradeType>
|
||||
shouldLogModalCloseEvent: boolean
|
||||
setShouldLogModalCloseEvent: (shouldLog: boolean) => void
|
||||
trade: InterfaceTrade
|
||||
allowedSlippage: Percent
|
||||
recipient: string | null
|
||||
showAcceptChanges: boolean
|
||||
onAcceptChanges: () => void
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
|
||||
const [lastExecutionPrice, setLastExecutionPrice] = useState(trade.executionPrice)
|
||||
const [priceUpdate, setPriceUpdate] = useState<number | undefined>()
|
||||
|
||||
const fiatValueInput = useUSDPrice(trade.inputAmount)
|
||||
const fiatValueOutput = useUSDPrice(trade.outputAmount)
|
||||
|
||||
useEffect(() => {
|
||||
if (!trade.executionPrice.equalTo(lastExecutionPrice)) {
|
||||
setPriceUpdate(getPriceUpdateBasisPoints(lastExecutionPrice, trade.executionPrice))
|
||||
setLastExecutionPrice(trade.executionPrice)
|
||||
}
|
||||
}, [lastExecutionPrice, setLastExecutionPrice, trade.executionPrice])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldLogModalCloseEvent && showAcceptChanges) {
|
||||
sendAnalyticsEvent(
|
||||
SwapEventName.SWAP_PRICE_UPDATE_ACKNOWLEDGED,
|
||||
formatAnalyticsEventProperties(trade, priceUpdate, SwapPriceUpdateUserResponse.REJECTED)
|
||||
)
|
||||
}
|
||||
setShouldLogModalCloseEvent(false)
|
||||
}, [shouldLogModalCloseEvent, showAcceptChanges, setShouldLogModalCloseEvent, trade, priceUpdate])
|
||||
|
||||
return (
|
||||
<AutoColumn gap="4px" style={{ marginTop: '1rem' }}>
|
||||
<LightCard padding="0.75rem 1rem">
|
||||
<AutoColumn gap="sm">
|
||||
<RowBetween align="center">
|
||||
<RowFixed gap="0px">
|
||||
<TruncatedText
|
||||
fontSize={24}
|
||||
fontWeight={500}
|
||||
color={showAcceptChanges && trade.tradeType === TradeType.EXACT_OUTPUT ? theme.accentAction : ''}
|
||||
data-testid="input-amount"
|
||||
>
|
||||
{trade.inputAmount.toSignificant(6)}
|
||||
</TruncatedText>
|
||||
</RowFixed>
|
||||
<RowFixed gap="0px">
|
||||
<CurrencyLogo currency={trade.inputAmount.currency} size="20px" style={{ marginRight: '12px' }} />
|
||||
<Text fontSize={20} fontWeight={500} data-testid="input-symbol">
|
||||
{trade.inputAmount.currency.symbol}
|
||||
</Text>
|
||||
</RowFixed>
|
||||
</RowBetween>
|
||||
<RowBetween>
|
||||
<FiatValue fiatValue={fiatValueInput} />
|
||||
</RowBetween>
|
||||
</AutoColumn>
|
||||
</LightCard>
|
||||
<ArrowWrapper>
|
||||
<ArrowDown size="16" color={theme.textPrimary} />
|
||||
</ArrowWrapper>
|
||||
<LightCard padding="0.75rem 1rem" style={{ marginBottom: '0.25rem' }}>
|
||||
<AutoColumn gap="sm">
|
||||
<RowBetween align="flex-end">
|
||||
<RowFixed gap="0px">
|
||||
<TruncatedText fontSize={24} fontWeight={500} data-testid="output-amount">
|
||||
{trade.outputAmount.toSignificant(6)}
|
||||
</TruncatedText>
|
||||
</RowFixed>
|
||||
<RowFixed gap="0px">
|
||||
<CurrencyLogo currency={trade.outputAmount.currency} size="20px" style={{ marginRight: '12px' }} />
|
||||
<Text fontSize={20} fontWeight={500} data-testid="output-symbol">
|
||||
{trade.outputAmount.currency.symbol}
|
||||
</Text>
|
||||
</RowFixed>
|
||||
</RowBetween>
|
||||
<RowBetween>
|
||||
<ThemedText.DeprecatedBody fontSize={14} color={theme.textTertiary}>
|
||||
<FiatValue
|
||||
fiatValue={fiatValueOutput}
|
||||
priceImpact={computeFiatValuePriceImpact(fiatValueInput.data, fiatValueOutput.data)}
|
||||
/>
|
||||
</ThemedText.DeprecatedBody>
|
||||
</RowBetween>
|
||||
</AutoColumn>
|
||||
</LightCard>
|
||||
<RowBetween style={{ marginTop: '0.25rem', padding: '0 1rem' }}>
|
||||
<TradePrice price={trade.executionPrice} />
|
||||
</RowBetween>
|
||||
<LightCard style={{ padding: '.75rem', marginTop: '0.5rem' }}>
|
||||
<AdvancedSwapDetails trade={trade} allowedSlippage={allowedSlippage} />
|
||||
</LightCard>
|
||||
{showAcceptChanges ? (
|
||||
<SwapShowAcceptChanges justify="flex-start" gap="0px" data-testid="show-accept-changes">
|
||||
<RowBetween>
|
||||
<RowFixed>
|
||||
<AlertTriangle size={20} style={{ marginRight: '8px', minWidth: 24 }} />
|
||||
<ThemedText.DeprecatedMain color={theme.accentAction}>
|
||||
<Trans>Price Updated</Trans>
|
||||
</ThemedText.DeprecatedMain>
|
||||
</RowFixed>
|
||||
<ButtonPrimary
|
||||
style={{ padding: '.5rem', width: 'fit-content', fontSize: '0.825rem', borderRadius: '12px' }}
|
||||
onClick={onAcceptChanges}
|
||||
>
|
||||
<Trans>Accept</Trans>
|
||||
</ButtonPrimary>
|
||||
</RowBetween>
|
||||
</SwapShowAcceptChanges>
|
||||
) : null}
|
||||
|
||||
<AutoColumn justify="flex-start" gap="sm" style={{ padding: '.75rem 1rem' }}>
|
||||
{trade.tradeType === TradeType.EXACT_INPUT ? (
|
||||
<ThemedText.DeprecatedItalic fontWeight={400} textAlign="left" style={{ width: '100%' }}>
|
||||
<Trans>
|
||||
Output is estimated. You will receive at least{' '}
|
||||
<b>
|
||||
{trade.minimumAmountOut(allowedSlippage).toSignificant(6)} {trade.outputAmount.currency.symbol}
|
||||
</b>{' '}
|
||||
or the transaction will revert.
|
||||
</Trans>
|
||||
</ThemedText.DeprecatedItalic>
|
||||
) : (
|
||||
<ThemedText.DeprecatedItalic fontWeight={400} textAlign="left" style={{ width: '100%' }}>
|
||||
<Trans>
|
||||
Input is estimated. You will sell at most{' '}
|
||||
<b>
|
||||
{trade.maximumAmountIn(allowedSlippage).toSignificant(6)} {trade.inputAmount.currency.symbol}
|
||||
</b>{' '}
|
||||
or the transaction will revert.
|
||||
</Trans>
|
||||
</ThemedText.DeprecatedItalic>
|
||||
)}
|
||||
</AutoColumn>
|
||||
{recipient !== null ? (
|
||||
<AutoColumn justify="flex-start" gap="sm" style={{ padding: '12px 0 0 0px' }} data-testid="recipient-info">
|
||||
<ThemedText.DeprecatedMain>
|
||||
<Trans>
|
||||
Output will be sent to{' '}
|
||||
<b title={recipient}>{isAddress(recipient) ? shortenAddress(recipient) : recipient}</b>
|
||||
</Trans>
|
||||
</ThemedText.DeprecatedMain>
|
||||
</AutoColumn>
|
||||
) : null}
|
||||
</AutoColumn>
|
||||
<HeaderContainer gap="sm">
|
||||
<Column gap="lg">
|
||||
<SwapModalHeaderAmount
|
||||
field={Field.INPUT}
|
||||
label={<Trans>You pay</Trans>}
|
||||
amount={trade.inputAmount}
|
||||
usdAmount={fiatValueInput.data}
|
||||
/>
|
||||
<SwapModalHeaderAmount
|
||||
field={Field.OUTPUT}
|
||||
label={<Trans>You receive</Trans>}
|
||||
amount={trade.outputAmount}
|
||||
usdAmount={fiatValueOutput.data}
|
||||
tooltipText={
|
||||
trade.tradeType === TradeType.EXACT_INPUT ? (
|
||||
<ThemedText.Caption>
|
||||
<Trans>
|
||||
Output is estimated. You will receive at least{' '}
|
||||
<b>
|
||||
{trade.minimumAmountOut(allowedSlippage).toSignificant(6)} {trade.outputAmount.currency.symbol}
|
||||
</b>{' '}
|
||||
or the transaction will revert.
|
||||
</Trans>
|
||||
</ThemedText.Caption>
|
||||
) : (
|
||||
<ThemedText.Caption>
|
||||
<Trans>
|
||||
Input is estimated. You will sell at most{' '}
|
||||
<b>
|
||||
{trade.maximumAmountIn(allowedSlippage).toSignificant(6)} {trade.inputAmount.currency.symbol}
|
||||
</b>{' '}
|
||||
or the transaction will revert.
|
||||
</Trans>
|
||||
</ThemedText.Caption>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Column>
|
||||
<Rule />
|
||||
</HeaderContainer>
|
||||
)
|
||||
}
|
||||
|
||||
68
src/components/swap/SwapModalHeaderAmount.tsx
Normal file
68
src/components/swap/SwapModalHeaderAmount.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { formatCurrencyAmount, formatNumber, NumberType } from '@uniswap/conedison/format'
|
||||
import { Currency, CurrencyAmount } from '@uniswap/sdk-core'
|
||||
import Column from 'components/Column'
|
||||
import CurrencyLogo from 'components/Logo/CurrencyLogo'
|
||||
import Row from 'components/Row'
|
||||
import { MouseoverTooltip } from 'components/Tooltip'
|
||||
import { useWindowSize } from 'hooks/useWindowSize'
|
||||
import { PropsWithChildren, ReactNode } from 'react'
|
||||
import { TextProps } from 'rebass'
|
||||
import { Field } from 'state/swap/actions'
|
||||
import styled from 'styled-components/macro'
|
||||
import { BREAKPOINTS, ThemedText } from 'theme'
|
||||
|
||||
const MAX_AMOUNT_STR_LENGTH = 9
|
||||
|
||||
export const Label = styled(ThemedText.BodySmall)<{ cursor?: string }>`
|
||||
cursor: ${({ cursor }) => cursor};
|
||||
color: ${({ theme }) => theme.textSecondary};
|
||||
margin-right: 8px;
|
||||
`
|
||||
|
||||
const ResponsiveHeadline = ({ children, ...textProps }: PropsWithChildren<TextProps>) => {
|
||||
const { width } = useWindowSize()
|
||||
|
||||
if (width && width < BREAKPOINTS.xs) {
|
||||
return <ThemedText.HeadlineMedium {...textProps}>{children}</ThemedText.HeadlineMedium>
|
||||
}
|
||||
|
||||
return <ThemedText.HeadlineLarge {...textProps}>{children}</ThemedText.HeadlineLarge>
|
||||
}
|
||||
|
||||
interface AmountProps {
|
||||
field: Field
|
||||
tooltipText?: ReactNode
|
||||
label: ReactNode
|
||||
amount: CurrencyAmount<Currency> | undefined
|
||||
usdAmount?: number
|
||||
}
|
||||
|
||||
export function SwapModalHeaderAmount({ tooltipText, label, amount, usdAmount, field }: AmountProps) {
|
||||
let formattedAmount = formatCurrencyAmount(amount, NumberType.TokenTx)
|
||||
if (formattedAmount.length > MAX_AMOUNT_STR_LENGTH) {
|
||||
formattedAmount = formatCurrencyAmount(amount, NumberType.SwapTradeAmount)
|
||||
}
|
||||
|
||||
return (
|
||||
<Row align="center" justify="space-between" gap="md">
|
||||
<Column gap="xs">
|
||||
<ThemedText.BodySecondary>
|
||||
<MouseoverTooltip text={tooltipText} disabled={!tooltipText}>
|
||||
<Label cursor="help">{label}</Label>
|
||||
</MouseoverTooltip>
|
||||
</ThemedText.BodySecondary>
|
||||
<Column gap="xs">
|
||||
<ResponsiveHeadline data-testid={`${field}-amount`}>
|
||||
{formattedAmount} {amount?.currency.symbol}
|
||||
</ResponsiveHeadline>
|
||||
{usdAmount && (
|
||||
<ThemedText.BodySmall color="textTertiary">
|
||||
{formatNumber(usdAmount, NumberType.FiatTokenQuantity)}
|
||||
</ThemedText.BodySmall>
|
||||
)}
|
||||
</Column>
|
||||
</Column>
|
||||
{amount?.currency && <CurrencyLogo currency={amount.currency} size="36px" />}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { Currency, TradeType } from '@uniswap/sdk-core'
|
||||
import { useWeb3React } from '@web3-react/core'
|
||||
import Column from 'components/Column'
|
||||
import { LoadingRows } from 'components/Loader/styled'
|
||||
@@ -12,13 +11,7 @@ import getRoutingDiagramEntries from 'utils/getRoutingDiagramEntries'
|
||||
|
||||
import RouterLabel from './RouterLabel'
|
||||
|
||||
export default function SwapRoute({
|
||||
trade,
|
||||
syncing,
|
||||
}: {
|
||||
trade: InterfaceTrade<Currency, Currency, TradeType>
|
||||
syncing: boolean
|
||||
}) {
|
||||
export default function SwapRoute({ trade, syncing }: { trade: InterfaceTrade; syncing: boolean }) {
|
||||
const { chainId } = useWeb3React()
|
||||
const autoRouterSupported = useAutoRouterSupported()
|
||||
|
||||
@@ -28,9 +21,9 @@ export default function SwapRoute({
|
||||
// TODO(WEB-3303)
|
||||
// Can `trade.gasUseEstimateUSD` be defined when `chainId` is not in `SUPPORTED_GAS_ESTIMATE_CHAIN_IDS`?
|
||||
trade.gasUseEstimateUSD && chainId && SUPPORTED_GAS_ESTIMATE_CHAIN_IDS.includes(chainId)
|
||||
? trade.gasUseEstimateUSD.toFixed(2) === '0.00'
|
||||
? trade.gasUseEstimateUSD === '0.00'
|
||||
? '<$0.01'
|
||||
: '$' + trade.gasUseEstimateUSD.toFixed(2)
|
||||
: '$' + trade.gasUseEstimateUSD
|
||||
: undefined
|
||||
|
||||
return (
|
||||
|
||||
10
src/components/swap/SwapSkeleton.test.tsx
Normal file
10
src/components/swap/SwapSkeleton.test.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { render } from 'test-utils/render'
|
||||
|
||||
import { SwapSkeleton } from './SwapSkeleton'
|
||||
|
||||
describe('SwapSkeleton.tsx', () => {
|
||||
it('renders a skeleton', () => {
|
||||
const { asFragment } = render(<SwapSkeleton />)
|
||||
expect(asFragment()).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
109
src/components/swap/SwapSkeleton.tsx
Normal file
109
src/components/swap/SwapSkeleton.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { ArrowContainer } from 'pages/Swap'
|
||||
import { ArrowDown } from 'react-feather'
|
||||
import styled, { useTheme } from 'styled-components/macro'
|
||||
import { ThemedText } from 'theme'
|
||||
|
||||
import { ArrowWrapper } from './styleds'
|
||||
|
||||
const StyledArrowWrapper = styled(ArrowWrapper)`
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
`
|
||||
|
||||
const LoadingWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 8px;
|
||||
border: ${({ theme }) => `1px solid ${theme.backgroundOutline}`};
|
||||
border-radius: 16px;
|
||||
background-color: ${({ theme }) => theme.backgroundSurface};
|
||||
`
|
||||
|
||||
const Blob = styled.div<{ width?: number; radius?: number }>`
|
||||
background-color: ${({ theme }) => theme.backgroundModule};
|
||||
border-radius: ${({ radius }) => (radius ?? 4) + 'px'};
|
||||
height: 56px;
|
||||
width: ${({ width }) => (width ? width + 'px' : '100%')};
|
||||
`
|
||||
|
||||
const ModuleBlob = styled(Blob)`
|
||||
background-color: ${({ theme }) => theme.backgroundOutline};
|
||||
height: 36px;
|
||||
`
|
||||
|
||||
const TitleColumn = styled.div`
|
||||
padding: 8px;
|
||||
`
|
||||
|
||||
const Row = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`
|
||||
|
||||
const InputColumn = styled.div`
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
background-color: ${({ theme }) => theme.backgroundModule};
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
padding: 48px 12px;
|
||||
`
|
||||
|
||||
const OutputWrapper = styled.div`
|
||||
position: relative;
|
||||
`
|
||||
|
||||
function Title() {
|
||||
return (
|
||||
<TitleColumn>
|
||||
<ThemedText.SubHeader>
|
||||
<Trans>Swap</Trans>
|
||||
</ThemedText.SubHeader>
|
||||
</TitleColumn>
|
||||
)
|
||||
}
|
||||
|
||||
function FloatingInput() {
|
||||
return (
|
||||
<Row>
|
||||
<ModuleBlob width={60} />
|
||||
<ModuleBlob width={100} radius={16} />
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
function FloatingButton() {
|
||||
return <Blob radius={16} />
|
||||
}
|
||||
|
||||
export function SwapSkeleton() {
|
||||
const theme = useTheme()
|
||||
|
||||
return (
|
||||
<LoadingWrapper>
|
||||
<Title />
|
||||
<InputColumn>
|
||||
<FloatingInput />
|
||||
</InputColumn>
|
||||
<OutputWrapper>
|
||||
<StyledArrowWrapper clickable={false}>
|
||||
<ArrowContainer>
|
||||
<ArrowDown size="16" color={theme.textTertiary} />
|
||||
</ArrowContainer>
|
||||
</StyledArrowWrapper>
|
||||
<InputColumn>
|
||||
<FloatingInput />
|
||||
</InputColumn>
|
||||
</OutputWrapper>
|
||||
<FloatingButton />
|
||||
</LoadingWrapper>
|
||||
)
|
||||
}
|
||||
@@ -32,17 +32,17 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.c5 {
|
||||
.c8 {
|
||||
width: -webkit-fit-content;
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.c7 {
|
||||
.c6 {
|
||||
color: #7780A0;
|
||||
}
|
||||
|
||||
.c8 {
|
||||
.c7 {
|
||||
color: #0D111C;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.c6 {
|
||||
.c5 {
|
||||
display: inline-block;
|
||||
height: inherit;
|
||||
}
|
||||
@@ -82,14 +82,54 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
|
||||
class="c2 c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c2 c3 c5"
|
||||
class="c5"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c6 css-zhpkf8"
|
||||
>
|
||||
Network fee
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c7 css-zhpkf8"
|
||||
>
|
||||
~$1.00
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c2 c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c5"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c6 css-zhpkf8"
|
||||
>
|
||||
Price Impact
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c7 css-zhpkf8"
|
||||
>
|
||||
105566.37%
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c2 c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c2 c3 c8"
|
||||
>
|
||||
<div
|
||||
class="c6"
|
||||
class="c5"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c7 css-zhpkf8"
|
||||
class="c6 css-zhpkf8"
|
||||
>
|
||||
Minimum output
|
||||
</div>
|
||||
@@ -97,7 +137,7 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c8 css-zhpkf8"
|
||||
class="c7 css-zhpkf8"
|
||||
>
|
||||
0.00000000000000098 DEF
|
||||
</div>
|
||||
@@ -106,14 +146,14 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
|
||||
class="c2 c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c2 c3 c5"
|
||||
class="c2 c3 c8"
|
||||
>
|
||||
<div
|
||||
class="c6"
|
||||
class="c5"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c7 css-zhpkf8"
|
||||
class="c6 css-zhpkf8"
|
||||
>
|
||||
Expected output
|
||||
</div>
|
||||
@@ -121,7 +161,7 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c8 css-zhpkf8"
|
||||
class="c7 css-zhpkf8"
|
||||
>
|
||||
0.000000000000001 DEF
|
||||
</div>
|
||||
@@ -133,16 +173,16 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
|
||||
class="c2 c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c7 css-zhpkf8"
|
||||
class="c6 css-zhpkf8"
|
||||
>
|
||||
Order routing
|
||||
</div>
|
||||
<div
|
||||
class="c6"
|
||||
class="c5"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c8 css-zhpkf8"
|
||||
class="c7 css-zhpkf8"
|
||||
>
|
||||
Uniswap API
|
||||
</div>
|
||||
|
||||
@@ -42,11 +42,11 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
|
||||
color: #0D111C;
|
||||
}
|
||||
|
||||
.c15 {
|
||||
.c12 {
|
||||
color: #7780A0;
|
||||
}
|
||||
|
||||
.c13 {
|
||||
.c16 {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: #D2D9EE;
|
||||
@@ -66,7 +66,7 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.c12 {
|
||||
.c15 {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
@@ -89,11 +89,20 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.c14 {
|
||||
.c10 {
|
||||
display: inline-block;
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
.c11 {
|
||||
margin-right: 4px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.c11 > * {
|
||||
stroke: #98A1C0;
|
||||
}
|
||||
|
||||
.c8 {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
@@ -135,7 +144,7 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.c10 {
|
||||
.c13 {
|
||||
-webkit-transform: none;
|
||||
-ms-transform: none;
|
||||
transform: none;
|
||||
@@ -144,7 +153,7 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
|
||||
transition: transform 0.1s linear;
|
||||
}
|
||||
|
||||
.c11 {
|
||||
.c14 {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
@@ -184,8 +193,32 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
|
||||
<div
|
||||
class="c2 c3 c6"
|
||||
>
|
||||
<svg
|
||||
<div
|
||||
class="c10"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c7"
|
||||
>
|
||||
<div
|
||||
class="c2 c3 c6"
|
||||
>
|
||||
<svg
|
||||
class="c11"
|
||||
>
|
||||
gas-icon.svg
|
||||
</svg>
|
||||
<div
|
||||
class="c12 css-zhpkf8"
|
||||
>
|
||||
$1.00
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
class="c13"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="#98A1C0"
|
||||
@@ -207,15 +240,55 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c11"
|
||||
class="c14"
|
||||
data-testid="advanced-swap-details"
|
||||
>
|
||||
<div
|
||||
class="c12"
|
||||
class="c15"
|
||||
>
|
||||
<div
|
||||
class="c13"
|
||||
class="c16"
|
||||
/>
|
||||
<div
|
||||
class="c2 c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c10"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c12 css-zhpkf8"
|
||||
>
|
||||
Network fee
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c9 css-zhpkf8"
|
||||
>
|
||||
~$1.00
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c2 c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c10"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c12 css-zhpkf8"
|
||||
>
|
||||
Price Impact
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c9 css-zhpkf8"
|
||||
>
|
||||
105566.37%
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c2 c3 c4"
|
||||
>
|
||||
@@ -223,11 +296,11 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
|
||||
class="c2 c3 c6"
|
||||
>
|
||||
<div
|
||||
class="c14"
|
||||
class="c10"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c15 css-zhpkf8"
|
||||
class="c12 css-zhpkf8"
|
||||
>
|
||||
Minimum output
|
||||
</div>
|
||||
@@ -247,11 +320,11 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
|
||||
class="c2 c3 c6"
|
||||
>
|
||||
<div
|
||||
class="c14"
|
||||
class="c10"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c15 css-zhpkf8"
|
||||
class="c12 css-zhpkf8"
|
||||
>
|
||||
Expected output
|
||||
</div>
|
||||
@@ -265,18 +338,18 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c13"
|
||||
class="c16"
|
||||
/>
|
||||
<div
|
||||
class="c2 c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c15 css-zhpkf8"
|
||||
class="c12 css-zhpkf8"
|
||||
>
|
||||
Order routing
|
||||
</div>
|
||||
<div
|
||||
class="c14"
|
||||
class="c10"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,172 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SwapModalFooter.tsx renders with a disabled button with no account 1`] = `
|
||||
exports[`SwapModalFooter.tsx matches base snapshot, test trade exact input 1`] = `
|
||||
<DocumentFragment>
|
||||
.c3 {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.c4 {
|
||||
width: 100%;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
-webkit-align-items: flex-start;
|
||||
-webkit-box-align: flex-start;
|
||||
-ms-flex-align: flex-start;
|
||||
align-items: flex-start;
|
||||
-webkit-box-pack: justify;
|
||||
-webkit-justify-content: space-between;
|
||||
-ms-flex-pack: justify;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
color: #0D111C;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.c7 {
|
||||
display: inline-block;
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
.c5 {
|
||||
color: #7780A0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.c8 {
|
||||
cursor: help;
|
||||
color: #7780A0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.c6 {
|
||||
text-align: right;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0 c1"
|
||||
>
|
||||
<div
|
||||
class="c2 css-zhpkf8"
|
||||
>
|
||||
<div
|
||||
class="c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c2 c5 css-zhpkf8"
|
||||
>
|
||||
Exchange rate
|
||||
</div>
|
||||
<div
|
||||
class="c2 c6 css-zhpkf8"
|
||||
>
|
||||
1 DEF = 1.00 ABC
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c2 css-zhpkf8"
|
||||
>
|
||||
<div
|
||||
class="c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c7"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c2 c8 css-zhpkf8"
|
||||
cursor="help"
|
||||
>
|
||||
Network fee
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c2 c6 css-zhpkf8"
|
||||
>
|
||||
~$1.00
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c2 css-zhpkf8"
|
||||
>
|
||||
<div
|
||||
class="c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c7"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c2 c8 css-zhpkf8"
|
||||
cursor="help"
|
||||
>
|
||||
Price impact
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c6 css-zhpkf8"
|
||||
>
|
||||
105566.373%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c2 css-zhpkf8"
|
||||
>
|
||||
<div
|
||||
class="c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c7"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c2 c8 css-zhpkf8"
|
||||
cursor="help"
|
||||
>
|
||||
Minimum received
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c2 c6 css-zhpkf8"
|
||||
>
|
||||
0.00000000000000098 DEF
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
.c0 {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -58,6 +223,10 @@ exports[`SwapModalFooter.tsx renders with a disabled button with no account 1`]
|
||||
margin: !important;
|
||||
}
|
||||
|
||||
.c7 {
|
||||
color: #F5F6FC;
|
||||
}
|
||||
|
||||
.c4 {
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
@@ -146,106 +315,24 @@ exports[`SwapModalFooter.tsx renders with a disabled button with no account 1`]
|
||||
}
|
||||
|
||||
.c6 {
|
||||
background-color: rgba(250,43,57,0.1);
|
||||
border-radius: 1rem;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-align-items: center;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
font-size: 0.825rem;
|
||||
width: 100%;
|
||||
padding: 3rem 1.25rem 1rem 1rem;
|
||||
margin-top: -2rem;
|
||||
color: #FA2B39;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.c6 p {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.c7 {
|
||||
background-color: rgba(250,43,57,0.1);
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-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;
|
||||
margin-right: 12px;
|
||||
border-radius: 12px;
|
||||
min-width: 48px;
|
||||
height: 48px;
|
||||
height: 56px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0 c1 c2"
|
||||
>
|
||||
<button
|
||||
class="c3 c4 c5"
|
||||
class="c3 c4 c5 c6"
|
||||
data-testid="confirm-swap-button"
|
||||
disabled=""
|
||||
id="confirm-swap-or-send"
|
||||
style="margin: 10px 0px 0px 0px;"
|
||||
>
|
||||
<div
|
||||
class="css-10ob8xa"
|
||||
class="c7 css-iapcxi"
|
||||
>
|
||||
Confirm Swap
|
||||
Swap
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="c6"
|
||||
>
|
||||
<div
|
||||
class="c7"
|
||||
>
|
||||
<svg
|
||||
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"
|
||||
>
|
||||
<path
|
||||
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="9"
|
||||
y2="13"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12.01"
|
||||
y1="17"
|
||||
y2="17"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p
|
||||
style="word-break: break-word;"
|
||||
>
|
||||
swap error
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact input 1`] = `
|
||||
exports[`SwapModalHeader.tsx matches base snapshot, test trade exact input 1`] = `
|
||||
<DocumentFragment>
|
||||
.c1 {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.c5 {
|
||||
.c3 {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.c6 {
|
||||
.c4 {
|
||||
width: 100%;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
@@ -26,99 +19,30 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
|
||||
-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;
|
||||
}
|
||||
|
||||
.c8 {
|
||||
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;
|
||||
gap: 0px;
|
||||
}
|
||||
|
||||
.c16 {
|
||||
width: 100%;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
-webkit-align-items: flex-end;
|
||||
-webkit-box-align: flex-end;
|
||||
-ms-flex-align: flex-end;
|
||||
align-items: flex-end;
|
||||
-webkit-box-pack: start;
|
||||
-webkit-justify-content: flex-start;
|
||||
-ms-flex-pack: start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.c7 {
|
||||
-webkit-box-pack: justify;
|
||||
-webkit-justify-content: space-between;
|
||||
-ms-flex-pack: justify;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.c9 {
|
||||
width: -webkit-fit-content;
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
margin: -0px;
|
||||
}
|
||||
|
||||
.c22 {
|
||||
width: -webkit-fit-content;
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.c18 {
|
||||
color: #0D111C;
|
||||
}
|
||||
|
||||
.c24 {
|
||||
.c6 {
|
||||
color: #7780A0;
|
||||
}
|
||||
|
||||
.c21 {
|
||||
.c8 {
|
||||
color: #0D111C;
|
||||
}
|
||||
|
||||
.c12 {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
border-width: 0;
|
||||
margin: 0;
|
||||
background-color: #D2D9EE;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.c19 {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.c3 {
|
||||
border: 1px solid #E8ECFB;
|
||||
background-color: #F5F6FC;
|
||||
}
|
||||
|
||||
.c20 {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
@@ -130,63 +54,39 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
|
||||
-webkit-justify-content: flex-start;
|
||||
-ms-flex-pack: start;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.c5 {
|
||||
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: 4px;
|
||||
}
|
||||
|
||||
.c0 {
|
||||
display: grid;
|
||||
grid-auto-rows: auto;
|
||||
grid-row-gap: 4px;
|
||||
}
|
||||
|
||||
.c4 {
|
||||
display: grid;
|
||||
grid-auto-rows: auto;
|
||||
grid-row-gap: 8px;
|
||||
}
|
||||
|
||||
.c25 {
|
||||
display: grid;
|
||||
grid-auto-rows: auto;
|
||||
grid-row-gap: 8px;
|
||||
justify-items: flex-start;
|
||||
}
|
||||
|
||||
.c13 {
|
||||
border-radius: 12px;
|
||||
border-radius: 12px;
|
||||
height: 24px;
|
||||
width: 50%;
|
||||
width: 50%;
|
||||
-webkit-animation: fAQEyV 1.5s infinite;
|
||||
animation: fAQEyV 1.5s infinite;
|
||||
-webkit-animation-fill-mode: both;
|
||||
animation-fill-mode: both;
|
||||
background: linear-gradient( to left,#E8ECFB 25%,#fff 50%,#E8ECFB 75% );
|
||||
will-change: background-position;
|
||||
background-size: 400%;
|
||||
}
|
||||
|
||||
.c23 {
|
||||
display: inline-block;
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
.c14 {
|
||||
border-radius: 4px;
|
||||
width: 4rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.c12 {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
.c11 {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(white 60%,#ffffff00 calc(70% + 1px));
|
||||
box-shadow: 0 0 1px white;
|
||||
}
|
||||
|
||||
.c11 {
|
||||
.c10 {
|
||||
position: relative;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
@@ -194,342 +94,334 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.c17 {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
.c7 {
|
||||
display: inline-block;
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
.c9 {
|
||||
cursor: help;
|
||||
color: #7780A0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.c13 {
|
||||
margin: 16px 2px 24px 2px;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0 c1"
|
||||
>
|
||||
<div
|
||||
class="c2"
|
||||
>
|
||||
<div
|
||||
class="c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c5"
|
||||
>
|
||||
<div
|
||||
class="c6 css-1jljtub"
|
||||
>
|
||||
<div
|
||||
class="c7"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c8 c9 css-zhpkf8"
|
||||
cursor="help"
|
||||
>
|
||||
You pay
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c5"
|
||||
>
|
||||
<div
|
||||
class="c8 css-xdrz3"
|
||||
data-testid="INPUT-amount"
|
||||
>
|
||||
<0.00001 ABC
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c10"
|
||||
>
|
||||
<img
|
||||
alt="ABC logo"
|
||||
class="c11"
|
||||
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000001/logo.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c5"
|
||||
>
|
||||
<div
|
||||
class="c6 css-1jljtub"
|
||||
>
|
||||
<div
|
||||
class="c7"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c8 c9 css-zhpkf8"
|
||||
cursor="help"
|
||||
>
|
||||
You receive
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c5"
|
||||
>
|
||||
<div
|
||||
class="c8 css-xdrz3"
|
||||
data-testid="OUTPUT-amount"
|
||||
>
|
||||
<0.00001 DEF
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c10"
|
||||
>
|
||||
<img
|
||||
alt="DEF logo"
|
||||
class="c11"
|
||||
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000002/logo.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c12 c13"
|
||||
/>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`SwapModalHeader.tsx test trade exact output, no recipient 1`] = `
|
||||
<DocumentFragment>
|
||||
.c3 {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.c4 {
|
||||
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: justify;
|
||||
-webkit-justify-content: space-between;
|
||||
-ms-flex-pack: justify;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.c6 {
|
||||
color: #7780A0;
|
||||
}
|
||||
|
||||
.c8 {
|
||||
color: #0D111C;
|
||||
}
|
||||
|
||||
.c12 {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
border-width: 0;
|
||||
margin: 0;
|
||||
background-color: #D2D9EE;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
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;
|
||||
padding: 0;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-gap: 0.25rem;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.c5 {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-flex-direction: row;
|
||||
-ms-flex-direction: row;
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
-webkit-flex-wrap: wrap;
|
||||
-ms-flex-wrap: wrap;
|
||||
flex-wrap: wrap;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
-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: 4px;
|
||||
}
|
||||
|
||||
.c0 {
|
||||
display: grid;
|
||||
grid-auto-rows: auto;
|
||||
grid-row-gap: 8px;
|
||||
}
|
||||
|
||||
.c11 {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(white 60%,#ffffff00 calc(70% + 1px));
|
||||
box-shadow: 0 0 1px white;
|
||||
}
|
||||
|
||||
.c10 {
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.c15 {
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
position: relative;
|
||||
margin-top: -18px;
|
||||
margin-bottom: -18px;
|
||||
left: calc(50% - 16px);
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-pack: center;
|
||||
-webkit-justify-content: center;
|
||||
-ms-flex-pack: center;
|
||||
justify-content: center;
|
||||
-webkit-align-items: center;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
background-color: #FFFFFF;
|
||||
border: 4px solid;
|
||||
border-color: #F5F6FC;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.c7 {
|
||||
display: inline-block;
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
.c9 {
|
||||
cursor: help;
|
||||
color: #7780A0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.c13 {
|
||||
margin: 16px 2px 24px 2px;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0"
|
||||
style="margin-top: 1rem;"
|
||||
class="c0 c1"
|
||||
>
|
||||
<div
|
||||
class="c1 c2 c3"
|
||||
class="c2"
|
||||
>
|
||||
<div
|
||||
class="c4"
|
||||
class="c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c5 c6 c7"
|
||||
class="c5"
|
||||
>
|
||||
<div
|
||||
class="c5 c8 c9"
|
||||
class="c6 css-1jljtub"
|
||||
>
|
||||
<div
|
||||
class="c10 css-13xjr5l"
|
||||
data-testid="input-amount"
|
||||
>
|
||||
0.000000000000001
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c5 c8 c9"
|
||||
>
|
||||
<div
|
||||
class="c11"
|
||||
style="margin-right: 12px;"
|
||||
>
|
||||
<img
|
||||
alt="ABC logo"
|
||||
class="c12"
|
||||
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000001/logo.png"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="css-10ob8xa"
|
||||
data-testid="input-symbol"
|
||||
>
|
||||
ABC
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c5 c6 c7"
|
||||
>
|
||||
<div
|
||||
class="css-zhpkf8"
|
||||
>
|
||||
<div
|
||||
class="c13 c14"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c15"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
height="16"
|
||||
stroke="#0D111C"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="5"
|
||||
y2="19"
|
||||
/>
|
||||
<polyline
|
||||
points="19 12 12 19 5 12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="c1 c2 c3"
|
||||
style="margin-bottom: 0.25rem;"
|
||||
>
|
||||
<div
|
||||
class="c4"
|
||||
>
|
||||
<div
|
||||
class="c5 c16 c7"
|
||||
>
|
||||
<div
|
||||
class="c5 c8 c9"
|
||||
>
|
||||
<div
|
||||
class="c10 css-1kwqs79"
|
||||
data-testid="output-amount"
|
||||
>
|
||||
0.000000000000001
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c5 c8 c9"
|
||||
>
|
||||
<div
|
||||
class="c11"
|
||||
style="margin-right: 12px;"
|
||||
>
|
||||
<img
|
||||
alt="DEF logo"
|
||||
class="c12"
|
||||
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000002/logo.png"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="css-10ob8xa"
|
||||
data-testid="output-symbol"
|
||||
>
|
||||
DEF
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c5 c6 c7"
|
||||
>
|
||||
<div
|
||||
class="css-zhpkf8"
|
||||
>
|
||||
<div
|
||||
class="css-zhpkf8"
|
||||
>
|
||||
<div
|
||||
class="c13 c14"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c5 c6 c7"
|
||||
style="margin-top: 0.25rem; padding: 0px 1rem;"
|
||||
>
|
||||
<button
|
||||
class="c17"
|
||||
title="1 DEF = 1.00 ABC "
|
||||
>
|
||||
<div
|
||||
class="c18 css-zhpkf8"
|
||||
>
|
||||
1 DEF = 1.00 ABC
|
||||
</div>
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="c5 c19 c3"
|
||||
style="padding: .75rem; margin-top: 0.5rem;"
|
||||
>
|
||||
<div
|
||||
class="c20"
|
||||
>
|
||||
<div
|
||||
class="c21"
|
||||
/>
|
||||
<div
|
||||
class="c5 c6 c7"
|
||||
>
|
||||
<div
|
||||
class="c5 c6 c22"
|
||||
>
|
||||
<div
|
||||
class="c23"
|
||||
class="c7"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c24 css-zhpkf8"
|
||||
class="c8 c9 css-zhpkf8"
|
||||
cursor="help"
|
||||
>
|
||||
Minimum output
|
||||
You pay
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c18 css-zhpkf8"
|
||||
class="c5"
|
||||
>
|
||||
0.00000000000000098 DEF
|
||||
<div
|
||||
class="c8 css-xdrz3"
|
||||
data-testid="INPUT-amount"
|
||||
>
|
||||
<0.00001 ABC
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c5 c6 c7"
|
||||
class="c10"
|
||||
>
|
||||
<img
|
||||
alt="ABC logo"
|
||||
class="c11"
|
||||
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000001/logo.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c3 c4"
|
||||
>
|
||||
<div
|
||||
class="c5"
|
||||
>
|
||||
<div
|
||||
class="c5 c6 c22"
|
||||
class="c6 css-1jljtub"
|
||||
>
|
||||
<div
|
||||
class="c23"
|
||||
class="c7"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c24 css-zhpkf8"
|
||||
class="c8 c9 css-zhpkf8"
|
||||
cursor="help"
|
||||
>
|
||||
Expected output
|
||||
You receive
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c18 css-zhpkf8"
|
||||
class="c5"
|
||||
>
|
||||
0.000000000000001 DEF
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c21"
|
||||
/>
|
||||
<div
|
||||
class="c5 c6 c7"
|
||||
>
|
||||
<div
|
||||
class="c24 css-zhpkf8"
|
||||
>
|
||||
Order routing
|
||||
</div>
|
||||
<div
|
||||
class="c23"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="c18 css-zhpkf8"
|
||||
>
|
||||
Uniswap API
|
||||
</div>
|
||||
<div
|
||||
class="c8 css-xdrz3"
|
||||
data-testid="OUTPUT-amount"
|
||||
>
|
||||
<0.00001 GHI
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c25"
|
||||
style="padding: .75rem 1rem;"
|
||||
>
|
||||
<div
|
||||
class="c24 css-k51stg"
|
||||
style="width: 100%;"
|
||||
>
|
||||
Output is estimated. You will receive at least
|
||||
<b>
|
||||
0.00000000000000098 DEF
|
||||
</b>
|
||||
or the transaction will revert.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c25"
|
||||
data-testid="recipient-info"
|
||||
style="padding: 12px 0px 0px 0px;"
|
||||
>
|
||||
<div
|
||||
class="c24 css-8mokm4"
|
||||
>
|
||||
Output will be sent to
|
||||
<b
|
||||
title="0x0000000000000000000000000000000000000004"
|
||||
<div
|
||||
class="c10"
|
||||
>
|
||||
0x0000...0004
|
||||
</b>
|
||||
<img
|
||||
alt="GHI logo"
|
||||
class="c11"
|
||||
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000003/logo.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c12 c13"
|
||||
/>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
221
src/components/swap/__snapshots__/SwapSkeleton.test.tsx.snap
Normal file
221
src/components/swap/__snapshots__/SwapSkeleton.test.tsx.snap
Normal file
@@ -0,0 +1,221 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SwapSkeleton.tsx renders a skeleton 1`] = `
|
||||
<DocumentFragment>
|
||||
.c2 {
|
||||
color: #0D111C;
|
||||
}
|
||||
|
||||
.c9 {
|
||||
border-radius: 12px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
position: relative;
|
||||
margin-top: -18px;
|
||||
margin-bottom: -18px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
background-color: #E8ECFB;
|
||||
border: 4px solid;
|
||||
border-color: #FFFFFF;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.c11 {
|
||||
display: -webkit-inline-box;
|
||||
display: -webkit-inline-flex;
|
||||
display: -ms-inline-flexbox;
|
||||
display: inline-flex;
|
||||
-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;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.c10 {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
-webkit-transform: translate(-50%,-50%);
|
||||
-ms-transform: translate(-50%,-50%);
|
||||
transform: translate(-50%,-50%);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.c0 {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
-ms-flex-direction: column;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
-webkit-box-pack: justify;
|
||||
-webkit-justify-content: space-between;
|
||||
-ms-flex-pack: justify;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border: 1px solid #D2D9EE;
|
||||
border-radius: 16px;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.c5 {
|
||||
background-color: #F5F6FC;
|
||||
border-radius: 4px;
|
||||
height: 56px;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.c7 {
|
||||
background-color: #F5F6FC;
|
||||
border-radius: 16px;
|
||||
height: 56px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.c12 {
|
||||
background-color: #F5F6FC;
|
||||
border-radius: 16px;
|
||||
height: 56px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.c6 {
|
||||
background-color: #D2D9EE;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.c4 {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-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;
|
||||
}
|
||||
|
||||
.c3 {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-flex-flow: column;
|
||||
-ms-flex-flow: column;
|
||||
flex-flow: column;
|
||||
background-color: #F5F6FC;
|
||||
border-radius: 16px;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
padding: 48px 12px;
|
||||
}
|
||||
|
||||
.c8 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0"
|
||||
>
|
||||
<div
|
||||
class="c1"
|
||||
>
|
||||
<div
|
||||
class="c2 css-rjqmed"
|
||||
>
|
||||
Swap
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c3"
|
||||
>
|
||||
<div
|
||||
class="c4"
|
||||
>
|
||||
<div
|
||||
class="c5 c6"
|
||||
width="60"
|
||||
/>
|
||||
<div
|
||||
class="c7 c6"
|
||||
radius="16"
|
||||
width="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c8"
|
||||
>
|
||||
<div
|
||||
class="c9 c10"
|
||||
>
|
||||
<div
|
||||
class="c11"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
height="16"
|
||||
stroke="#98A1C0"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="5"
|
||||
y2="19"
|
||||
/>
|
||||
<polyline
|
||||
points="19 12 12 19 5 12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c3"
|
||||
>
|
||||
<div
|
||||
class="c4"
|
||||
>
|
||||
<div
|
||||
class="c5 c6"
|
||||
width="60"
|
||||
/>
|
||||
<div
|
||||
class="c7 c6"
|
||||
radius="16"
|
||||
width="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="c12"
|
||||
radius="16"
|
||||
/>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -2,7 +2,6 @@ import { SupportedChainId } from 'constants/chains'
|
||||
import { transparentize } from 'polished'
|
||||
import { ReactNode } from 'react'
|
||||
import { AlertTriangle } from 'react-feather'
|
||||
import { Text } from 'rebass'
|
||||
import styled, { css } from 'styled-components/macro'
|
||||
import { Z_INDEX } from 'theme/zIndex'
|
||||
|
||||
@@ -29,6 +28,7 @@ export const SwapWrapper = styled.main<{ chainId: number | undefined }>`
|
||||
border-radius: 16px;
|
||||
border: 1px solid ${({ theme }) => theme.backgroundOutline};
|
||||
padding: 8px;
|
||||
padding-top: 12px;
|
||||
box-shadow: ${({ chainId }) => !!chainId && chainId === SupportedChainId.BNB && '0px 40px 120px 0px #f0b90b29'};
|
||||
z-index: ${Z_INDEX.default};
|
||||
transition: transform 250ms ease;
|
||||
@@ -63,13 +63,6 @@ export const ArrowWrapper = styled.div<{ clickable: boolean }>`
|
||||
: null}
|
||||
`
|
||||
|
||||
export const TruncatedText = styled(Text)`
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
`
|
||||
|
||||
// styles
|
||||
export const Dots = styled.span`
|
||||
&::after {
|
||||
@@ -135,7 +128,7 @@ export function SwapCallbackError({ error }: { error: ReactNode }) {
|
||||
export const SwapShowAcceptChanges = styled(AutoColumn)`
|
||||
background-color: ${({ theme }) => transparentize(0.95, theme.deprecated_primary3)};
|
||||
color: ${({ theme }) => theme.accentAction};
|
||||
padding: 0.5rem;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
margin-top: 8px;
|
||||
`
|
||||
|
||||
@@ -46,16 +46,16 @@ function useTryActivation() {
|
||||
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
// TODO(WEB-3162): re-add special treatment for already-pending injected errors & move debug to after didUserReject() check
|
||||
console.debug(`Connection failed: ${connection.getName()}`)
|
||||
console.error(error)
|
||||
|
||||
// Gracefully handles errors from the user rejecting a connection attempt
|
||||
if (didUserReject(connection, error)) {
|
||||
setActivationState(IDLE_ACTIVATION_STATE)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO(WEB-3162): re-add special treatment for already-pending injected errors & move debug to after didUserReject() check
|
||||
console.debug(`Connection failed: ${connection.getName()}`)
|
||||
console.error(error)
|
||||
|
||||
// Failed Connection events are logged here, while successful ones are logged by Web3Provider
|
||||
sendAnalyticsEvent(InterfaceEventName.WALLET_CONNECT_TXN_COMPLETED, {
|
||||
result: WalletConnectionResult.FAILED,
|
||||
|
||||
@@ -113,3 +113,7 @@ export const L2_CHAIN_IDS = [
|
||||
] as const
|
||||
|
||||
export type SupportedL2ChainId = typeof L2_CHAIN_IDS[number]
|
||||
|
||||
export function isPolygonChain(chainId: number): chainId is SupportedChainId.POLYGON | SupportedChainId.POLYGON_MUMBAI {
|
||||
return chainId === SupportedChainId.POLYGON || chainId === SupportedChainId.POLYGON_MUMBAI
|
||||
}
|
||||
|
||||
@@ -359,6 +359,14 @@ export const UNI: { [chainId: number]: Token } = {
|
||||
[SupportedChainId.GOERLI]: new Token(SupportedChainId.GOERLI, UNI_ADDRESS[5], 18, 'UNI', 'Uniswap'),
|
||||
}
|
||||
|
||||
export const ARB = new Token(
|
||||
SupportedChainId.ARBITRUM_ONE,
|
||||
'0x912CE59144191C1204E64559FE8253a0e49E6548',
|
||||
18,
|
||||
'ARB',
|
||||
'Arbitrum'
|
||||
)
|
||||
|
||||
export const WRAPPED_NATIVE_CURRENCY: { [chainId: number]: Token | undefined } = {
|
||||
...(WETH9 as Record<SupportedChainId, Token>),
|
||||
[SupportedChainId.OPTIMISM]: new Token(
|
||||
|
||||
@@ -6,5 +6,5 @@ export enum FeatureFlag {
|
||||
permit2 = 'permit2',
|
||||
fiatOnRampButtonOnSwap = 'fiat_on_ramp_button_on_swap_page',
|
||||
detailsV2 = 'details_v2',
|
||||
removeWidget = 'remove_widget_tdp',
|
||||
uraEnabled = 'ura_enabled',
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { BaseVariant, FeatureFlag, useBaseFlag } from '../index'
|
||||
|
||||
export function useWidgetRemovalFlag(): BaseVariant {
|
||||
return useBaseFlag(FeatureFlag.removeWidget, BaseVariant.Control)
|
||||
}
|
||||
|
||||
export function useWidgetRemovalEnabled(): boolean {
|
||||
return useWidgetRemovalFlag() === BaseVariant.Enabled
|
||||
}
|
||||
|
||||
export { BaseVariant as WidgetRemovalVariant }
|
||||
12
src/featureFlags/flags/unifiedRouter.ts
Normal file
12
src/featureFlags/flags/unifiedRouter.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { BaseVariant, FeatureFlag, useBaseFlag } from '../index'
|
||||
|
||||
export function useUnifiedRoutingAPIFlag(): BaseVariant {
|
||||
return useBaseFlag(FeatureFlag.uraEnabled)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/no-unused-modules
|
||||
export function useUnifiedRoutingAPIEnabled(): boolean {
|
||||
return useUnifiedRoutingAPIFlag() === BaseVariant.Enabled
|
||||
}
|
||||
|
||||
export { BaseVariant as UnifiedRouterVariant }
|
||||
@@ -1,6 +1,7 @@
|
||||
import { WRAPPED_NATIVE_CURRENCY } from 'constants/tokens'
|
||||
import { ARB, WRAPPED_NATIVE_CURRENCY } from 'constants/tokens'
|
||||
import gql from 'graphql-tag'
|
||||
import { useMemo } from 'react'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import { Chain, SearchTokensQuery, useSearchTokensQuery } from './__generated__/types-and-hooks'
|
||||
import { chainIdToBackendName } from './util'
|
||||
@@ -41,19 +42,30 @@ gql`
|
||||
}
|
||||
`
|
||||
|
||||
const ARB_ADDRESS = ARB.address.toLowerCase()
|
||||
|
||||
export type SearchToken = NonNullable<NonNullable<SearchTokensQuery['searchTokens']>[number]>
|
||||
|
||||
function isMoreRevelantToken(current: SearchToken, existing: SearchToken | undefined, searchChain: Chain) {
|
||||
if (!existing) return true
|
||||
/* Returns the more relevant cross-chain token based on native status and search chain */
|
||||
function dedupeCrosschainTokens(current: SearchToken, existing: SearchToken | undefined, searchChain: Chain) {
|
||||
if (!existing) return current
|
||||
invariant(current.project?.id === existing.project?.id, 'Cannot dedupe tokens within different tokenProjects')
|
||||
|
||||
// Always priotize natives, and if both tokens are native, prefer native on current chain (i.e. Matic on Polygon over Matic on Mainnet )
|
||||
if (current.standard === 'NATIVE' && (existing.standard !== 'NATIVE' || current.chain === searchChain)) return true
|
||||
// Special case: always prefer Arbitrum ARB over Mainnet ARB
|
||||
if (current.address?.toLowerCase() === ARB_ADDRESS) return current
|
||||
if (existing.address?.toLowerCase() === ARB_ADDRESS) return existing
|
||||
|
||||
// Always prioritize natives, and if both tokens are native, prefer native on current chain (i.e. Matic on Polygon over Matic on Mainnet )
|
||||
if (current.standard === 'NATIVE' && (existing.standard !== 'NATIVE' || current.chain === searchChain)) return current
|
||||
|
||||
// Prefer tokens on the searched chain, otherwise prefer mainnet tokens
|
||||
return current.chain === searchChain || (existing.chain !== searchChain && current.chain === Chain.Ethereum)
|
||||
if (current.chain === searchChain || (existing.chain !== searchChain && current.chain === Chain.Ethereum))
|
||||
return current
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
// Places natives first, wrapped native on current chain next, then sorts by volume
|
||||
/* Places natives first, wrapped native on current chain next, then sorts by volume */
|
||||
function searchTokenSortFunction(
|
||||
searchChain: Chain,
|
||||
wrappedNativeAddress: string | undefined,
|
||||
@@ -87,7 +99,7 @@ export function useSearchTokens(searchQuery: string, chainId: number) {
|
||||
data?.searchTokens?.forEach((token) => {
|
||||
if (token.project?.id) {
|
||||
const existing = selectionMap[token.project.id]
|
||||
if (isMoreRevelantToken(token, existing, searchChain)) selectionMap[token.project.id] = token
|
||||
selectionMap[token.project.id] = dedupeCrosschainTokens(token, existing, searchChain)
|
||||
}
|
||||
})
|
||||
return Object.values(selectionMap).sort(
|
||||
|
||||
@@ -99,6 +99,7 @@ fragment TransactionParts on Transaction {
|
||||
status
|
||||
to
|
||||
from
|
||||
nonce
|
||||
}
|
||||
|
||||
fragment AssetActivityParts on AssetActivity {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { isL2ChainId } from 'utils/chains'
|
||||
|
||||
import { useAllLists, useCombinedActiveList, useCombinedTokenMapFromUrls } from '../state/lists/hooks'
|
||||
import { WrappedTokenInfo } from '../state/lists/wrappedTokenInfo'
|
||||
import { deserializeToken, useUserAddedTokens, useUserAddedTokensOnChain } from '../state/user/hooks'
|
||||
import { deserializeToken, useUserAddedTokens } from '../state/user/hooks'
|
||||
import { useUnsupportedTokenList } from './../state/lists/hooks'
|
||||
|
||||
type Maybe<T> = T | null | undefined
|
||||
@@ -182,20 +182,6 @@ export function useIsUserAddedToken(currency: Currency | undefined | null): bool
|
||||
return !!userAddedTokens.find((token) => currency.equals(token))
|
||||
}
|
||||
|
||||
// Check if currency on specific chain is included in custom list from user storage
|
||||
export function useIsUserAddedTokenOnChain(
|
||||
address: string | undefined | null,
|
||||
chain: number | undefined | null
|
||||
): boolean {
|
||||
const userAddedTokens = useUserAddedTokensOnChain(chain)
|
||||
|
||||
if (!address || !chain) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !!userAddedTokens.find((token) => token.address === address)
|
||||
}
|
||||
|
||||
// undefined if invalid or does not exist
|
||||
// null if loading or null was passed
|
||||
// otherwise returns the token
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useMemo } from 'react'
|
||||
import { InterfaceTrade } from 'state/routing/types'
|
||||
|
||||
import useGasPrice from './useGasPrice'
|
||||
import useStablecoinPrice, { useStablecoinValue } from './useStablecoinPrice'
|
||||
import useStablecoinPrice, { useStablecoinAmountFromFiatValue, useStablecoinValue } from './useStablecoinPrice'
|
||||
|
||||
const DEFAULT_AUTO_SLIPPAGE = new Percent(1, 1000) // .10%
|
||||
|
||||
@@ -72,15 +72,14 @@ const MAX_AUTO_SLIPPAGE_TOLERANCE = new Percent(5, 100) // 5%
|
||||
/**
|
||||
* Returns slippage tolerance based on values from current trade, gas estimates from api, and active network.
|
||||
*/
|
||||
export default function useAutoSlippageTolerance(
|
||||
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined
|
||||
): Percent {
|
||||
export default function useAutoSlippageTolerance(trade?: InterfaceTrade): Percent {
|
||||
const { chainId } = useWeb3React()
|
||||
const onL2 = chainId && L2_CHAIN_IDS.includes(chainId)
|
||||
const outputDollarValue = useStablecoinValue(trade?.outputAmount)
|
||||
const nativeGasPrice = useGasPrice()
|
||||
|
||||
const gasEstimate = guesstimateGas(trade)
|
||||
const gasEstimateUSD = useStablecoinAmountFromFiatValue(trade?.gasUseEstimateUSD) ?? null
|
||||
const nativeCurrency = useNativeCurrency(chainId)
|
||||
const nativeCurrencyPrice = useStablecoinPrice((trade && nativeCurrency) ?? undefined)
|
||||
|
||||
@@ -100,9 +99,7 @@ export default function useAutoSlippageTolerance(
|
||||
// NOTE - dont use gas estimate for L2s yet - need to verify accuracy
|
||||
// if not, use local heuristic
|
||||
const dollarCostToUse =
|
||||
chainId && SUPPORTED_GAS_ESTIMATE_CHAIN_IDS.includes(chainId) && trade?.gasUseEstimateUSD
|
||||
? trade.gasUseEstimateUSD
|
||||
: dollarGasCost
|
||||
chainId && SUPPORTED_GAS_ESTIMATE_CHAIN_IDS.includes(chainId) && gasEstimateUSD ? gasEstimateUSD : dollarGasCost
|
||||
|
||||
if (outputDollarValue && dollarCostToUse) {
|
||||
// optimize for highest possible slippage without getting MEV'd
|
||||
@@ -121,5 +118,15 @@ export default function useAutoSlippageTolerance(
|
||||
}
|
||||
|
||||
return DEFAULT_AUTO_SLIPPAGE
|
||||
}, [trade, onL2, nativeGasPrice, gasEstimate, nativeCurrency, nativeCurrencyPrice, chainId, outputDollarValue])
|
||||
}, [
|
||||
trade,
|
||||
onL2,
|
||||
nativeGasPrice,
|
||||
gasEstimate,
|
||||
nativeCurrency,
|
||||
nativeCurrencyPrice,
|
||||
chainId,
|
||||
gasEstimateUSD,
|
||||
outputDollarValue,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -83,15 +83,6 @@ describe('#useBestV3Trade ExactIn', () => {
|
||||
expect(useClientSideV3Trade).toHaveBeenCalledWith(TradeType.EXACT_INPUT, undefined, undefined)
|
||||
expect(result.current).toEqual({ state: TradeState.VALID, trade: undefined })
|
||||
})
|
||||
|
||||
it('does not compute client side v3 trade if routing api is SYNCING', () => {
|
||||
expectRouterMock(TradeState.SYNCING)
|
||||
|
||||
const { result } = renderHook(() => useBestTrade(TradeType.EXACT_INPUT, USDCAmount, DAI))
|
||||
|
||||
expect(useClientSideV3Trade).toHaveBeenCalledWith(TradeType.EXACT_INPUT, undefined, undefined)
|
||||
expect(result.current).toEqual({ state: TradeState.SYNCING, trade: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('when routing api is in error state', () => {
|
||||
@@ -167,15 +158,6 @@ describe('#useBestV3Trade ExactOut', () => {
|
||||
expect(useClientSideV3Trade).toHaveBeenCalledWith(TradeType.EXACT_OUTPUT, undefined, undefined)
|
||||
expect(result.current).toEqual({ state: TradeState.VALID, trade: undefined })
|
||||
})
|
||||
|
||||
it('does not compute client side v3 trade if routing api is SYNCING', () => {
|
||||
expectRouterMock(TradeState.SYNCING)
|
||||
|
||||
const { result } = renderHook(() => useBestTrade(TradeType.EXACT_OUTPUT, DAIAmount, USDC_MAINNET))
|
||||
|
||||
expect(useClientSideV3Trade).toHaveBeenCalledWith(TradeType.EXACT_OUTPUT, undefined, undefined)
|
||||
expect(result.current).toEqual({ state: TradeState.SYNCING, trade: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('when routing api is in error state', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user