Compare commits

...

34 Commits

Author SHA1 Message Date
UL Service Account
5fe2af4574 ci: add global CODEOWNERS 2023-05-12 17:06:18 +00:00
lavalamp
38af86e1bb ci: More CI pipeline fixes (#6550)
Move git config
2023-05-12 10:03:45 -07:00
Zach Pomerantz
11a8df2a3e build: report test failures via Slack (#6539)
* build: report test failures
2023-05-12 09:19:20 -07:00
Vignesh Mohankumar
3726b6bb47 fix: remove top-level Fragment (#6540) 2023-05-12 11:19:52 -04:00
Vignesh Mohankumar
bfde34c774 refactor: moves retry helper to subfolder (#6531) 2023-05-12 11:08:14 -04:00
Tina
bd8113d018 chore: Goodbye widget :( (#6543)
* goodbye widget :(

* remove unused function

* modify trace tests

* fix lint

* is github down?
2023-05-12 11:05:22 -04:00
lavalamp
14e3ef044e ci: CI pipeline fixes for merge issues (#6529)
* CI fixes

* update text content

* Change PR to force push

* releases environment for prod deploy

* add runs-on

* Rename third step

* Update .github/workflows/1-main-to-staging.yml

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* Update .github/workflows/1-main-to-staging.yml

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* nits

---------

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
2023-05-12 08:30:30 -04:00
Vignesh Mohankumar
4fc4bdcd55 fix: propagates user rejection errors (#6533) 2023-05-11 19:40:02 -04:00
Zach Pomerantz
3733570a89 build: update workflow owners (#6537) 2023-05-11 14:24:52 -07:00
Tina
7a042a5199 chore: Replace widget skeleton with internal swap skeleton (#6524)
* replace widget skeleton with internal version

* remove unuecessary div wrappers

* add snapshot test

* forgot to commit this file earlier

* remove exports

* revert(e2e): waitForAnimations

* convert values to px, refactor a bit to use flex gap instead of padding

* remove unnecessary props

* update snapshot test

---------

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
2023-05-11 16:58:35 -04:00
Jordan Frankfurt
6d5e17a6e7 fix: deduplicate remote and local tx lists (#6438)
* fix: deduplicate remote and local tx lists

* add nonce to local transactions

* removes local transactions that have nonces that are duplicates of remote transactions

* add e2e test

* lint fix

* fix types in test

* use supported chain id for reducer tests

* use getReceipt to remove outdated transactions via existing polling setup

* pr nits from cmcewen

* fix lint

* fix test
2023-05-11 13:16:18 -05:00
Jack Short
8301c5892c chore: removing token type check from routing (#6536) 2023-05-11 13:37:28 -04:00
Vignesh Mohankumar
59b757dda0 fix: disables CMC list temporarily (#6532)
* fix: disables CMC list temporarily

* -
2023-05-10 20:32:43 -04:00
Jack Short
92a6ec67b3 feat: [DetailsV2] different media types on landing page (#6492)
* initial layout assuming media type exists

* fixing embeds

* media type function

* updated snapshot test

* initial shadow impl

* better shadows

* audio player controls

* updating tests

* content not available handlers

* errors on all media types

* removing fullscreen from iframe

* adding snapshot tests

* responding to comments

* text align center

* updating tests
2023-05-10 13:10:25 -04:00
lavalamp
1d6a1e90d7 ci: GH action for deploying to production (#6482)
* GH action for deploying to production

* Remove dependency on tests

* Update .github/workflows/4-deploy-to-prod.yml

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* Update .github/workflows/4-deploy-to-prod.yml

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* Add tag as action step rather than separate target

* Comments

* Update .github/workflows/4-deploy-to-prod.yml

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* Specify branches

* Update run name

---------

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
2023-05-10 12:59:59 -04:00
Jack Short
01aa3291b3 chore: bubbling up pwat spender from route query (#6481)
* chore: bubbling up pwat spender from route query

* refactoring

* adding loading state

* moving to loading allowance

* responding to comments

* linting
2023-05-10 12:45:58 -04:00
Zach Pomerantz
5539ebedf7 build: update stage 2 name (#6526) 2023-05-10 09:38:31 -07:00
Vignesh Mohankumar
e6adddbf55 refactor: return onDemandEntries instead of mediaURLs (#6514)
* refactor: return onDemandEntries instead of mediaURLs

* lint

* fix test
2023-05-10 11:46:28 -04:00
Mike Grabowski
0050b1e165 feat: new routing diagram (#6510)
* chore: initial commit

* chore: add todo to refactor and simplify if conditional in the future

* chore: update layout

* chore: ui tweaks

* chore: add todo

* chore: change todo

* chore: update UI

* chore: tmp

* feat: rename router preference

* chore: update type

* fix error

* fix one more issue

* finish UI work

* chore: remove unecessary components

* chore: update non-snapshot unit tests

* chore: fix lint

* chore: fix ts

* chore: one more time

* fix

* chore: update snapshots

* chore: add jira tickets

* chore: fix mobile popovers

* chore: add analytics event

* chore: fix padding and send event

* chore: fix loading state

* oops

* chore: address review

* chore: comment

* chore
2023-05-10 19:06:12 +04:00
Vignesh Mohankumar
5bf33ab004 revert(e2e): waitForAnimations (#6525)
Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
2023-05-10 10:53:53 -04:00
lavalamp
a4cfeecd8c ci: Workflow for pushing to releases/staging (#6319)
* Initial draft of releases/staging force push

* Rename

* Update PAT, test on PR target

* Remove pull request target, add environment

* Update .github/workflows/push-staging.yml

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* Update .github/workflows/push-staging.yml

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* Update .github/workflows/push-staging.yml

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* comments

* Update step name

---------

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
2023-05-10 08:29:31 -04:00
lavalamp
76cbfdd0b9 ci: GH action for promoting staging -> prod (#6366)
* Add action for promoting to prod

* Update .github/workflows/pr-staging-to-prod.yml

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* Update .github/workflows/pr-staging-to-prod.yml

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* Update .github/workflows/pr-staging-to-prod.yml

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* Update .github/workflows/pr-staging-to-prod.yml

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* Update .github/workflows/pr-staging-to-prod.yml

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* Comments

* Comments

* Remove commit message

* Fix service account reference, add CODEOWNERS

* Update .github/workflows/pr-staging-to-prod.yml

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* Nits

* Fix newline

---------

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
2023-05-10 08:29:20 -04:00
lavalamp
0db9e51e41 ci: Staging deploy GH action definition (#6455)
* Staging deploy GH action definition

* Remove workflow_dispatch

* Update src/utils/env.ts

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* Restrict sentry reporting to staging environment

* Rename and change environment

---------

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
2023-05-10 08:28:59 -04:00
Vignesh Mohankumar
82e7925a17 fix: properly filter AbortErrors (#6519)
* fix: properly filter AbortErrors

* lol
2023-05-09 19:04:41 -04:00
Zach Pomerantz
2150347ba2 build: cache generated files across builds (#6495)
* build: cache generated files across builds

* docs: lingui pkg-up comment

* docs: explain clean extraction
2023-05-09 15:49:53 -07:00
Vignesh Mohankumar
2f80646ddd fix: add chunkResponseStatus tag (#6509)
* fix: add chunkResponseStatus tag

* add-tests

* fix tests

* tests

* description

* comments

* comment

* move

* comment

* lint
2023-05-09 17:44:58 -04:00
matteen
55eea6a724 chore(deps): bump @uniswap/token-lists (#6520)
* chore(deps): bump @uniswap/token-lists

* run yarn deduplicate
2023-05-09 17:34:05 -04:00
matteen
709a70652f feat: add tokenlist validation (#6504)
* feat: add tokenlist validation

* use alternative for spread operator

* maintain tokenlists version and use original ajv version

* bump ajv

* Revert "bump ajv"

This reverts commit b9d2dd61c6.

* rename vars in validator

* update gitignore

* nit fixes

* test

* add ^ back

* remove ^

* removed and readded ajv

* try require.resolve

* Revert "try require.resolve"

This reverts commit 62f58bcb7f.

* bump eslint-config

* yarn lock merge conflict

* bring back spread operator

* remove redundant lint ignore
2023-05-09 16:34:15 -04:00
Jack Short
5a7a041f12 feat: [DetailsV2] background for nft details (#6496)
* feat: background for nft details

* fixing top spacing

* adding new states for zindex
2023-05-09 16:08:28 -04:00
cartcrom
b60d98fc17 fix: loading spinner console bug (#6518)
fix: update svg path
2023-05-09 14:59:26 -04:00
Zach Pomerantz
38d9ab67eb test(e2e): token details and test cleanup (#6516)
* test(e2e): split e2e commands/setup

* test(e2e): consolidate tests

* test(e2e): simplify

* test(e2e): mv token details tests to appropriate spec

* test(e2e): rm obsolete constant

* test(e2e): comments

* test(e2e): do not wait for animations
2023-05-09 10:33:55 -07:00
Jordan Frankfurt
5e6ef1575b test: skip flakey e2e test (#6517) 2023-05-09 11:07:13 -05:00
Vignesh Mohankumar
4a015e9d0d fix: ensure event.request is defined in trace (#6498)
* fix: ensure event.request is defined in trace

* add check for empty event
2023-05-09 11:44:07 -04:00
Mike Grabowski
c383a0a0a2 feat: new Settings menu for Swap (#6480)
* feat: initial commit

* remove extra check

* chore: divider fix

* feat: switch from boolean to routerPreference enum

* chore: align name

* chore: fix two errors

* chore: clean up

* chore: entire radio button clickable

* chore: remove unused toggle component

* Revert "chore: remove unused toggle component"

This reverts commit 42858a02b5.

* feat: rewrite slippage

* feat: Slippage

* chore: tbd tomorrow

* Update src/state/user/reducer.ts

Co-authored-by: Tina <59578595+tinaszheng@users.noreply.github.com>

* feat: replace auto with Slippage enum

* chore: add todo for deadline

* chore: cleanup

* feat: improve autoslippage

* chore: replace price with auto

* chore: fix lint

* test: add coverage for Expand

* chore: fix tests

* chore: review feedback part 1

* chore: rework warning

* chore: add jira tickets

* feat: add tests for useUserSlippageTolerance

* chore: add some more

* chore: one more

* add tests for slippage

* chore: add unit tests for transactionsettings

* remove

* revet changes to improve coverage

* chore: update to figma caption

* chore

* chore

* chore: update wording

* Update src/components/Expand/index.tsx

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* fix: issue with new value, update confusing migration comment

* chore: remove opacity animation temporarily

* chore: update snapshot test

* chore: fix e2e + update comment

* chore: fix tests

* chore: fix tests

---------

Co-authored-by: Tina <59578595+tinaszheng@users.noreply.github.com>
Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
2023-05-09 10:33:25 -04:00
153 changed files with 4141 additions and 3644 deletions

2
.github/CODEOWNERS vendored
View File

@@ -1 +1 @@
@uniswap/web-reviewers
@uniswap/web-admins

48
.github/actions/report/action.yml vendored Normal file
View 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

View File

@@ -10,11 +10,12 @@ runs:
with:
node-version: 14
registry-url: https://registry.npmjs.org
cache: 'yarn'
# node_modules/.cache is intentionally omitted, as this is used for build tool caches.
- uses: actions/cache@v3
id: install-cache
with:
# node_modules/.cache is intentionally omitted, as this is used for build tool caches.
path: |
node_modules
!node_modules/.cache
@@ -22,3 +23,55 @@ runs:
- if: steps.install-cache.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile --ignore-scripts
shell: bash
# Validators compile quickly, so caching can be omitted.
- run: yarn ajv
shell: bash
# Contracts are compiled from source. If source hasn't changed, the contracts do not need to be re-compiled.
- uses: actions/cache@v3
id: contracts-cache
with:
path: |
src/abis/types
src/types/v3
key: ${{ runner.os }}-contracts-${{ hashFiles('src/abis/**/*.json', 'node_modules/@uniswap/**/artifacts/contracts/**/*.json') }}
- if: steps.contracts-cache.outputs.cache-hit != 'true'
run: yarn contracts
shell: bash
# GraphQL is generated from schema. The schema is always fetched, but if unchanged, graphql does not need to be re-generated.
- run: yarn graphql:fetch
shell: bash
- uses: actions/cache@v3
id: graphql-cache
with:
path: src/graphql/**/__generated__
key: ${{ runner.os }}-graphql-${{ hashFiles('src/graphql/**/schema.graphql') }}
- if: steps.graphql-cache.outputs.cache-hit != 'true'
run: yarn graphql:generate
shell: bash
# Messages are extracted from source.
# A record of source file content hashes is maintained in node_modules/.cache/lingui by a custom extractor.
# Messages are always extracted, but extraction may rely on the custom extractor's loaded cache.
- uses: actions/cache@v3
id: i18n-extract-cache
with:
path: |
src/locales/en-US.po
node_modules/.cache
key: ${{ runner.os }}-i18n-extract-${{ github.run_id }}
restore-keys: ${{ runner.os }}-i18n-extract-
- run: yarn i18n:extract
shell: bash
# Translations are compiled from messages. If messages haven't changed, the translations do not need to be re-compiled.
- uses: actions/cache@v3
id: i18n-compile-cache
with:
path: src/locales/*.js
key: ${{ runner.os }}-i18n-compile-${{ hashFiles('src/locales/*.po') }}
- if: steps.i18n-compile-cache.outputs.cache-hit !='true'
run: yarn i18n:compile
shell: bash

View File

@@ -8,6 +8,5 @@ updates:
allow:
- dependency-name: '@uniswap/default-token-list'
- dependency-name: '@uniswap/token-lists'
- dependency-name: '@uniswap/widgets'
reviewers:
- 'Uniswap/dependabot-reviewers'

32
.github/workflows/1-main-to-staging.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: 1 | Push main -> staging
# This CI job is responsible for pushing the current contents of the `main` branch to the
# `releases/staging` branch, which will in turn kick off a deploy to the staging environment.
on:
workflow_dispatch:
# https://stackoverflow.com/questions/57921401/push-to-origin-from-github-action
jobs:
push-staging:
name: 'Push to staging branch'
runs-on: ubuntu-latest
environment:
name: push/staging
steps:
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
with:
token: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
ref: main
- name: Git config
run: |
git config user.name "UL Service Account"
git config user.email "hello-happy-puppy@users.noreply.github.com"
- name: Add CODEOWNERS file
run: |
echo "@uniswap/web-admins" > CODEOWNERS
git add CODEOWNERS
git commit -m "ci: add global CODEOWNERS"
- name: Git push
run: |
git push origin main:releases/staging --force

View File

@@ -0,0 +1,41 @@
name: 2 | Deploy staging
on:
push:
branches:
- 'releases/staging'
jobs:
deploy-to-staging:
runs-on: ubuntu-latest
environment:
name: deploy/staging
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: yarn prepare
- run: yarn build
env:
REACT_APP_STAGING: 1
- name: Setup node@16 (required by Cloudflare Pages)
uses: actions/setup-node@v3
with:
node-version: 16
- name: Update Cloudflare Pages deployment
uses: cloudflare/pages-action@364c7ca09a4b57837c5967871d64a2c31adb8c0d
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: interface-staging
directory: build
githubToken: ${{ secrets.GITHUB_TOKEN }}
- name: Upload source maps to Sentry
uses: getsentry/action-release@bd5f874fcda966ba48139b0140fb3ec0cb3aabdd
continue-on-error: true
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
environment: staging
sourcemaps: './build/static/js'
url_prefix: '~/static/js'

27
.github/workflows/3-staging-to-prod.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: 3 | Push staging -> prod
# This CI job is responsible for force pushing the content of releases/staging to releases/prod. It
# is restricted to web-reviewers through virtue of the GitHub environment protection rules for the
# prod environment.
on:
workflow_dispatch:
jobs:
push-prod:
name: 'Push to prod branch'
runs-on: ubuntu-latest
environment:
name: push/prod
steps:
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
with:
token: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
ref: releases/staging
- name: Git config
run: |
git config user.name "UL Service Account"
git config user.email "hello-happy-puppy@users.noreply.github.com"
- name: Git push
run: |
git push origin releases/staging:releases/prod --force

91
.github/workflows/4-deploy-to-prod.yml vendored Normal file
View File

@@ -0,0 +1,91 @@
name: 4 | Deploy prod
on:
push:
branches:
- 'releases/prod'
jobs:
deploy-to-prod:
runs-on: ubuntu-latest
environment:
name: deploy/prod
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: yarn prepare
- run: yarn build
- name: Bump and tag
id: github-tag-action
uses: mathieudutour/github-tag-action@v6.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
release_branches: releases/prod
default_bump: patch
- name: Pin to IPFS
id: pinata
uses: anantaramdas/ipfs-pinata-deploy-action@39bbda1ce1fe24c69c6f57861b8038278d53688d
with:
pin-name: Uniswap ${{ steps.github-tag-action.outputs.new_tag }}
path: './build'
pinata-api-key: ${{ secrets.PINATA_API_KEY }}
pinata-secret-api-key: ${{ secrets.PINATA_API_SECRET_KEY }}
- name: Convert CIDv0 to CIDv1
id: convert-cidv0
uses: uniswap/convert-cidv0-cidv1@v1.0.0
with:
cidv0: ${{ steps.pinata.outputs.hash }}
- name: Release
uses: actions/create-release@v1.1.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.github-tag-action.outputs.new_tag }}
release_name: Release ${{ steps.github-tag-action.outputs.new_tag }}
body: |
IPFS hash of the deployment:
- CIDv0: `${{ steps.pinata.outputs.hash }}`
- CIDv1: `${{ steps.convert-cidv0.outputs.cidv1 }}`
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
You can also access the Uniswap Interface from an IPFS gateway.
**BEWARE**: The Uniswap interface uses [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) to remember your settings, such as which tokens you have imported.
**You should always use an IPFS gateway that enforces origin separation**, or our hosted deployment of the latest release at [app.uniswap.org](https://app.uniswap.org).
Your Uniswap settings are never remembered across different URLs.
IPFS gateways:
- https://${{ steps.convert-cidv0.outputs.cidv1 }}.ipfs.dweb.link/
- https://${{ steps.convert-cidv0.outputs.cidv1 }}.ipfs.cf-ipfs.com/
- [ipfs://${{ steps.pinata.outputs.hash }}/](ipfs://${{ steps.pinata.outputs.hash }}/)
${{ steps.github-tag-action.outputs.changelog }}
- name: Setup node@16 (required by Cloudflare Pages)
uses: actions/setup-node@v3
with:
node-version: 16
- name: Update Cloudflare Pages deployment
uses: cloudflare/pages-action@364c7ca09a4b57837c5967871d64a2c31adb8c0d
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
directory: build
githubToken: ${{ secrets.GITHUB_TOKEN }}
branch: main
- name: Upload source maps to Sentry
uses: getsentry/action-release@4744f6a65149f441c5f396d5b0877307c0db52c7
continue-on-error: true
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
environment: production
sourcemaps: './build/static/js'
url_prefix: '~/static/js'

View File

@@ -47,7 +47,6 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: yarn prepare
- run: yarn build
- name: Pin to IPFS

View File

@@ -2,6 +2,7 @@ name: Test
# Many build steps have their own caches, so each job has its own cache to improve subsequent build times.
# Build tools are configured to cache cache to node_modules/.cache, so this is cached independently of node_modules.
# Caches are saved every run (by keying on github.run_id), and the most recent available cache is loaded.
# See https://jongleberry.medium.com/speed-up-your-ci-and-dx-with-node-modules-cache-ac8df82b7bb0.
on:
@@ -25,6 +26,11 @@ jobs:
key: ${{ runner.os }}-eslint-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
restore-keys: ${{ runner.os }}-eslint-${{ hashFiles('**/yarn.lock') }}-
- run: yarn lint
- if: failure() && github.ref_name == 'main'
uses: ./.github/actions/report
with:
name: Lint
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
typecheck:
runs-on: ubuntu-latest
@@ -37,8 +43,12 @@ jobs:
path: node_modules/.cache
key: ${{ runner.os }}-tsc-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
restore-keys: ${{ runner.os }}-tsc-${{ hashFiles('**/yarn.lock') }}-
- run: yarn prepare
- run: yarn typecheck
- if: failure() && github.ref_name == 'main'
uses: ./.github/actions/report
with:
name: Typecheck
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
deps-tests:
runs-on: ubuntu-latest
@@ -46,6 +56,11 @@ jobs:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: yarn yarn-deduplicate --strategy=highest --list --fail
- if: failure() && github.ref_name == 'main'
uses: ./.github/actions/report
with:
name: Dependency checks
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
unit-tests:
runs-on: ubuntu-latest
@@ -58,7 +73,6 @@ jobs:
path: node_modules/.cache
key: ${{ runner.os }}-jest-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
restore-keys: ${{ runner.os }}-jest-${{ hashFiles('**/yarn.lock') }}-
- run: yarn prepare
- run: yarn test --coverage --maxWorkers=100%
- uses: codecov/codecov-action@v3
with:
@@ -66,6 +80,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_TEST_REPORTER_WEBHOOK }}
build-e2e:
runs-on: ubuntu-latest
@@ -78,7 +97,6 @@ jobs:
path: node_modules/.cache
key: ${{ runner.os }}-build-e2e-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
restore-keys: ${{ runner.os }}-build-e2e-${{ hashFiles('**/yarn.lock') }}-
- run: yarn prepare
- run: yarn build:e2e
env:
NODE_OPTIONS: "--max_old_space_size=4096"
@@ -140,9 +158,15 @@ jobs:
# Included as a single job to check for cypress-test-matrix success, as a matrix cannot be checked.
cypress-tests:
if: ${{ always() }}
if: always()
needs: [cypress-test-matrix]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- if: needs.cypress-test-matrix.result != 'success'
run: exit 1
- if: failure() && github.ref_name == 'main'
uses: ./.github/actions/report
with:
name: Cypress tests
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}

6
.gitignore vendored
View File

@@ -8,8 +8,10 @@
/src/locales/**/en-US.po
/src/locales/**/pseudo.po
# generated graphql types
/src/graphql/**/__generated__
# generated files
/src/**/__generated__
# schema
schema.graphql
# dependencies

1
CODEOWNERS Normal file
View File

@@ -0,0 +1 @@
@uniswap/web-admins

View File

@@ -0,0 +1,133 @@
import { getTestSelector } from '../../utils'
describe('mini-portfolio activity history', () => {
afterEach(() => {
cy.intercept(
{
method: 'POST',
url: 'https://beta.api.uniswap.org/v1/graphql',
},
// Pass an empty object to allow the original behavior
{}
).as('restoreOriginalBehavior')
})
it('should deduplicate activity history by nonce', () => {
cy.visit('/swap', { ethereum: 'hardhat' })
.hardhat({ automine: false })
.then((hardhat) => hardhat.wallet.getTransactionCount())
.then((currentNonce) => {
const nextNonce = currentNonce + 1
// Mock graphql response to include a specific nonce.
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: currentNonce,
__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: nextNonce,
__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('graphqlMock')
// Input swap info.
cy.get('#swap-currency-input .token-amount-input').clear().type('1')
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.contains('USDC').click()
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
// Set slippage to a high value.
cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.get(getTestSelector('max-slippage-settings')).click()
cy.get(getTestSelector('slippage-input')).clear().type('5')
cy.get('body').click('topRight')
cy.get(getTestSelector('slippage-input')).should('not.exist')
// Click swap button.
cy.contains('1 USDC = ').should('exist')
cy.get('#swap-button').should('not.be', 'disabled').click()
cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
// Check activity history tab.
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('mini-portfolio-nav-activity')).click()
// Assert that the local pending transaction is replaced by a remote transaction with the same nonce.
cy.contains('Swapping').should('not.exist')
})
})
})

View File

@@ -2,62 +2,22 @@ import { BigNumber } from '@ethersproject/bignumber'
import { parseEther } from '@ethersproject/units'
import { SupportedChainId, WETH9 } from '@uniswap/sdk-core'
import { UNI as UNI_MAINNET, USDC_MAINNET } from '../../src/constants/tokens'
import { FeatureFlag } from '../../src/featureFlags/flags/featureFlags'
import { WETH_GOERLI } from '../fixtures/constants'
import { UNI, USDC_MAINNET } from '../../src/constants/tokens'
import { getTestSelector } from '../utils'
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
describe('Swap', () => {
const verifyAmount = (field: 'input' | 'output', amountText: string | null) => {
if (amountText === null) {
cy.get(`#swap-currency-${field} .token-amount-input`).should('not.have.value')
} else {
cy.get(`#swap-currency-${field} .token-amount-input`).should('have.value', amountText)
}
}
const verifyToken = (field: 'input' | 'output', tokenSymbol: string | null) => {
if (tokenSymbol === null) {
cy.get(`#swap-currency-${field} .token-symbol-container`).should('contain.text', 'Select token')
} else {
cy.get(`#swap-currency-${field} .token-symbol-container`).should('contain.text', tokenSymbol)
}
}
const selectToken = (tokenSymbol: string, field: 'input' | 'output') => {
// open token selector...
cy.get(`#swap-currency-${field} .open-currency-select-button`).click()
// select token...
cy.contains(tokenSymbol).click()
cy.get('body')
.then(($body) => {
if ($body.find(getTestSelector('TokenSafetyWrapper')).length) {
return 'I understand'
}
return 'no-op' // Don't click on anything, a no-op
})
.then((content) => {
if (content !== 'no-op') {
cy.contains(content).click()
}
})
// token selector should close...
cy.contains('Search name or paste address').should('not.exist')
}
describe('Swap on main page', () => {
before(() => {
cy.visit('/swap', { ethereum: 'hardhat' })
})
it('starts with ETH selected by default', () => {
verifyAmount('input', '')
verifyToken('input', 'ETH')
verifyAmount('output', null)
verifyToken('output', null)
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', () => {
@@ -97,6 +57,7 @@ describe('Swap', () => {
// 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')
@@ -133,39 +94,24 @@ describe('Swap', () => {
})
})
it('should have the correct default input from URL params ', () => {
cy.visit(`/swap?inputCurrency=${WETH_GOERLI}`)
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')
verifyToken('input', 'WETH')
verifyToken('output', null)
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')
selectToken('Ether', 'output')
cy.get(getTestSelector('swap-currency-button')).first().click()
verifyToken('input', 'ETH')
verifyToken('output', 'WETH')
})
it('should have the correct default output from URL params ', () => {
cy.visit(`/swap?outputCurrency=${WETH_GOERLI}`)
verifyToken('input', null)
verifyToken('output', 'WETH')
cy.get(getTestSelector('swap-currency-button')).first().click()
verifyToken('input', 'WETH')
verifyToken('output', null)
selectToken('Ether', 'output')
cy.get(getTestSelector('swap-currency-button')).first().click()
verifyToken('input', 'ETH')
verifyToken('output', 'WETH')
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')
selectToken('WETH', 'output')
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')
})
@@ -174,10 +120,9 @@ describe('Swap', () => {
cy.visit('/swap')
cy.contains('Settings').should('not.exist')
cy.get(getTestSelector('swap-settings-button')).click()
cy.contains('Slippage tolerance').should('exist')
cy.contains('Max slippage').should('exist')
cy.contains('Transaction deadline').should('exist')
cy.contains('Auto Router API').should('exist')
cy.contains('Expert Mode').should('exist')
cy.get(getTestSelector('swap-settings-button')).click()
cy.contains('Settings').should('not.exist')
})
@@ -333,7 +278,7 @@ describe('Swap', () => {
cy.visit('/swap', { ethereum: 'hardhat' })
.hardhat()
.then((hardhat) => {
cy.stub(hardhat.wallet, 'sendTransaction').rejects(new Error('user cancelled'))
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)
@@ -347,83 +292,20 @@ describe('Swap', () => {
cy.contains('Transaction rejected').should('not.exist')
})
})
describe('Swap on Token Detail Page', () => {
beforeEach(() => {
// On mobile widths, we just link back to /swap instead of rendering the swap component.
cy.viewport(1200, 800)
cy.visit(`/tokens/ethereum/${UNI_MAINNET[1].address}`, {
ethereum: 'hardhat',
featureFlags: [FeatureFlag.removeWidget],
}).then(() => {
cy.wait('@eth_blockNumber')
cy.scrollTo('top')
})
})
it('should have the expected output for a tokens detail page', () => {
verifyAmount('input', '')
verifyToken('input', null)
verifyAmount('output', null)
verifyToken('output', 'UNI')
})
it('should automatically navigate to the new TDP', () => {
selectToken('WETH', 'output')
cy.url().should('include', `${WETH9[1].address}`)
cy.url().should('not.include', `${UNI_MAINNET[1].address}`)
})
it('should not share swap state with the main swap page', () => {
verifyToken('output', 'UNI')
selectToken('WETH', 'input')
cy.visit('/swap', { featureFlags: [FeatureFlag.removeWidget] })
cy.contains('UNI').should('not.exist')
cy.contains('WETH').should('not.exist')
})
it('can enter an amount into input', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('0.001').should('have.value', '0.001')
})
it('zero swap amount', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('0.0').should('have.value', '0.0')
})
it('invalid swap amount', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('\\').should('have.value', '')
})
it('can enter an amount into output', () => {
cy.get('#swap-currency-output .token-amount-input').clear().type('0.001').should('have.value', '0.001')
})
it('zero output amount', () => {
cy.get('#swap-currency-output .token-amount-input').clear().type('0.0').should('have.value', '0.0')
})
it('should show a L2 token even if the user is connected to a different network', () => {
cy.visit('/tokens', { ethereum: 'hardhat', featureFlags: [FeatureFlag.removeWidget] })
cy.get(getTestSelector('tokens-network-filter-selected')).click()
cy.get(getTestSelector('tokens-network-filter-option-arbitrum')).click()
cy.get(getTestSelector('tokens-network-filter-selected')).should('contain', 'Arbitrum')
cy.get(getTestSelector('token-table-row-ARB')).click()
verifyToken('output', 'ARB')
cy.contains('Connect to Arbitrum').should('exist')
})
})
it('should render an error for slippage failure', () => {
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')
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')
@@ -431,16 +313,14 @@ describe('Swap', () => {
// Open the currency select modal.
cy.get('#swap-currency-output .open-currency-select-button').click()
// Wait for the currency list to load
cy.contains('1inch').should('exist')
// Select UNI as output token
cy.get(getTestSelector('token-search-input')).clear().type('Uniswap')
cy.get(getTestSelector('currency-list-wrapper'))
.contains(/^Uniswap$/)
.first()
.should('exist')
.click()
// 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

View File

@@ -1,5 +1,10 @@
import { SupportedChainId, WETH9 } from '@uniswap/sdk-core'
import { UNI } from '../../src/constants/tokens'
import { getTestSelector } from '../utils'
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
const UNI_ADDRESS = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984'
describe('Token details', () => {
@@ -83,4 +88,70 @@ describe('Token details', () => {
.should('include.text', 'Warning')
.and('include.text', "This token isn't traded on leading U.S. centralized exchanges")
})
describe('swapping', () => {
beforeEach(() => {
// On mobile widths, we just link back to /swap instead of rendering the swap component.
cy.viewport(1200, 800)
cy.visit(`/tokens/ethereum/${UNI_MAINNET.address}`, {
ethereum: 'hardhat',
}).then(() => {
cy.wait('@eth_blockNumber')
cy.scrollTo('top')
})
})
it('should have the expected output for a tokens detail page', () => {
cy.get(`#swap-currency-input .token-amount-input`).should('have.value', '')
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'Select token')
cy.get(`#swap-currency-output .token-amount-input`).should('not.have.value')
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
})
it('should automatically navigate to the new TDP', () => {
cy.get(`#swap-currency-output .open-currency-select-button`).click()
cy.contains('WETH').click()
cy.url().should('include', `${WETH9[1].address}`)
cy.url().should('not.include', `${UNI_MAINNET.address}`)
})
it.only('should not share swap state with the main swap page', () => {
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
cy.get(`#swap-currency-input .open-currency-select-button`).click()
cy.contains('WETH').click()
cy.visit('/swap')
cy.contains('UNI').should('not.exist')
cy.contains('WETH').should('not.exist')
})
it('can enter an amount into input', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('0.001').should('have.value', '0.001')
})
it('zero swap amount', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('0.0').should('have.value', '0.0')
})
it('invalid swap amount', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('\\').should('have.value', '')
})
it('can enter an amount into output', () => {
cy.get('#swap-currency-output .token-amount-input').clear().type('0.001').should('have.value', '0.001')
})
it('zero output amount', () => {
cy.get('#swap-currency-output .token-amount-input').clear().type('0.0').should('have.value', '0.0')
})
it('should show a L2 token even if the user is connected to a different network', () => {
cy.visit('/tokens', { ethereum: 'hardhat' })
cy.get(getTestSelector('tokens-network-filter-selected')).click()
cy.get(getTestSelector('tokens-network-filter-option-arbitrum')).click()
cy.get(getTestSelector('tokens-network-filter-selected')).should('contain', 'Arbitrum')
cy.get(getTestSelector('token-table-row-ARB')).click()
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'ARB')
cy.contains('Connect to Arbitrum').should('exist')
})
})
})

View File

@@ -1 +0,0 @@
export const WETH_GOERLI = '0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6'

View File

@@ -0,0 +1,80 @@
import 'cypress-hardhat/lib/browser'
import { Eip1193Bridge } from '@ethersproject/experimental/lib/eip1193-bridge'
import { FeatureFlag } from '../../src/featureFlags/flags/featureFlags'
import { UserState } from '../../src/state/user/reducer'
import { CONNECTED_WALLET_USER_STATE } from '../utils/user-state'
import { injected } from './ethereum'
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface ApplicationWindow {
ethereum: Eip1193Bridge
}
interface VisitOptions {
serviceWorker?: true
featureFlags?: Array<FeatureFlag>
/**
* The mock ethereum provider to inject into the page.
* @default 'goerli'
*/
// TODO(INFRA-175): Migrate all usage of 'goerli' to 'hardhat'.
ethereum?: 'goerli' | 'hardhat'
/**
* Initial user state.
* @default {@type import('../utils/user-state').CONNECTED_WALLET_USER_STATE}
*/
userState?: Partial<UserState>
}
}
}
// sets up the injected provider to be a mock ethereum provider with the given mnemonic/index
// eslint-disable-next-line no-undef
Cypress.Commands.overwrite(
'visit',
(original, url: string | Partial<Cypress.VisitOptions>, options?: Partial<Cypress.VisitOptions>) => {
if (typeof url !== 'string') throw new Error('Invalid arguments. The first argument to cy.visit must be the path.')
// Add a hash in the URL if it is not present (to use hash-based routing correctly with queryParams).
let hashUrl = url.startsWith('/') && url.length > 2 && !url.startsWith('/#') ? `/#${url}` : url
if (options?.ethereum === 'goerli') hashUrl += `${url.includes('?') ? '&' : '?'}chain=goerli`
return cy
.intercept('/service-worker.js', options?.serviceWorker ? undefined : { statusCode: 404 })
.provider()
.then((provider) =>
original({
...options,
url: hashUrl,
onBeforeLoad(win) {
options?.onBeforeLoad?.(win)
// We want to test from a clean state, so we clear the local storage (which clears redux).
win.localStorage.clear()
// Set initial user state.
win.localStorage.setItem(
'redux_localstorage_simple_user', // storage key for the user reducer using 'redux-localstorage-simple'
JSON.stringify(options?.userState ?? CONNECTED_WALLET_USER_STATE)
)
// Set feature flags, if configured.
if (options?.featureFlags) {
const featureFlags = options.featureFlags.reduce((flags, flag) => ({ ...flags, [flag]: 'enabled' }), {})
win.localStorage.setItem('featureFlags', JSON.stringify(featureFlags))
}
// Inject the mock ethereum provider.
if (options?.ethereum === 'hardhat') {
win.ethereum = provider
} else {
win.ethereum = injected
}
},
})
)
}
)

View File

@@ -6,125 +6,17 @@
// ***********************************************************
import '@cypress/code-coverage/support'
import 'cypress-hardhat/lib/browser'
import './commands'
import './setupTests'
import { Eip1193Bridge } from '@ethersproject/experimental/lib/eip1193-bridge'
import TokenListJSON from '@uniswap/default-token-list'
import assert from 'assert'
import { FeatureFlag } from '../../src/featureFlags/flags/featureFlags'
import { UserState } from '../../src/state/user/reducer'
import { CONNECTED_WALLET_USER_STATE } from '../utils/user-state'
import { injected } from './ethereum'
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface ApplicationWindow {
ethereum: Eip1193Bridge
}
interface VisitOptions {
serviceWorker?: true
featureFlags?: Array<FeatureFlag>
/**
* The mock ethereum provider to inject into the page.
* @default 'goerli'
*/
// TODO(INFRA-175): Migrate all usage of 'goerli' to 'hardhat'.
ethereum?: 'goerli' | 'hardhat'
/**
* Initial user state.
* @default {@type import('../utils/user-state').CONNECTED_WALLET_USER_STATE}
*/
userState?: Partial<UserState>
}
}
}
// sets up the injected provider to be a mock ethereum provider with the given mnemonic/index
// eslint-disable-next-line no-undef
Cypress.Commands.overwrite(
'visit',
(original, url: string | Partial<Cypress.VisitOptions>, options?: Partial<Cypress.VisitOptions>) => {
assert(typeof url === 'string')
// Add a hash in the URL if it is not present (to use hash-based routing correctly with queryParams).
let hashUrl = url.startsWith('/') && url.length > 2 && !url.startsWith('/#') ? `/#${url}` : url
if (options?.ethereum === 'goerli') hashUrl += `${url.includes('?') ? '&' : '?'}chain=goerli`
return cy
.intercept('/service-worker.js', options?.serviceWorker ? undefined : { statusCode: 404 })
.provider()
.then((provider) =>
original({
...options,
url: hashUrl,
onBeforeLoad(win) {
options?.onBeforeLoad?.(win)
// We want to test from a clean state, so we clear the local storage (which clears redux).
win.localStorage.clear()
// Set initial user state.
win.localStorage.setItem(
'redux_localstorage_simple_user', // storage key for the user reducer using 'redux-localstorage-simple'
JSON.stringify(options?.userState ?? CONNECTED_WALLET_USER_STATE)
)
// Set feature flags, if configured.
if (options?.featureFlags) {
const featureFlags = options.featureFlags.reduce((flags, flag) => ({ ...flags, [flag]: 'enabled' }), {})
win.localStorage.setItem('featureFlags', JSON.stringify(featureFlags))
}
// Inject the mock ethereum provider.
if (options?.ethereum === 'hardhat') {
win.ethereum = provider
} else {
win.ethereum = injected
}
},
})
)
}
)
beforeEach(() => {
cy
// Many API calls enforce that requests come from our app, so we must mock Origin and Referer.
.intercept('*', (req) => {
req.headers['referer'] = 'https://app.uniswap.org'
req.headers['origin'] = 'https://app.uniswap.org'
})
// Infura uses a test endpoint, which allow-lists http://localhost:3000 instead.
.intercept(/infura.io/, (req) => {
req.headers['referer'] = 'http://localhost:3000'
req.headers['origin'] = 'http://localhost:3000'
req.alias = req.body.method
req.continue()
})
// Mock Amplitude responses to avoid analytics from tests.
.intercept('https://api.uniswap.org/v1/amplitude-proxy', (req) => {
const requestBody = JSON.stringify(req.body)
const byteSize = new Blob([requestBody]).size
req.reply(
JSON.stringify({
code: 200,
server_upload_time: Date.now(),
payload_size_bytes: byteSize,
events_ingested: req.body.events.length,
})
)
})
// Mock our own token list responses to avoid the latency of IPFS.
.intercept('https://gateway.ipfs.io/ipns/tokens.uniswap.org', TokenListJSON)
.intercept('https://gateway.ipfs.io/ipns/extendedtokens.uniswap.org', { statusCode: 201, body: { tokens: [] } })
.intercept('https://gateway.ipfs.io/ipns/unsupportedtokens.uniswap.org', { statusCode: 201, body: { tokens: [] } })
// Reset hardhat between tests to ensure isolation.
// This resets the fork, as well as options like automine.
.hardhat()
.then((hardhat) => hardhat.reset())
})
// Squelch logs from fetches, as they clutter the logs so much as to make them unusable.
// See https://docs.cypress.io/api/commands/intercept#Disabling-logs-for-a-request.
// TODO(https://github.com/cypress-io/cypress/issues/26069): Squelch only wildcard logs once Cypress allows it.
const log = Cypress.log
Cypress.log = function (options, ...args) {
if (options.displayName === 'script' || options.name === 'request') return
return log(options, ...args)
} as typeof log
Cypress.on('uncaught:exception', () => {
// returning false here prevents Cypress from failing the test

View File

@@ -0,0 +1,41 @@
// @ts-ignore
import TokenListJSON from '@uniswap/default-token-list'
beforeEach(() => {
// Many API calls enforce that requests come from our app, so we must mock Origin and Referer.
cy.intercept('*', (req) => {
req.headers['referer'] = 'https://app.uniswap.org'
req.headers['origin'] = 'https://app.uniswap.org'
})
// Infura uses a test endpoint, which allow-lists http://localhost:3000 instead.
cy.intercept(/infura.io/, (req) => {
req.headers['referer'] = 'http://localhost:3000'
req.headers['origin'] = 'http://localhost:3000'
req.alias = req.body.method
req.continue()
})
// Mock analytics responses to avoid analytics in tests.
cy.intercept('https://api.uniswap.org/v1/amplitude-proxy', (req) => {
const requestBody = JSON.stringify(req.body)
const byteSize = new Blob([requestBody]).size
req.reply(
JSON.stringify({
code: 200,
server_upload_time: Date.now(),
payload_size_bytes: byteSize,
events_ingested: req.body.events.length,
})
)
}).intercept('https://*.sentry.io', { statusCode: 200 })
// Mock our own token list responses to avoid the latency of IPFS.
cy.intercept('https://gateway.ipfs.io/ipns/tokens.uniswap.org', TokenListJSON)
.intercept('https://gateway.ipfs.io/ipns/extendedtokens.uniswap.org', { statusCode: 404 })
.intercept('https://gateway.ipfs.io/ipns/unsupportedtokens.uniswap.org', { statusCode: 404 })
// Reset hardhat between tests to ensure isolation.
// This resets the fork, as well as options like automine.
cy.hardhat().then((hardhat) => hardhat.reset())
})

View File

@@ -1,6 +1,6 @@
/* eslint-env node */
const defaultConfig = require('./graphql.config')
const defaultConfig = require('./graphql.data.config')
module.exports = {
src: defaultConfig.src,

View File

@@ -1,8 +1,63 @@
import { default as babelExtractor } from '@lingui/cli/api/extractors/babel'
import { createHash } from 'crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
import * as path from 'path'
import * as pkgUp from 'pkg-up' // pkg-up is used by lingui, and is used here to match lingui's own extractors
/**
* A custom caching extractor for CI.
* Falls back to the babelExtractor in a non-CI (ie local) environment.
* Caches a file's latest extracted content's hash, and skips re-extracting if it is already present in the cache.
* In CI, re-extracting files takes over one minute, so this is a significant savings.
*/
const cachingExtractor: typeof babelExtractor = {
match(filename: string) {
return babelExtractor.match(filename)
},
extract(filename: string, code: string, ...options: unknown[]) {
if (!process.env.CI) return babelExtractor.extract(filename, code, ...options)
// This runs from node_modules/@lingui/conf, so we need to back out to the root.
const pkg = pkgUp.sync()
if (!pkg) throw new Error('No root found')
const root = path.dirname(pkg)
const filePath = path.join(root, filename)
const file = readFileSync(filePath)
const hash = createHash('sha256').update(file).digest('hex')
const cacheRoot = path.join(root, 'node_modules/.cache/lingui')
mkdirSync(cacheRoot, { recursive: true })
const cachePath = path.join(cacheRoot, filename.replace(/\//g, '-'))
// Only read from the cache if we're not performing a "clean" run, as a clean run must re-extract from all
// files to ensure that obsolete messages are removed.
if (!process.argv.includes('--clean')) {
try {
const cache = readFileSync(cachePath, 'utf8')
if (cache === hash) return
} catch (e) {
// It should not be considered an error if there is no cache file.
}
}
writeFileSync(cachePath, hash)
return babelExtractor.extract(filename, code, ...options)
},
}
const linguiConfig = {
catalogs: [
{
path: '<rootDir>/src/locales/{locale}',
include: ['<rootDir>/src'],
include: ['<rootDir>/src/**/*.ts', '<rootDir>/src/**/*.tsx'],
exclude: [
'<rootDir>/src/**/*.d.ts',
'<rootDir>/src/**/*.test.*',
'<rootDir>/src/types/v3/**',
'<rootDir>/src/abis/types/**',
'<rootDir>/src/graphql/**/__generated__/**',
],
},
],
compileNamespace: 'cjs',
@@ -53,6 +108,7 @@ const linguiConfig = {
runtimeConfigModule: ['@lingui/core', 'i18n'],
sourceLocale: 'en-US',
pseudoLocale: 'pseudo',
extractors: [cachingExtractor],
}
export default linguiConfig

View File

@@ -5,18 +5,20 @@
"homepage": ".",
"license": "GPL-3.0-or-later",
"scripts": {
"ajv": "node scripts/compile-ajv-validators.js",
"contracts:compile:abi": "typechain --target ethers-v5 --out-dir src/abis/types \"./src/abis/**/*.json\"",
"contracts:compile:v3": "typechain --target ethers-v5 --out-dir src/types/v3 \"./node_modules/@uniswap/**/artifacts/contracts/**/*[!dbg].json\"",
"contracts:compile": "yarn contracts:compile:abi && yarn contracts:compile:v3",
"contracts": "yarn contracts:compile:abi && yarn contracts:compile:v3",
"graphql:fetch": "node scripts/fetch-schema.js",
"graphql:generate:data": "graphql-codegen --config apollo-codegen.ts",
"graphql:generate:thegraph": "graphql-codegen --config apollo-codegen_thegraph.ts",
"graphql:generate:data": "graphql-codegen --config graphql.data.codegen.config.ts",
"graphql:generate:thegraph": "graphql-codegen --config graphql.thegraph.codegen.config.ts",
"graphql:generate": "yarn graphql:generate:data && yarn graphql:generate:thegraph",
"prei18n:extract": "node scripts/prei18n-extract.js",
"graphql": "yarn graphql:fetch && yarn graphql:generate",
"i18n:extract": "lingui extract --locale en-US",
"i18n:compile": "yarn i18n:extract && lingui compile",
"i18n:pseudo": "lingui extract --locale pseudo && lingui compile",
"prepare": "yarn contracts:compile && yarn graphql:fetch && yarn graphql:generate && yarn i18n:compile",
"i18n:pseudo": "lingui extract --locale pseudo",
"i18n:compile": "lingui compile",
"i18n": "yarn i18n:extract --clean && yarn i18n:compile",
"prepare": "yarn ajv && yarn contracts && yarn graphql && yarn i18n",
"start": "craco start",
"build": "craco build",
"build:e2e": "REACT_APP_CSP_ALLOW_UNSAFE_EVAL=true REACT_APP_ADD_COVERAGE_INSTRUMENTATION=true craco build",
@@ -97,7 +99,7 @@
"@types/uuid": "^8.3.4",
"@types/wcag-contrast": "^3.0.0",
"@uniswap/default-token-list": "^9.4.0",
"@uniswap/eslint-config": "^1.1.1",
"@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",
@@ -159,7 +161,7 @@
"@uniswap/router-sdk": "^1.3.0",
"@uniswap/sdk-core": "^3.2.2",
"@uniswap/smart-order-router": "^3.6.1",
"@uniswap/token-lists": "^1.0.0-beta.30",
"@uniswap/token-lists": "^1.0.0-beta.31",
"@uniswap/universal-router-sdk": "^1.3.8",
"@uniswap/v2-core": "1.0.0",
"@uniswap/v2-periphery": "^1.1.0-beta.0",
@@ -167,7 +169,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",
@@ -190,6 +191,8 @@
"@web3-react/types": "^8.2.0",
"@web3-react/url": "^8.2.0",
"@web3-react/walletconnect": "^8.2.0",
"ajv": "^8.11.0",
"ajv-formats": "^2.1.1",
"array.prototype.flat": "^1.2.4",
"array.prototype.flatmap": "^1.2.4",
"cids": "^1.0.0",
@@ -250,18 +253,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",

View File

@@ -0,0 +1,20 @@
/* eslint-env node */
const fs = require('fs')
const path = require('path')
const Ajv = require('ajv')
const standaloneCode = require('ajv/dist/standalone').default
const addFormats = require('ajv-formats')
const schema = require('@uniswap/token-lists/dist/tokenlist.schema.json')
const tokenListAjv = new Ajv({ code: { source: true, esm: true } })
addFormats(tokenListAjv)
const validateTokenList = tokenListAjv.compile(schema)
let tokenListModuleCode = standaloneCode(tokenListAjv, validateTokenList)
fs.writeFileSync(path.join(__dirname, '../src/utils/__generated__/validateTokenList.js'), tokenListModuleCode)
const tokensAjv = new Ajv({ code: { source: true, esm: true } })
addFormats(tokensAjv)
const validateTokens = tokensAjv.compile({ ...schema, required: ['tokens'] })
let tokensModuleCode = standaloneCode(tokensAjv, validateTokens)
fs.writeFileSync(path.join(__dirname, '../src/utils/__generated__/validateTokens.js'), tokensModuleCode)

View File

@@ -4,8 +4,8 @@ require('dotenv').config({ path: '.env.production' })
const child_process = require('child_process')
const fs = require('fs/promises')
const { promisify } = require('util')
const dataConfig = require('../graphql.config')
const thegraphConfig = require('../graphql_thegraph.config')
const dataConfig = require('../graphql.data.config')
const thegraphConfig = require('../graphql.thegraph.config')
const exec = promisify(child_process.exec)

View File

@@ -1,10 +0,0 @@
/* eslint-env node */
const { exec } = require('child_process')
const isWindows = process.platform === 'win32' || /^(msys|cygwin)$/.test(process.env.OSTYPE)
if (isWindows) {
exec(`type nul > src/locales/en-US.po`)
} else {
exec(`touch src/locales/en-US.po`)
}

View File

@@ -87,10 +87,20 @@ function combineActivities(localMap: ActivityMap = {}, remoteMap: ActivityMap =
// 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
// Check for nonce collision
const isNonceCollision =
localActivity.nonce !== undefined &&
Object.keys(remoteMap).some((remoteHash) => remoteMap[remoteHash]?.nonce === localActivity.nonce)
if (!isNonceCollision) {
// 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
}, [])
}

View File

@@ -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> = {}

View File

@@ -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)

View File

@@ -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 }

View File

@@ -30,6 +30,7 @@ const Wrapper = styled(Column)<{ numItems: number; isExpanded: boolean }>`
overflow: hidden;
`
// TODO(WEB-3288): Replace this component to use `components/Expand` under the hood
type ExpandoRowProps = PropsWithChildren<{ title?: string; numItems: number; isExpanded: boolean; toggle: () => void }>
export function ExpandoRow({ title = t`Hidden`, numItems, isExpanded, toggle, children }: ExpandoRowProps) {
if (numItems === 0) return null

View File

@@ -0,0 +1,105 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Expand renders correctly 1`] = `
<DocumentFragment>
.c1 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c2 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 0;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
}
.c3 {
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
}
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
gap: 12px;
}
.c4 {
cursor: pointer;
-webkit-box-pack: end;
-webkit-justify-content: flex-end;
-ms-flex-pack: end;
justify-content: flex-end;
width: unset;
}
.c5 {
color: #7780A0;
-webkit-transform: rotate(0deg);
-ms-transform: rotate(0deg);
transform: rotate(0deg);
-webkit-transition: -webkit-transform 250ms;
-webkit-transition: transform 250ms;
transition: transform 250ms;
}
<div
class="c0"
>
<div
class="c1 c2 c3"
>
<span>
Header
</span>
<div
aria-expanded="false"
class="c1 c2 c4"
>
<span>
Button
</span>
<svg
class="c5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="6 9 12 15 18 9"
/>
</svg>
</div>
</div>
</div>
</DocumentFragment>
`;

View File

@@ -0,0 +1,30 @@
import { fireEvent, render, screen } from 'test-utils/render'
import Expand from './index'
describe('Expand', () => {
it('renders correctly', () => {
const { asFragment } = render(
<Expand header={<span>Header</span>} button={<span>Button</span>}>
Body
</Expand>
)
expect(asFragment()).toMatchSnapshot()
})
it('toggles children on button press', () => {
render(
<Expand header={<span>Header</span>} button={<span>Button</span>}>
Body
</Expand>
)
const button = screen.getByText('Button')
fireEvent.click(button)
expect(screen.queryByText('Body')).not.toBeNull()
fireEvent.click(button)
expect(screen.queryByText('Body')).toBeNull()
})
})

View File

@@ -0,0 +1,43 @@
import Column from 'components/Column'
import React, { PropsWithChildren, ReactElement, useState } from 'react'
import { ChevronDown } from 'react-feather'
import styled from 'styled-components/macro'
import Row, { RowBetween } from '../Row'
const ButtonContainer = styled(Row)`
cursor: pointer;
justify-content: flex-end;
width: unset;
`
const ExpandIcon = styled(ChevronDown)<{ $isExpanded: boolean }>`
color: ${({ theme }) => theme.textSecondary};
transform: ${({ $isExpanded }) => ($isExpanded ? 'rotate(180deg)' : 'rotate(0deg)')};
transition: transform ${({ theme }) => theme.transition.duration.medium};
`
export default function Expand({
header,
button,
children,
testId,
}: PropsWithChildren<{
header: ReactElement
button: ReactElement
testId?: string
}>) {
const [isExpanded, setExpanded] = useState(false)
return (
<Column gap="md">
<RowBetween>
{header}
<ButtonContainer data-testid={testId} onClick={() => setExpanded(!isExpanded)} aria-expanded={isExpanded}>
{button}
<ExpandIcon $isExpanded={isExpanded} />
</ButtonContainer>
</RowBetween>
{isExpanded && children}
</Column>
)
}

View File

@@ -1,6 +1,5 @@
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 { useUpdateAtom } from 'jotai/utils'
import { Children, PropsWithChildren, ReactElement, ReactNode, useCallback, useState } from 'react'
@@ -208,12 +207,6 @@ export default function FeatureFlagModal() {
featureFlag={FeatureFlag.detailsV2}
label="Use the new details page for nfts"
/>
<FeatureFlagOption
variant={WidgetRemovalVariant}
value={useWidgetRemovalFlag()}
featureFlag={FeatureFlag.removeWidget}
label="Swap Component on TDP"
/>
<FeatureFlagGroup name="Debug">
<FeatureFlagOption
variant={TraceJsonRpcVariant}

View File

@@ -28,7 +28,7 @@ export default function Loader({
{...rest}
>
<path
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 9.27455 20.9097 6.80375"
d="M2,12 a10,10 0 0,1 10,-10 M12,22 a10,10 0 0,1 -10,-10 M22,12 a10,10 0 0,1 -10,10"
strokeWidth={strokeWidth ?? '2.5'}
strokeLinecap="round"
strokeLinejoin="round"

View File

@@ -95,7 +95,7 @@ export const ChainSelector = ({ leftAlign }: ChainSelectorProps) => {
return (
<Box position="relative" ref={ref}>
<MouseoverTooltip text={t`Your wallet's current network is unsupported.`} disableHover={isSupported}>
<MouseoverTooltip text={t`Your wallet's current network is unsupported.`} disabled={isSupported}>
<Row
as="button"
gap="8"

View File

@@ -57,13 +57,13 @@ export function FindPoolTabs({ origin }: { origin: string }) {
export function AddRemoveTabs({
adding,
creating,
defaultSlippage,
autoSlippage,
positionID,
children,
}: {
adding: boolean
creating: boolean
defaultSlippage: Percent
autoSlippage: Percent
positionID?: string | undefined
showBackLink?: boolean
children?: ReactNode | undefined
@@ -108,7 +108,7 @@ export function AddRemoveTabs({
)}
</ThemedText.DeprecatedMediumHeader>
<Box style={{ marginRight: '.5rem' }}>{children}</Box>
<SettingsTab placeholderSlippage={defaultSlippage} />
<SettingsTab autoSlippage={autoSlippage} />
</RowBetween>
</Tabs>
)

View File

@@ -99,8 +99,8 @@ export default function Popover({
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null)
const [arrowElement, setArrowElement] = useState<HTMLDivElement | null>(null)
const options = useMemo(
(): Options => ({
const options: Options = useMemo(
() => ({
placement,
strategy: 'fixed',
modifiers: [
@@ -109,7 +109,7 @@ export default function Popover({
{ name: 'preventOverflow', options: { padding: 8 } },
],
}),
[arrowElement, offsetX, offsetY, placement]
[placement, offsetX, offsetY, arrowElement]
)
const { styles, update, attributes } = usePopper(referenceElement, popperElement, options)

View File

@@ -0,0 +1,50 @@
import { RowBetween } from 'components/Row'
import { darken } from 'polished'
import { PropsWithChildren } from 'react'
import styled from 'styled-components/macro'
const Button = styled.button<{ isActive?: boolean; activeElement?: boolean }>`
align-items: center;
background: transparent;
border: 2px solid ${({ theme, isActive }) => (isActive ? theme.accentAction : theme.backgroundOutline)};
border-radius: 50%;
cursor: pointer;
display: flex;
outline: none;
padding: 5px;
width: fit-content;
`
const ButtonFill = styled.span<{ isActive?: boolean }>`
background: ${({ theme, isActive }) => (isActive ? theme.accentAction : theme.textTertiary)};
border-radius: 50%;
:hover {
background: ${({ isActive, theme }) =>
isActive ? darken(0.05, theme.accentAction) : darken(0.05, theme.deprecated_bg4)};
color: ${({ isActive, theme }) => (isActive ? theme.white : theme.textTertiary)};
}
width: 10px;
height: 10px;
opacity: ${({ isActive }) => (isActive ? 1 : 0)};
`
const Container = styled(RowBetween)`
cursor: pointer;
`
interface RadioProps {
className?: string
isActive: boolean
toggle: () => void
}
export default function Radio({ className, isActive, children, toggle }: PropsWithChildren<RadioProps>) {
return (
<Container className={className} onClick={toggle}>
{children}
<Button isActive={isActive}>
<ButtonFill isActive={isActive} />
</Button>
</Container>
)
}

View File

@@ -1,9 +1,9 @@
import { Protocol } from '@uniswap/router-sdk'
import { Currency, Percent } from '@uniswap/sdk-core'
import { FeeAmount } from '@uniswap/v3-sdk'
import { RoutingDiagramEntry } from 'components/swap/SwapRoute'
import { DAI, USDC_MAINNET, WBTC } from 'constants/tokens'
import { render } from 'test-utils/render'
import { RoutingDiagramEntry } from 'utils/getRoutingDiagramEntries'
import RoutingDiagram from './RoutingDiagram'

View File

@@ -6,12 +6,12 @@ import Badge from 'components/Badge'
import DoubleCurrencyLogo from 'components/DoubleLogo'
import CurrencyLogo from 'components/Logo/CurrencyLogo'
import Row, { AutoRow } from 'components/Row'
import { RoutingDiagramEntry } from 'components/swap/SwapRoute'
import { useTokenInfoFromActiveList } from 'hooks/useTokenInfoFromActiveList'
import { Box } from 'rebass'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
import { Z_INDEX } from 'theme/zIndex'
import { RoutingDiagramEntry } from 'utils/getRoutingDiagramEntries'
import { ReactComponent as DotLine } from '../../assets/svg/dot_line.svg'
import { MouseoverTooltip } from '../Tooltip'

View File

@@ -1,6 +1,12 @@
import { Box } from 'rebass/styled-components'
import styled from 'styled-components/macro'
import styled, { DefaultTheme } from 'styled-components/macro'
type Gap = keyof DefaultTheme['grids']
// TODO(WEB-3289):
// Setting `width: 100%` by default prevents composability in complex flex layouts.
// Same applies to `RowFixed` and its negative margins. This component needs to be
// further investigated and improved to make UI work easier.
const Row = styled(Box)<{
width?: string
align?: string
@@ -18,7 +24,7 @@ const Row = styled(Box)<{
padding: ${({ padding }) => padding};
border: ${({ border }) => border};
border-radius: ${({ borderRadius }) => borderRadius};
gap: ${({ gap }) => gap};
gap: ${({ gap, theme }) => gap && (theme.grids[gap as Gap] || gap)};
`
export const RowBetween = styled(Row)`

View File

@@ -0,0 +1,46 @@
import styled from 'styled-components/macro'
import Row from '../../Row'
export const Input = styled.input`
width: 100%;
display: flex;
flex: 1;
font-size: 16px;
border: 0;
outline: none;
background: transparent;
text-align: right;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
}
::placeholder {
color: ${({ theme }) => theme.textTertiary};
}
`
export const InputContainer = styled(Row)<{ error?: boolean }>`
padding: 8px 16px;
border-radius: 12px;
width: auto;
flex: 1;
input {
color: ${({ theme, error }) => (error ? theme.accentFailure : theme.textPrimary)};
}
border: 1px solid ${({ theme, error }) => (error ? theme.accentFailure : theme.deprecated_bg3)};
${({ theme, error }) =>
error
? `
border: 1px solid ${theme.accentFailure};
:focus-within {
border-color: ${theme.accentFailureSoft};
}
`
: `
border: 1px solid ${theme.backgroundOutline};
:focus-within {
border-color: ${theme.accentActiveSoft};
}
`}
`

View File

@@ -0,0 +1,98 @@
import { Percent } from '@uniswap/sdk-core'
import store from 'state'
import { updateUserSlippageTolerance } from 'state/user/reducer'
import { SlippageTolerance } from 'state/user/types'
import { fireEvent, render, screen } from 'test-utils/render'
import MaxSlippageSettings from '.'
const AUTO_SLIPPAGE = new Percent(5, 10_000)
const renderAndExpandSlippageSettings = () => {
render(<MaxSlippageSettings autoSlippage={AUTO_SLIPPAGE} />)
// By default, the button to expand Slippage component and show `input` will have `Auto` label
fireEvent.click(screen.getByText('Auto'))
}
// Switch to custom mode by tapping on `Custom` label
const switchToCustomSlippage = () => {
fireEvent.click(screen.getByText('Custom'))
}
const getSlippageInput = () => screen.getByTestId('slippage-input') as HTMLInputElement
describe('MaxSlippageSettings', () => {
describe('input', () => {
// Restore to default slippage before each unit test
beforeEach(() => {
store.dispatch(updateUserSlippageTolerance({ userSlippageTolerance: SlippageTolerance.Auto }))
})
it('does not render auto slippage as a value, but a placeholder', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()
expect(getSlippageInput().value).toBe('')
})
it('renders custom slippage above the input', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()
fireEvent.change(getSlippageInput(), { target: { value: '0.5' } })
expect(screen.queryAllByText('0.50%').length).toEqual(1)
})
it('updates input value on blur with the slippage in store', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()
const input = getSlippageInput()
fireEvent.change(input, { target: { value: '0.5' } })
fireEvent.blur(input)
expect(input.value).toBe('0.50')
})
it('clears errors on blur and overwrites incorrect value with the latest correct value', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()
const input = getSlippageInput()
fireEvent.change(input, { target: { value: '5' } })
fireEvent.change(input, { target: { value: '50' } })
fireEvent.change(input, { target: { value: '500' } })
fireEvent.blur(input)
expect(input.value).toBe('50.00')
})
it('does not allow to enter more than 2 digits after the decimal point', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()
const input = getSlippageInput()
fireEvent.change(input, { target: { value: '0.01' } })
fireEvent.change(input, { target: { value: '0.011' } })
expect(input.value).toBe('0.01')
})
it('does not accept non-numerical values', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()
const input = getSlippageInput()
fireEvent.change(input, { target: { value: 'c' } })
expect(input.value).toBe('')
})
it('does not set slippage when user enters `.` value', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()
const input = getSlippageInput()
fireEvent.change(input, { target: { value: '.' } })
expect(input.value).toBe('.')
fireEvent.blur(input)
expect(input.value).toBe('')
})
})
})

View File

@@ -0,0 +1,175 @@
import { Trans } from '@lingui/macro'
import { Percent } from '@uniswap/sdk-core'
import Expand from 'components/Expand'
import QuestionHelper from 'components/QuestionHelper'
import Row, { RowBetween } from 'components/Row'
import React, { useState } from 'react'
import { useUserSlippageTolerance } from 'state/user/hooks'
import { SlippageTolerance } from 'state/user/types'
import styled from 'styled-components/macro'
import { CautionTriangle, ThemedText } from 'theme'
import { Input, InputContainer } from '../Input'
enum SlippageError {
InvalidInput = 'InvalidInput',
}
const Option = styled(Row)<{ isActive: boolean }>`
width: auto;
cursor: pointer;
padding: 6px 12px;
text-align: center;
gap: 4px;
border-radius: 12px;
background: ${({ isActive, theme }) => (isActive ? theme.backgroundInteractive : 'transparent')};
pointer-events: ${({ isActive }) => isActive && 'none'};
`
const Switch = styled(Row)`
width: auto;
padding: 4px;
border: 1px solid ${({ theme }) => theme.backgroundOutline};
border-radius: 16px;
`
const NUMBER_WITH_MAX_TWO_DECIMAL_PLACES = /^(?:\d*\.\d{0,2}|\d+)$/
const MINIMUM_RECOMMENDED_SLIPPAGE = new Percent(5, 10_000)
const MAXIMUM_RECOMMENDED_SLIPPAGE = new Percent(1, 100)
export default function MaxSlippageSettings({ autoSlippage }: { autoSlippage: Percent }) {
const [userSlippageTolerance, setUserSlippageTolerance] = useUserSlippageTolerance()
// In order to trigger `custom` mode, we need to set `userSlippageTolerance` to a value that is not `auto`.
// To do so, we use `autoSlippage` value. However, since users are likely to change that value,
// we render it as a placeholder instead of a value.
const defaultSlippageInputValue =
userSlippageTolerance !== SlippageTolerance.Auto && !userSlippageTolerance.equalTo(autoSlippage)
? userSlippageTolerance.toFixed(2)
: ''
// If user has previously entered a custom slippage, we want to show that value in the input field
// instead of a placeholder.
const [slippageInput, setSlippageInput] = useState(defaultSlippageInputValue)
const [slippageError, setSlippageError] = useState<SlippageError | false>(false)
const parseSlippageInput = (value: string) => {
// Do not allow non-numerical characters in the input field or more than two decimals
if (value.length > 0 && !NUMBER_WITH_MAX_TWO_DECIMAL_PLACES.test(value)) {
return
}
setSlippageInput(value)
setSlippageError(false)
// If the input is empty, set the slippage to the default
if (value.length === 0) {
setUserSlippageTolerance(SlippageTolerance.Auto)
return
}
if (value === '.') {
return
}
// Parse user input and set the slippage if valid, error otherwise
try {
const parsed = Math.floor(Number.parseFloat(value) * 100)
if (parsed > 5000) {
setSlippageError(SlippageError.InvalidInput)
} else {
setUserSlippageTolerance(new Percent(parsed, 10_000))
}
} catch (e) {
setSlippageError(SlippageError.InvalidInput)
}
}
const tooLow =
userSlippageTolerance !== SlippageTolerance.Auto && userSlippageTolerance.lessThan(MINIMUM_RECOMMENDED_SLIPPAGE)
const tooHigh =
userSlippageTolerance !== SlippageTolerance.Auto && userSlippageTolerance.greaterThan(MAXIMUM_RECOMMENDED_SLIPPAGE)
return (
<Expand
testId="max-slippage-settings"
header={
<Row width="auto">
<ThemedText.BodySecondary>
<Trans>Max slippage</Trans>
</ThemedText.BodySecondary>
<QuestionHelper
text={
<Trans>Your transaction will revert if the price changes unfavorably by more than this percentage.</Trans>
}
/>
</Row>
}
button={
<ThemedText.BodyPrimary>
{userSlippageTolerance === SlippageTolerance.Auto ? (
<Trans>Auto</Trans>
) : (
`${userSlippageTolerance.toFixed(2)}%`
)}
</ThemedText.BodyPrimary>
}
>
<RowBetween gap="md">
<Switch>
<Option
onClick={() => {
// Reset the input field when switching to auto
setSlippageInput('')
setUserSlippageTolerance(SlippageTolerance.Auto)
}}
isActive={userSlippageTolerance === SlippageTolerance.Auto}
>
<ThemedText.BodyPrimary>
<Trans>Auto</Trans>
</ThemedText.BodyPrimary>
</Option>
<Option
onClick={() => {
// When switching to custom slippage, use `auto` value as a default.
setUserSlippageTolerance(autoSlippage)
}}
isActive={userSlippageTolerance !== SlippageTolerance.Auto}
>
<ThemedText.BodyPrimary>
<Trans>Custom</Trans>
</ThemedText.BodyPrimary>
</Option>
</Switch>
<InputContainer gap="md" error={!!slippageError}>
<Input
data-testid="slippage-input"
placeholder={autoSlippage.toFixed(2)}
value={slippageInput}
onChange={(e) => parseSlippageInput(e.target.value)}
onBlur={() => {
// When the input field is blurred, reset the input field to the default value
setSlippageInput(defaultSlippageInputValue)
setSlippageError(false)
}}
/>
<ThemedText.BodyPrimary>%</ThemedText.BodyPrimary>
</InputContainer>
</RowBetween>
{tooLow || tooHigh ? (
<RowBetween gap="md">
<CautionTriangle />
<ThemedText.Caption color="accentWarning">
{tooLow ? (
<Trans>
Slippage below {MINIMUM_RECOMMENDED_SLIPPAGE.toFixed(2)}% may result in a failed transaction
</Trans>
) : (
<Trans>Your transaction may be frontrun and result in an unfavorable trade.</Trans>
)}
</ThemedText.Caption>
</RowBetween>
) : null}
</Expand>
)
}

View File

@@ -0,0 +1,81 @@
import { Trans } from '@lingui/macro'
import Column from 'components/Column'
import Radio from 'components/Radio'
import { RowBetween, RowFixed } from 'components/Row'
import Toggle from 'components/Toggle'
import { RouterPreference } from 'state/routing/slice'
import { useRouterPreference } from 'state/user/hooks'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
const Preference = styled(Radio)`
background-color: ${({ theme }) => theme.backgroundModule};
padding: 12px 16px;
`
const PreferencesContainer = styled(Column)`
gap: 1.5px;
border-radius: 12px;
overflow: hidden;
`
export default function RouterPreferenceSettings() {
const [routerPreference, setRouterPreference] = useRouterPreference()
const isAutoRoutingActive = routerPreference === RouterPreference.AUTO
return (
<Column gap="md">
<RowBetween gap="sm">
<RowFixed>
<Column gap="xs">
<ThemedText.BodySecondary>
<Trans>Auto Router API</Trans>
</ThemedText.BodySecondary>
<ThemedText.Caption color="textSecondary">
<Trans>Use the Uniswap Labs API to get faster quotes.</Trans>
</ThemedText.Caption>
</Column>
</RowFixed>
<Toggle
id="toggle-optimized-router-button"
isActive={isAutoRoutingActive}
toggle={() => setRouterPreference(isAutoRoutingActive ? RouterPreference.API : RouterPreference.AUTO)}
/>
</RowBetween>
{!isAutoRoutingActive && (
<PreferencesContainer>
<Preference
isActive={routerPreference === RouterPreference.API}
toggle={() => setRouterPreference(RouterPreference.API)}
>
<Column gap="xs">
<ThemedText.BodyPrimary>
<Trans>Uniswap API</Trans>
</ThemedText.BodyPrimary>
<ThemedText.Caption color="textSecondary">
<Trans>Finds the best route on the Uniswap Protocol using the Uniswap Labs Routing API.</Trans>
</ThemedText.Caption>
</Column>
</Preference>
<Preference
isActive={routerPreference === RouterPreference.CLIENT}
toggle={() => setRouterPreference(RouterPreference.CLIENT)}
>
<Column gap="xs">
<ThemedText.BodyPrimary>
<Trans>Uniswap client</Trans>
</ThemedText.BodyPrimary>
<ThemedText.Caption color="textSecondary">
<Trans>
Finds the best route on the Uniswap Protocol through your browser. May result in high latency and
prices.
</Trans>
</ThemedText.Caption>
</Column>
</Preference>
</PreferencesContainer>
)}
</Column>
)
}

View File

@@ -0,0 +1,67 @@
import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc'
import store from 'state'
import { updateUserDeadline } from 'state/user/reducer'
import { fireEvent, render, screen } from 'test-utils/render'
import TransactionDeadlineSettings from '.'
const renderAndExpandTransactionDeadlineSettings = () => {
render(<TransactionDeadlineSettings />)
// By default, the button to expand Slippage component and show `input` will have `<deadline>m` label
fireEvent.click(screen.getByText(`${DEFAULT_DEADLINE_FROM_NOW / 60}m`))
}
const getDeadlineInput = () => screen.getByTestId('deadline-input') as HTMLInputElement
describe('TransactionDeadlineSettings', () => {
describe('input', () => {
// Restore to default transaction deadline before each unit test
beforeEach(() => {
store.dispatch(updateUserDeadline({ userDeadline: DEFAULT_DEADLINE_FROM_NOW }))
})
it('does not render default deadline as a value, but a placeholder', () => {
renderAndExpandTransactionDeadlineSettings()
expect(getDeadlineInput().value).toBe('')
})
it('renders custom deadline above the input', () => {
renderAndExpandTransactionDeadlineSettings()
fireEvent.change(getDeadlineInput(), { target: { value: '50' } })
expect(screen.queryAllByText('50m').length).toEqual(1)
})
it('marks deadline as invalid if it is greater than 4320m (3 days) or 0m', () => {
renderAndExpandTransactionDeadlineSettings()
const input = getDeadlineInput()
fireEvent.change(input, { target: { value: '4321' } })
fireEvent.change(input, { target: { value: '0' } })
fireEvent.blur(input)
expect(input.value).toBe('')
})
it('clears errors on blur and overwrites incorrect value with the latest correct value', () => {
renderAndExpandTransactionDeadlineSettings()
const input = getDeadlineInput()
fireEvent.change(input, { target: { value: '5' } })
fireEvent.change(input, { target: { value: '4321' } })
// Label renders latest correct value, at this point input is higlighted as invalid
expect(screen.queryAllByText('5m').length).toEqual(1)
fireEvent.blur(input)
expect(input.value).toBe('5')
})
it('does not accept non-numerical values', () => {
renderAndExpandTransactionDeadlineSettings()
const input = getDeadlineInput()
fireEvent.change(input, { target: { value: 'c' } })
expect(input.value).toBe('')
})
})
})

View File

@@ -0,0 +1,92 @@
import { Trans } from '@lingui/macro'
import Expand from 'components/Expand'
import QuestionHelper from 'components/QuestionHelper'
import Row from 'components/Row'
import { Input, InputContainer } from 'components/Settings/Input'
import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc'
import ms from 'ms.macro'
import React, { useState } from 'react'
import { useUserTransactionTTL } from 'state/user/hooks'
import { ThemedText } from 'theme'
enum DeadlineError {
InvalidInput = 'InvalidInput',
}
const THREE_DAYS_IN_SECONDS = ms`3 days` / 1000
const NUMBERS_ONLY = /^[0-9\b]+$/
export default function TransactionDeadlineSettings() {
const [deadline, setDeadline] = useUserTransactionTTL()
const defaultInputValue = deadline && deadline !== DEFAULT_DEADLINE_FROM_NOW ? (deadline / 60).toString() : ''
// If user has previously entered a custom deadline, we want to show that value in the input field
// instead of a placeholder by defualt
const [deadlineInput, setDeadlineInput] = useState(defaultInputValue)
const [deadlineError, setDeadlineError] = useState<DeadlineError | false>(false)
function parseCustomDeadline(value: string) {
// Do not allow non-numerical characters in the input field
if (value.length > 0 && !NUMBERS_ONLY.test(value)) {
return
}
setDeadlineInput(value)
setDeadlineError(false)
// If the input is empty, set the deadline to the default
if (value.length === 0) {
setDeadline(DEFAULT_DEADLINE_FROM_NOW)
return
}
// Parse user input and set the deadline if valid, error otherwise
try {
const parsed: number = Number.parseInt(value) * 60
if (parsed === 0 || parsed > THREE_DAYS_IN_SECONDS) {
setDeadlineError(DeadlineError.InvalidInput)
} else {
setDeadline(parsed)
}
} catch (error) {
setDeadlineError(DeadlineError.InvalidInput)
}
}
return (
<Expand
testId="transaction-deadline-settings"
header={
<Row width="auto">
<ThemedText.BodySecondary>
<Trans>Transaction deadline</Trans>
</ThemedText.BodySecondary>
<QuestionHelper
text={<Trans>Your transaction will revert if it is pending for more than this period of time.</Trans>}
/>
</Row>
}
button={<Trans>{deadline / 60}m</Trans>}
>
<Row>
<InputContainer gap="md" error={!!deadlineError}>
<Input
data-testid="deadline-input"
placeholder={(DEFAULT_DEADLINE_FROM_NOW / 60).toString()}
value={deadlineInput}
onChange={(e) => parseCustomDeadline(e.target.value)}
onBlur={() => {
// When the input field is blurred, reset the input field to the current deadline
setDeadlineInput(defaultInputValue)
setDeadlineError(false)
}}
/>
<ThemedText.BodyPrimary>
<Trans>minutes</Trans>
</ThemedText.BodyPrimary>
</InputContainer>
</Row>
</Expand>
)
}

View File

@@ -1,43 +1,24 @@
// eslint-disable-next-line no-restricted-imports
import { t, Trans } from '@lingui/macro'
import { t } from '@lingui/macro'
import { Percent } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { sendEvent } from 'components/analytics'
import { AutoColumn } from 'components/Column'
import { L2_CHAIN_IDS } from 'constants/chains'
import { useOnClickOutside } from 'hooks/useOnClickOutside'
import { isSupportedChainId } from 'lib/hooks/routing/clientSideSmartOrderRouter'
import { useRef, useState } from 'react'
import { Settings, X } from 'react-feather'
import { Text } from 'rebass'
import styled, { useTheme } from 'styled-components/macro'
import { useRef } from 'react'
import { Settings } from 'react-feather'
import { useModalIsOpen, useToggleSettingsMenu } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer'
import styled from 'styled-components/macro'
import { useOnClickOutside } from '../../hooks/useOnClickOutside'
import { useModalIsOpen, useToggleSettingsMenu } from '../../state/application/hooks'
import { ApplicationModal } from '../../state/application/reducer'
import { useClientSideRouter, useExpertModeManager } from '../../state/user/hooks'
import { ThemedText } from '../../theme'
import { ButtonError } from '../Button'
import { AutoColumn } from '../Column'
import Modal from '../Modal'
import QuestionHelper from '../QuestionHelper'
import { RowBetween, RowFixed } from '../Row'
import Toggle from '../Toggle'
import TransactionSettings from '../TransactionSettings'
import MaxSlippageSettings from './MaxSlippageSettings'
import RouterPreferenceSettings from './RouterPreferenceSettings'
import TransactionDeadlineSettings from './TransactionDeadlineSettings'
const StyledMenuIcon = styled(Settings)`
height: 20px;
width: 20px;
> * {
stroke: ${({ theme }) => theme.textSecondary};
}
`
const StyledCloseIcon = styled(X)`
height: 20px;
width: 20px;
:hover {
cursor: pointer;
}
> * {
stroke: ${({ theme }) => theme.textSecondary};
}
@@ -53,7 +34,6 @@ const StyledMenuButton = styled.button<{ disabled: boolean }>`
padding: 0;
border-radius: 0.5rem;
height: 20px;
${({ disabled }) =>
!disabled &&
`
@@ -65,12 +45,6 @@ const StyledMenuButton = styled.button<{ disabled: boolean }>`
}
`}
`
const EmojiWrapper = styled.div`
position: absolute;
bottom: -6px;
right: 0px;
font-size: 14px;
`
const StyledMenu = styled.div`
margin-left: 0.5rem;
@@ -97,89 +71,32 @@ const MenuFlyout = styled.span`
right: 0rem;
z-index: 100;
color: ${({ theme }) => theme.textPrimary};
${({ theme }) => theme.deprecated_mediaWidth.deprecated_upToMedium`
min-width: 18.125rem;
`};
user-select: none;
`
const Break = styled.div`
const Divider = styled.div`
width: 100%;
height: 1px;
background-color: ${({ theme }) => theme.deprecated_bg3};
border-width: 0;
margin: 0;
background-color: ${({ theme }) => theme.backgroundOutline};
`
const ModalContentWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 0;
background-color: ${({ theme }) => theme.backgroundInteractive};
border-radius: 20px;
`
export default function SettingsTab({ placeholderSlippage }: { placeholderSlippage: Percent }) {
export default function SettingsTab({ autoSlippage }: { autoSlippage: Percent }) {
const { chainId } = useWeb3React()
const showDeadlineSettings = Boolean(chainId && !L2_CHAIN_IDS.includes(chainId))
const node = useRef<HTMLDivElement | null>(null)
const open = useModalIsOpen(ApplicationModal.SETTINGS)
const toggle = useToggleSettingsMenu()
const theme = useTheme()
const [expertMode, toggleExpertMode] = useExpertModeManager()
const [clientSideRouter, setClientSideRouter] = useClientSideRouter()
// show confirmation view before turning on
const [showConfirmation, setShowConfirmation] = useState(false)
useOnClickOutside(node, open ? toggle : undefined)
return (
<StyledMenu ref={node}>
<Modal isOpen={showConfirmation} onDismiss={() => setShowConfirmation(false)} maxHeight={100}>
<ModalContentWrapper>
<AutoColumn gap="lg">
<RowBetween style={{ padding: '0 2rem' }}>
<div />
<Text fontWeight={500} fontSize={20}>
<Trans>Are you sure?</Trans>
</Text>
<StyledCloseIcon onClick={() => setShowConfirmation(false)} />
</RowBetween>
<Break />
<AutoColumn gap="lg" style={{ padding: '0 2rem' }}>
<Text fontWeight={500} fontSize={20}>
<Trans>
Expert mode turns off the confirm transaction prompt and allows high slippage trades that often result
in bad rates and lost funds.
</Trans>
</Text>
<Text fontWeight={600} fontSize={20}>
<Trans>ONLY USE THIS MODE IF YOU KNOW WHAT YOU ARE DOING.</Trans>
</Text>
<ButtonError
error={true}
padding="12px"
onClick={() => {
const confirmWord = t`confirm`
if (window.prompt(t`Please type the word "${confirmWord}" to enable expert mode.`) === confirmWord) {
toggleExpertMode()
setShowConfirmation(false)
}
}}
>
<Text fontSize={20} fontWeight={500} id="confirm-expert-mode">
<Trans>Turn On Expert Mode</Trans>
</Text>
</ButtonError>
</AutoColumn>
</AutoColumn>
</ModalContentWrapper>
</Modal>
<StyledMenuButton
disabled={!isSupportedChainId(chainId)}
onClick={toggle}
@@ -188,72 +105,19 @@ export default function SettingsTab({ placeholderSlippage }: { placeholderSlippa
aria-label={t`Transaction Settings`}
>
<StyledMenuIcon data-testid="swap-settings-button" />
{expertMode && (
<EmojiWrapper>
<span role="img" aria-label="wizard-icon">
🧙
</span>
</EmojiWrapper>
)}
</StyledMenuButton>
{open && (
<MenuFlyout>
<AutoColumn gap="md" style={{ padding: '1rem' }}>
<Text fontWeight={600} fontSize={14}>
<Trans>Settings</Trans>
</Text>
<TransactionSettings placeholderSlippage={placeholderSlippage} />
<Text fontWeight={600} fontSize={14}>
<Trans>Interface Settings</Trans>
</Text>
{isSupportedChainId(chainId) && (
<RowBetween>
<RowFixed>
<ThemedText.DeprecatedBlack fontWeight={400} fontSize={14} color={theme.textSecondary}>
<Trans>Auto Router API</Trans>
</ThemedText.DeprecatedBlack>
<QuestionHelper text={<Trans>Use the Uniswap Labs API to get faster quotes.</Trans>} />
</RowFixed>
<Toggle
id="toggle-optimized-router-button"
isActive={!clientSideRouter}
toggle={() => {
sendEvent({
category: 'Routing',
action: clientSideRouter ? 'enable routing API' : 'disable routing API',
})
setClientSideRouter(!clientSideRouter)
}}
/>
</RowBetween>
<AutoColumn gap="16px" style={{ padding: '1rem' }}>
{isSupportedChainId(chainId) && <RouterPreferenceSettings />}
<Divider />
<MaxSlippageSettings autoSlippage={autoSlippage} />
{showDeadlineSettings && (
<>
<Divider />
<TransactionDeadlineSettings />
</>
)}
<RowBetween>
<RowFixed>
<ThemedText.DeprecatedBlack fontWeight={400} fontSize={14} color={theme.textSecondary}>
<Trans>Expert Mode</Trans>
</ThemedText.DeprecatedBlack>
<QuestionHelper
text={
<Trans>Allow high price impact trades and skip the confirm screen. Use at your own risk.</Trans>
}
/>
</RowFixed>
<Toggle
id="toggle-expert-mode-button"
isActive={expertMode}
toggle={
expertMode
? () => {
toggleExpertMode()
setShowConfirmation(false)
}
: () => {
toggle()
setShowConfirmation(true)
}
}
/>
</RowBetween>
</AutoColumn>
</MenuFlyout>
)}

View File

@@ -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>
)

View File

@@ -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} />}

View File

@@ -1,14 +1,29 @@
import { transparentize } from 'polished'
import { ReactNode, useEffect, useState } from 'react'
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react'
import styled from 'styled-components/macro'
import noop from 'utils/noop'
import Popover, { PopoverProps } from '../Popover'
export const TooltipContainer = styled.div`
max-width: 256px;
export enum TooltipSize {
Small = '256px',
Large = '400px',
}
const getPaddingForSize = (size: TooltipSize) => {
switch (size) {
case TooltipSize.Small:
return '12px'
case TooltipSize.Large:
return '16px 20px'
}
}
const TooltipContainer = styled.div<{ size: TooltipSize }>`
max-width: ${({ size }) => size};
width: calc(100vw - 16px);
cursor: default;
padding: 0.6rem 1rem;
padding: ${({ size }) => getPaddingForSize(size)};
pointer-events: auto;
color: ${({ theme }) => theme.textPrimary};
@@ -23,30 +38,23 @@ export const TooltipContainer = styled.div`
box-shadow: 0 4px 8px 0 ${({ theme }) => transparentize(0.9, theme.shadow1)};
`
interface TooltipProps extends Omit<PopoverProps, 'content'> {
type TooltipProps = Omit<PopoverProps, 'content'> & {
text: ReactNode
open?: () => void
close?: () => void
disableHover?: boolean // disable the hover and content display
size?: TooltipSize
disabled?: boolean
timeout?: number
}
interface TooltipContentProps extends Omit<PopoverProps, 'content'> {
content: ReactNode
onOpen?: () => void
open?: () => void
close?: () => void
// whether to wrap the content in a `TooltipContainer`
wrap?: boolean
disableHover?: boolean // disable the hover and content display
}
export default function Tooltip({ text, open, close, disableHover, ...rest }: TooltipProps) {
// TODO(WEB-3305)
// Migrate to MouseoverTooltip and move this component inline to MouseoverTooltip
export default function Tooltip({ text, open, close, disabled, size = TooltipSize.Small, ...rest }: TooltipProps) {
return (
<Popover
content={
text && (
<TooltipContainer onMouseEnter={disableHover ? noop : open} onMouseLeave={disableHover ? noop : close}>
<TooltipContainer size={size} onMouseEnter={disabled ? noop : open} onMouseLeave={disabled ? noop : close}>
{text}
</TooltipContainer>
)
@@ -56,27 +64,24 @@ export default function Tooltip({ text, open, close, disableHover, ...rest }: To
)
}
function TooltipContent({ content, wrap = false, open, close, disableHover, ...rest }: TooltipContentProps) {
return (
<Popover
content={
wrap ? (
<TooltipContainer onMouseEnter={disableHover ? noop : open} onMouseLeave={disableHover ? noop : close}>
{content}
</TooltipContainer>
) : (
content
)
}
{...rest}
/>
)
}
// TODO(WEB-3305)
// Do not pass through PopoverProps. Prefer higher-level interface to control MouseoverTooltip.
type MouseoverTooltipProps = Omit<PopoverProps, 'content' | 'show'> &
PropsWithChildren<{
text: ReactNode
size?: TooltipSize
disabled?: boolean
timeout?: number
placement?: PopoverProps['placement']
onOpen?: () => void
}>
/** Standard text tooltip. */
export function MouseoverTooltip({ text, disableHover, children, timeout, ...rest }: Omit<TooltipProps, 'show'>) {
export function MouseoverTooltip({ text, disabled, children, onOpen, timeout, ...rest }: MouseoverTooltipProps) {
const [show, setShow] = useState(false)
const open = () => text && setShow(true)
const open = () => {
setShow(true)
onOpen?.()
}
const close = () => setShow(false)
useEffect(() => {
@@ -93,49 +98,10 @@ export function MouseoverTooltip({ text, disableHover, children, timeout, ...res
}, [timeout, show])
return (
<Tooltip
{...rest}
open={open}
close={close}
disableHover={disableHover}
show={show}
text={disableHover ? null : text}
>
<div onMouseEnter={disableHover ? noop : open} onMouseLeave={disableHover || timeout ? noop : close}>
<Tooltip {...rest} open={open} close={close} disabled={disabled} show={show} text={disabled ? null : text}>
<div onMouseEnter={disabled ? noop : open} onMouseLeave={disabled || timeout ? noop : close}>
{children}
</div>
</Tooltip>
)
}
/** Tooltip that displays custom content. */
export function MouseoverTooltipContent({
content,
children,
onOpen: openCallback = undefined,
disableHover,
...rest
}: Omit<TooltipContentProps, 'show'>) {
const [show, setShow] = useState(false)
const open = () => {
setShow(true)
openCallback?.()
}
const close = () => {
setShow(false)
}
return (
<TooltipContent
{...rest}
open={open}
close={close}
show={!disableHover && show}
content={disableHover ? null : content}
>
<div onMouseEnter={open} onMouseLeave={close}>
{children}
</div>
</TooltipContent>
)
}

View File

@@ -1,279 +0,0 @@
import { Trans } from '@lingui/macro'
import { Percent } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { L2_CHAIN_IDS } from 'constants/chains'
import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc'
import ms from 'ms.macro'
import { darken } from 'polished'
import { useState } from 'react'
import { useUserSlippageTolerance, useUserTransactionTTL } from 'state/user/hooks'
import styled, { useTheme } from 'styled-components/macro'
import { ThemedText } from '../../theme'
import { AutoColumn } from '../Column'
import QuestionHelper from '../QuestionHelper'
import { RowBetween, RowFixed } from '../Row'
enum SlippageError {
InvalidInput = 'InvalidInput',
}
enum DeadlineError {
InvalidInput = 'InvalidInput',
}
const FancyButton = styled.button`
color: ${({ theme }) => theme.textPrimary};
align-items: center;
height: 2rem;
border-radius: 36px;
font-size: 1rem;
width: auto;
min-width: 3.5rem;
border: 1px solid ${({ theme }) => theme.deprecated_bg3};
outline: none;
background: ${({ theme }) => theme.deprecated_bg1};
:hover {
border: 1px solid ${({ theme }) => theme.deprecated_bg4};
}
:focus {
border: 1px solid ${({ theme }) => theme.accentAction};
}
`
const Option = styled(FancyButton)<{ active: boolean }>`
margin-right: 8px;
border-radius: 12px;
:hover {
cursor: pointer;
}
background-color: ${({ active, theme }) => active && theme.accentAction};
color: ${({ active, theme }) => (active ? theme.white : theme.textPrimary)};
`
const Input = styled.input`
background: ${({ theme }) => theme.deprecated_bg1};
font-size: 16px;
border-radius: 12px;
width: auto;
outline: none;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
}
color: ${({ theme, color }) => (color === 'red' ? theme.accentFailure : theme.textPrimary)};
text-align: right;
::placeholder {
color: ${({ theme }) => theme.textTertiary};
}
`
const OptionCustom = styled(FancyButton)<{ active?: boolean; warning?: boolean }>`
height: 2rem;
position: relative;
padding: 0 0.75rem;
border-radius: 12px;
flex: 1;
border: ${({ theme, active, warning }) =>
active
? `1px solid ${warning ? theme.accentFailure : theme.accentAction}`
: warning && `1px solid ${theme.accentFailure}`};
:hover {
border: ${({ theme, active, warning }) =>
active && `1px solid ${warning ? darken(0.1, theme.accentFailure) : darken(0.1, theme.accentAction)}`};
}
input {
width: 100%;
height: 100%;
border: 0px;
border-radius: 2rem;
}
`
const SlippageEmojiContainer = styled.span`
color: #f3841e;
${({ theme }) => theme.deprecated_mediaWidth.deprecated_upToSmall`
display: none;
`}
`
interface TransactionSettingsProps {
placeholderSlippage: Percent // varies according to the context in which the settings dialog is placed
}
const THREE_DAYS_IN_SECONDS = ms`3 days` / 1000
export default function TransactionSettings({ placeholderSlippage }: TransactionSettingsProps) {
const { chainId } = useWeb3React()
const theme = useTheme()
const [userSlippageTolerance, setUserSlippageTolerance] = useUserSlippageTolerance()
const [deadline, setDeadline] = useUserTransactionTTL()
const [slippageInput, setSlippageInput] = useState('')
const [slippageError, setSlippageError] = useState<SlippageError | false>(false)
const [deadlineInput, setDeadlineInput] = useState('')
const [deadlineError, setDeadlineError] = useState<DeadlineError | false>(false)
function parseSlippageInput(value: string) {
// populate what the user typed and clear the error
setSlippageInput(value)
setSlippageError(false)
if (value.length === 0) {
setUserSlippageTolerance('auto')
} else {
const parsed = Math.floor(Number.parseFloat(value) * 100)
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 5000) {
setUserSlippageTolerance('auto')
if (value !== '.') {
setSlippageError(SlippageError.InvalidInput)
}
} else {
setUserSlippageTolerance(new Percent(parsed, 10_000))
}
}
}
const tooLow = userSlippageTolerance !== 'auto' && userSlippageTolerance.lessThan(new Percent(5, 10_000))
const tooHigh = userSlippageTolerance !== 'auto' && userSlippageTolerance.greaterThan(new Percent(1, 100))
function parseCustomDeadline(value: string) {
// populate what the user typed and clear the error
setDeadlineInput(value)
setDeadlineError(false)
if (value.length === 0) {
setDeadline(DEFAULT_DEADLINE_FROM_NOW)
} else {
try {
const parsed: number = Math.floor(Number.parseFloat(value) * 60)
if (!Number.isInteger(parsed) || parsed < 60 || parsed > THREE_DAYS_IN_SECONDS) {
setDeadlineError(DeadlineError.InvalidInput)
} else {
setDeadline(parsed)
}
} catch (error) {
console.error(error)
setDeadlineError(DeadlineError.InvalidInput)
}
}
}
const showCustomDeadlineRow = Boolean(chainId && !L2_CHAIN_IDS.includes(chainId))
return (
<AutoColumn gap="md">
<AutoColumn gap="sm">
<RowFixed>
<ThemedText.DeprecatedBlack fontWeight={400} fontSize={14} color={theme.textSecondary}>
<Trans>Slippage tolerance</Trans>
</ThemedText.DeprecatedBlack>
<QuestionHelper
text={
<Trans>Your transaction will revert if the price changes unfavorably by more than this percentage.</Trans>
}
/>
</RowFixed>
<RowBetween>
<Option
onClick={() => {
parseSlippageInput('')
}}
active={userSlippageTolerance === 'auto'}
>
<Trans>Auto</Trans>
</Option>
<OptionCustom active={userSlippageTolerance !== 'auto'} warning={!!slippageError} tabIndex={-1}>
<RowBetween>
{tooLow || tooHigh ? (
<SlippageEmojiContainer>
<span role="img" aria-label="warning">
</span>
</SlippageEmojiContainer>
) : null}
<Input
data-testid="slippage-input"
placeholder={placeholderSlippage.toFixed(2)}
value={
slippageInput.length > 0
? slippageInput
: userSlippageTolerance === 'auto'
? ''
: userSlippageTolerance.toFixed(2)
}
onChange={(e) => parseSlippageInput(e.target.value)}
onBlur={() => {
setSlippageInput('')
setSlippageError(false)
}}
color={slippageError ? 'red' : ''}
/>
%
</RowBetween>
</OptionCustom>
</RowBetween>
{slippageError || tooLow || tooHigh ? (
<RowBetween
style={{
fontSize: '14px',
paddingTop: '7px',
color: slippageError ? 'red' : '#F3841E',
}}
>
{slippageError ? (
<Trans>Enter a valid slippage percentage</Trans>
) : tooLow ? (
<Trans>Your transaction may fail</Trans>
) : (
<Trans>Your transaction may be frontrun</Trans>
)}
</RowBetween>
) : null}
</AutoColumn>
{showCustomDeadlineRow && (
<AutoColumn gap="sm">
<RowFixed>
<ThemedText.DeprecatedBlack fontSize={14} fontWeight={400} color={theme.textSecondary}>
<Trans>Transaction deadline</Trans>
</ThemedText.DeprecatedBlack>
<QuestionHelper
text={<Trans>Your transaction will revert if it is pending for more than this period of time.</Trans>}
/>
</RowFixed>
<RowFixed>
<OptionCustom style={{ width: '80px' }} warning={!!deadlineError} tabIndex={-1}>
<Input
data-testid="deadline-input"
placeholder={(DEFAULT_DEADLINE_FROM_NOW / 60).toString()}
value={
deadlineInput.length > 0
? deadlineInput
: deadline === DEFAULT_DEADLINE_FROM_NOW
? ''
: (deadline / 60).toString()
}
onChange={(e) => parseCustomDeadline(e.target.value)}
onBlur={() => {
setDeadlineInput('')
setDeadlineError(false)
}}
color={deadlineError ? 'red' : ''}
/>
</OptionCustom>
<ThemedText.DeprecatedBody style={{ paddingLeft: '8px' }} fontSize={14}>
<Trans>minutes</Trans>
</ThemedText.DeprecatedBody>
</RowFixed>
</AutoColumn>
)}
</AutoColumn>
)
}

View File

@@ -86,7 +86,7 @@ function Updater() {
}
function trace(event: any) {
if (event.action !== 'request') return
if (!event?.request) return
const { method, id, params } = event.request
console.groupCollapsed(method, id)
console.debug(params)

View File

@@ -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} />
}

View File

@@ -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
}
}

View File

@@ -1,63 +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'
/**
* 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 === 'auto' ? undefined : appSlippage.toFixed(2)
)
const onSlippageChange = useCallback(
(widgetSlippage: Slippage) => {
setWidgetSlippage(widgetSlippage.max)
if (widgetSlippage.auto || !widgetSlippage.max) {
setAppSlippage('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('auto')
}, [setAppSlippage, setAppTtl])
const settings: SwapController['settings'] = useMemo(() => {
const auto = appSlippage === '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 } }
}

View File

@@ -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,
}

View File

@@ -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 } }
}

View File

@@ -20,20 +20,18 @@ describe('AdvancedSwapDetails.tsx', () => {
it('renders correct copy on mouseover', async () => {
render(<AdvancedSwapDetails trade={TEST_TRADE_EXACT_INPUT} allowedSlippage={TEST_ALLOWED_SLIPPAGE} />)
await act(() => userEvent.hover(screen.getByText('Price Impact')))
expect(await screen.getByText(/The impact your trade has on the market price of this pool./i)).toBeVisible()
await act(() => userEvent.hover(screen.getByText('Expected Output')))
await act(() => userEvent.hover(screen.getByText('Expected output')))
expect(await screen.getByText(/The amount you expect to receive at the current market price./i)).toBeVisible()
await act(() => userEvent.hover(screen.getByText(/Minimum received/i)))
await act(() => userEvent.hover(screen.getByText(/Minimum output/i)))
expect(await screen.getByText(/The minimum amount you are guaranteed to receive./i)).toBeVisible()
})
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)
render(<AdvancedSwapDetails trade={TEST_TRADE_EXACT_OUTPUT} allowedSlippage={TEST_ALLOWED_SLIPPAGE} />)
await act(() => userEvent.hover(screen.getByText(/Maximum sent/i)))
await act(() => userEvent.hover(screen.getByText(/Minimum output/i)))
expect(await screen.getByText(/The minimum amount you are guaranteed to receive./i)).toBeVisible()
await act(() => userEvent.hover(screen.getByText('Network Fee')))
await act(() => userEvent.hover(screen.getByText('Network fee')))
expect(await screen.getByText(/The fee paid to miners who process your transaction./i)).toBeVisible()
})

View File

@@ -1,30 +1,24 @@
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 { useWeb3React } from '@web3-react/core'
import Card from 'components/Card'
import { LoadingRows } from 'components/Loader/styled'
import { SUPPORTED_GAS_ESTIMATE_CHAIN_IDS } from 'constants/chains'
import useNativeCurrency from 'lib/hooks/useNativeCurrency'
import { useMemo } from 'react'
import { InterfaceTrade } from 'state/routing/types'
import styled, { useTheme } from 'styled-components/macro'
import { Separator, ThemedText } from '../../theme'
import { computeRealizedPriceImpact } from '../../utils/prices'
import { AutoColumn } from '../Column'
import Column from '../Column'
import { RowBetween, RowFixed } from '../Row'
import { MouseoverTooltip } from '../Tooltip'
import FormattedPriceImpact from './FormattedPriceImpact'
const StyledCard = styled(Card)`
padding: 0;
`
import { MouseoverTooltip, TooltipSize } from '../Tooltip'
import RouterLabel from './RouterLabel'
import SwapRoute from './SwapRoute'
interface AdvancedSwapDetailsProps {
trade?: InterfaceTrade<Currency, Currency, TradeType>
trade: InterfaceTrade<Currency, Currency, TradeType>
allowedSlippage: Percent
syncing?: boolean
hideInfoTooltips?: boolean
}
function TextWithLoadingPlaceholder({
@@ -45,119 +39,92 @@ function TextWithLoadingPlaceholder({
)
}
export function AdvancedSwapDetails({
trade,
allowedSlippage,
syncing = false,
hideInfoTooltips = false,
}: AdvancedSwapDetailsProps) {
const theme = useTheme()
export function AdvancedSwapDetails({ trade, allowedSlippage, syncing = false }: AdvancedSwapDetailsProps) {
const { chainId } = useWeb3React()
const nativeCurrency = useNativeCurrency(chainId)
const { expectedOutputAmount, priceImpact } = useMemo(() => {
return {
expectedOutputAmount: trade?.outputAmount,
priceImpact: trade ? computeRealizedPriceImpact(trade) : undefined,
}
}, [trade])
return !trade ? null : (
<StyledCard>
<AutoColumn gap="sm">
return (
<Column gap="md">
<Separator />
{!trade.gasUseEstimateUSD || !chainId || !SUPPORTED_GAS_ESTIMATE_CHAIN_IDS.includes(chainId) ? null : (
<RowBetween>
<RowFixed>
<MouseoverTooltip
text={
<Trans>
The amount you expect to receive at the current market price. You may receive less or more if the
market price changes while your transaction is pending.
</Trans>
}
disableHover={hideInfoTooltips}
>
<ThemedText.DeprecatedSubHeader color={theme.textPrimary}>
<Trans>Expected Output</Trans>
</ThemedText.DeprecatedSubHeader>
</MouseoverTooltip>
</RowFixed>
<TextWithLoadingPlaceholder syncing={syncing} width={65}>
<ThemedText.DeprecatedBlack textAlign="right" fontSize={14}>
{expectedOutputAmount
? `${expectedOutputAmount.toSignificant(6)} ${expectedOutputAmount.currency.symbol}`
: '-'}
</ThemedText.DeprecatedBlack>
</TextWithLoadingPlaceholder>
</RowBetween>
<RowBetween>
<RowFixed>
<MouseoverTooltip
text={<Trans>The impact your trade has on the market price of this pool.</Trans>}
disableHover={hideInfoTooltips}
>
<ThemedText.DeprecatedSubHeader color={theme.textPrimary}>
<Trans>Price Impact</Trans>
</ThemedText.DeprecatedSubHeader>
</MouseoverTooltip>
</RowFixed>
<MouseoverTooltip
text={
<Trans>
The fee paid to miners who process your transaction. This must be paid in {nativeCurrency.symbol}.
</Trans>
}
>
<ThemedText.BodySmall color="textSecondary">
<Trans>Network fee</Trans>
</ThemedText.BodySmall>
</MouseoverTooltip>
<TextWithLoadingPlaceholder syncing={syncing} width={50}>
<ThemedText.DeprecatedBlack textAlign="right" fontSize={14}>
<FormattedPriceImpact priceImpact={priceImpact} />
</ThemedText.DeprecatedBlack>
<ThemedText.BodySmall>~${trade.gasUseEstimateUSD.toFixed(2)}</ThemedText.BodySmall>
</TextWithLoadingPlaceholder>
</RowBetween>
<Separator />
<RowBetween>
<RowFixed style={{ marginRight: '20px' }}>
<MouseoverTooltip
text={
<Trans>
The minimum amount you are guaranteed to receive. If the price slips any further, your transaction
will revert.
</Trans>
}
disableHover={hideInfoTooltips}
>
<ThemedText.DeprecatedSubHeader color={theme.textTertiary}>
{trade.tradeType === TradeType.EXACT_INPUT ? (
<Trans>Minimum received</Trans>
) : (
<Trans>Maximum sent</Trans>
)}{' '}
<Trans>after slippage</Trans> ({allowedSlippage.toFixed(2)}%)
</ThemedText.DeprecatedSubHeader>
</MouseoverTooltip>
</RowFixed>
<TextWithLoadingPlaceholder syncing={syncing} width={70}>
<ThemedText.DeprecatedBlack textAlign="right" fontSize={14} color={theme.textTertiary}>
{trade.tradeType === TradeType.EXACT_INPUT
? `${trade.minimumAmountOut(allowedSlippage).toSignificant(6)} ${trade.outputAmount.currency.symbol}`
: `${trade.maximumAmountIn(allowedSlippage).toSignificant(6)} ${trade.inputAmount.currency.symbol}`}
</ThemedText.DeprecatedBlack>
</TextWithLoadingPlaceholder>
</RowBetween>
{!trade?.gasUseEstimateUSD || !chainId || !SUPPORTED_GAS_ESTIMATE_CHAIN_IDS.includes(chainId) ? null : (
<RowBetween>
<MouseoverTooltip
text={
<Trans>
The fee paid to miners who process your transaction. This must be paid in {nativeCurrency.symbol}.
</Trans>
}
disableHover={hideInfoTooltips}
>
<ThemedText.DeprecatedSubHeader color={theme.textTertiary}>
<Trans>Network Fee</Trans>
</ThemedText.DeprecatedSubHeader>
</MouseoverTooltip>
<TextWithLoadingPlaceholder syncing={syncing} width={50}>
<ThemedText.DeprecatedBlack textAlign="right" fontSize={14} color={theme.textTertiary}>
~${trade.gasUseEstimateUSD.toFixed(2)}
</ThemedText.DeprecatedBlack>
</TextWithLoadingPlaceholder>
</RowBetween>
)}
</AutoColumn>
</StyledCard>
)}
<RowBetween>
<RowFixed>
<MouseoverTooltip
text={
<Trans>
The minimum amount you are guaranteed to receive. If the price slips any further, your transaction will
revert.
</Trans>
}
>
<ThemedText.BodySmall color="textSecondary">
<Trans>Minimum output</Trans>
</ThemedText.BodySmall>
</MouseoverTooltip>
</RowFixed>
<TextWithLoadingPlaceholder syncing={syncing} width={70}>
<ThemedText.BodySmall>
{trade.tradeType === TradeType.EXACT_INPUT
? `${trade.minimumAmountOut(allowedSlippage).toSignificant(6)} ${trade.outputAmount.currency.symbol}`
: `${trade.maximumAmountIn(allowedSlippage).toSignificant(6)} ${trade.inputAmount.currency.symbol}`}
</ThemedText.BodySmall>
</TextWithLoadingPlaceholder>
</RowBetween>
<RowBetween>
<RowFixed>
<MouseoverTooltip
text={
<Trans>
The amount you expect to receive at the current market price. You may receive less or more if the market
price changes while your transaction is pending.
</Trans>
}
>
<ThemedText.BodySmall color="textSecondary">
<Trans>Expected output</Trans>
</ThemedText.BodySmall>
</MouseoverTooltip>
</RowFixed>
<TextWithLoadingPlaceholder syncing={syncing} width={65}>
<ThemedText.BodySmall>
{`${trade.outputAmount.toSignificant(6)} ${trade.outputAmount.currency.symbol}`}
</ThemedText.BodySmall>
</TextWithLoadingPlaceholder>
</RowBetween>
<Separator />
<RowBetween>
<ThemedText.BodySmall color="textSecondary">
<Trans>Order routing</Trans>
</ThemedText.BodySmall>
<MouseoverTooltip
size={TooltipSize.Large}
text={<SwapRoute data-testid="swap-route-info" trade={trade} syncing={syncing} />}
onOpen={() => {
sendAnalyticsEvent(SwapEventName.SWAP_AUTOROUTER_VISUALIZATION_EXPANDED, {
element: InterfaceElementName.AUTOROUTER_VISUALIZATION_ROW,
})
}}
>
<RouterLabel />
</MouseoverTooltip>
</RowBetween>
</Column>
)
}

View File

@@ -1,35 +0,0 @@
import { useRef } from 'react'
let uniqueId = 0
const getUniqueId = () => uniqueId++
export default function AutoRouterIcon({ className, id }: { className?: string; id?: string }) {
const componentIdRef = useRef(id ?? getUniqueId())
const componentId = `AutoRouterIconGradient${componentIdRef.current}`
return (
<svg
width="23"
height="20"
viewBox="0 0 23 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<defs>
<linearGradient id={componentId} x1="0" y1="0" x2="1" y2="0" gradientTransform="rotate(95)">
<stop id="stop1" offset="0" stopColor="#2274E2" />
<stop id="stop1" offset="0.5" stopColor="#2274E2" />
<stop id="stop2" offset="1" stopColor="#3FB672" />
</linearGradient>
</defs>
<path
d="M16 16C10 16 9 10 5 10M16 16C16 17.6569 17.3431 19 19 19C20.6569 19 22 17.6569 22 16C22 14.3431 20.6569 13 19 13C17.3431 13 16 14.3431 16 16ZM5 10C9 10 10 4 16 4M5 10H1.5M16 4C16 5.65685 17.3431 7 19 7C20.6569 7 22 5.65685 22 4C22 2.34315 20.6569 1 19 1C17.3431 1 16 2.34315 16 4Z"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke={`url(#${componentId})`}
/>
</svg>
)
}

View File

@@ -1,17 +0,0 @@
import { Percent } from '@uniswap/sdk-core'
import { warningSeverity } from '../../utils/prices'
import { ErrorText } from './styleds'
export const formatPriceImpact = (priceImpact: Percent) => `${priceImpact.multiply(-1).toFixed(2)}%`
/**
* Formatted version of price impact text with warning colors
*/
export default function FormattedPriceImpact({ priceImpact }: { priceImpact?: Percent }) {
return (
<ErrorText fontWeight={500} fontSize={14} severity={warningSeverity(priceImpact)}>
{priceImpact ? formatPriceImpact(priceImpact) : '-'}
</ErrorText>
)
}

View File

@@ -1,102 +0,0 @@
import { Trans } from '@lingui/macro'
import { Currency, TradeType } from '@uniswap/sdk-core'
import { sendEvent } from 'components/analytics'
import { AutoColumn } from 'components/Column'
import { LoadingOpacityContainer } from 'components/Loader/styled'
import { RowFixed } from 'components/Row'
import { MouseoverTooltipContent } from 'components/Tooltip'
import { InterfaceTrade } from 'state/routing/types'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
import { ReactComponent as GasIcon } from '../../assets/images/gas-icon.svg'
import { ResponsiveTooltipContainer } from './styleds'
import SwapRoute from './SwapRoute'
const GasWrapper = styled(RowFixed)`
border-radius: 8px;
padding: 4px 6px;
height: 24px;
color: ${({ theme }) => theme.textTertiary};
background-color: ${({ theme }) => theme.deprecated_bg1};
font-size: 14px;
font-weight: 500;
user-select: none;
`
const StyledGasIcon = styled(GasIcon)`
margin-right: 4px;
height: 14px;
& > * {
stroke: ${({ theme }) => theme.textTertiary};
}
`
export default function GasEstimateBadge({
trade,
loading,
showRoute,
disableHover,
}: {
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined | null // dollar amount in active chain's stablecoin
loading: boolean
showRoute?: boolean // show route instead of gas estimation summary
disableHover?: boolean
}) {
const formattedGasPriceString = trade?.gasUseEstimateUSD
? trade.gasUseEstimateUSD.toFixed(2) === '0.00'
? '<$0.01'
: '$' + trade.gasUseEstimateUSD.toFixed(2)
: undefined
return (
<MouseoverTooltipContent
wrap={false}
disableHover={disableHover}
content={
loading ? null : (
<ResponsiveTooltipContainer
origin="top right"
style={{
padding: showRoute ? '0' : '12px',
border: 'none',
borderRadius: showRoute ? '16px' : '12px',
maxWidth: '400px',
}}
>
{showRoute ? (
trade ? (
<SwapRoute trade={trade} syncing={loading} fixedOpen={showRoute} />
) : null
) : (
<AutoColumn gap="4px" justify="center">
<ThemedText.DeprecatedMain fontSize="12px" textAlign="center">
<Trans>Estimated network fee</Trans>
</ThemedText.DeprecatedMain>
<ThemedText.DeprecatedBody textAlign="center" fontWeight={500} style={{ userSelect: 'none' }}>
<Trans>${trade?.gasUseEstimateUSD?.toFixed(2)}</Trans>
</ThemedText.DeprecatedBody>
<ThemedText.DeprecatedMain fontSize="10px" textAlign="center" maxWidth="140px" color="text3">
<Trans>Estimate may differ due to your wallet gas settings</Trans>
</ThemedText.DeprecatedMain>
</AutoColumn>
)}
</ResponsiveTooltipContainer>
)
}
placement="bottom"
onOpen={() =>
sendEvent({
category: 'Gas',
action: 'Gas Details Tooltip Open',
})
}
>
<LoadingOpacityContainer $loading={loading}>
<GasWrapper>
<StyledGasIcon />
{formattedGasPriceString ?? null}
</GasWrapper>
</LoadingOpacityContainer>
</MouseoverTooltipContent>
)
}

View File

@@ -0,0 +1,63 @@
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'
import { InterfaceTrade } from 'state/routing/types'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
import { ReactComponent as GasIcon } from '../../assets/images/gas-icon.svg'
import SwapRoute from './SwapRoute'
const StyledGasIcon = styled(GasIcon)`
margin-right: 4px;
height: 18px;
// We apply the following to all children of the SVG in order to override the default color
& > * {
stroke: ${({ theme }) => theme.textTertiary};
}
`
export default function GasEstimateTooltip({
trade,
loading,
disabled,
}: {
trade: InterfaceTrade<Currency, Currency, TradeType> // dollar amount in active chain's stablecoin
loading: boolean
disabled?: boolean
}) {
const formattedGasPriceString = trade?.gasUseEstimateUSD
? trade.gasUseEstimateUSD.toFixed(2) === '0.00'
? '<$0.01'
: '$' + trade.gasUseEstimateUSD.toFixed(2)
: undefined
return (
<MouseoverTooltip
disabled={disabled}
size={TooltipSize.Large}
// TODO(WEB-3304)
// Most of Swap-related components accept either `syncing`, `loading` or both props at the same time.
// We are often using them interchangeably, or pass both values as one of them (`syncing={loading || syncing}`).
// This is confusing and can lead to unpredicted UI behavior. We should refactor and unify this.
text={<SwapRoute trade={trade} syncing={loading} />}
onOpen={() => {
sendAnalyticsEvent(SwapEventName.SWAP_AUTOROUTER_VISUALIZATION_EXPANDED, {
element: InterfaceElementName.AUTOROUTER_VISUALIZATION_ROW,
})
}}
placement="bottom"
>
<LoadingOpacityContainer $loading={loading}>
<RowFixed>
<StyledGasIcon />
<ThemedText.BodySmall color="textSecondary">{formattedGasPriceString}</ThemedText.BodySmall>
</RowFixed>
</LoadingOpacityContainer>
</MouseoverTooltip>
)
}

View File

@@ -8,7 +8,6 @@ import { ThemedText } from '../../theme'
import { AutoColumn } from '../Column'
import { RowBetween, RowFixed } from '../Row'
import { MouseoverTooltip } from '../Tooltip'
import { formatPriceImpact } from './FormattedPriceImpact'
const StyledCard = styled(OutlineCard)`
padding: 12px;
@@ -19,6 +18,8 @@ interface PriceImpactWarningProps {
priceImpact: Percent
}
const formatPriceImpact = (priceImpact: Percent) => `${priceImpact.multiply(-1).toFixed(2)}%`
export default function PriceImpactWarning({ priceImpact }: PriceImpactWarningProps) {
const theme = useTheme()

View File

@@ -1,58 +1,15 @@
import { Trans } from '@lingui/macro'
import useAutoRouterSupported from 'hooks/useAutoRouterSupported'
import styled from 'styled-components/macro'
import { RouterPreference } from 'state/routing/slice'
import { useRouterPreference } from 'state/user/hooks'
import { ThemedText } from 'theme'
import { ReactComponent as StaticRouterIcon } from '../../assets/svg/static_route.svg'
import AutoRouterIcon from './AutoRouterIcon'
export default function RouterLabel() {
const [routerPreference] = useRouterPreference()
const StyledAutoRouterIcon = styled(AutoRouterIcon)`
height: 16px;
width: 16px;
:hover {
filter: brightness(1.3);
switch (routerPreference) {
case RouterPreference.AUTO:
case RouterPreference.API:
return <ThemedText.BodySmall>Uniswap API</ThemedText.BodySmall>
case RouterPreference.CLIENT:
return <ThemedText.BodySmall>Uniswap Client</ThemedText.BodySmall>
}
`
const StyledStaticRouterIcon = styled(StaticRouterIcon)`
height: 16px;
width: 16px;
fill: ${({ theme }) => theme.textTertiary};
:hover {
filter: brightness(1.3);
}
`
const StyledAutoRouterLabel = styled(ThemedText.DeprecatedBlack)`
line-height: 1rem;
/* fallback color */
color: ${({ theme }) => theme.accentSuccess};
@supports (-webkit-background-clip: text) and (-webkit-text-fill-color: transparent) {
background-image: linear-gradient(90deg, #2172e5 0%, #54e521 163.16%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
`
export function AutoRouterLogo() {
const autoRouterSupported = useAutoRouterSupported()
return autoRouterSupported ? <StyledAutoRouterIcon /> : <StyledStaticRouterIcon />
}
export function AutoRouterLabel() {
const autoRouterSupported = useAutoRouterSupported()
return autoRouterSupported ? (
<StyledAutoRouterLabel fontSize={14}>Auto Router</StyledAutoRouterLabel>
) : (
<ThemedText.DeprecatedBlack fontSize={14}>
<Trans>Trade Route</Trans>
</ThemedText.DeprecatedBlack>
)
}

View File

@@ -4,7 +4,7 @@ import { BrowserEvent, InterfaceElementName, SharedEventName } from '@uniswap/an
import { useWeb3React } from '@web3-react/core'
import { useAccountDrawer } from 'components/AccountDrawer'
import { ButtonText } from 'components/Button'
import { MouseoverTooltipContent } from 'components/Tooltip'
import { MouseoverTooltip } from 'components/Tooltip'
import { useCallback, useEffect, useState } from 'react'
import { useBuyFiatFlowCompleted } from 'state/user/hooks'
import styled from 'styled-components/macro'
@@ -109,9 +109,8 @@ export default function SwapBuyFiatButton() {
!fiatOnrampAvailabilityChecked || (fiatOnrampAvailabilityChecked && fiatOnrampAvailable)
return (
<MouseoverTooltipContent
wrap
content={
<MouseoverTooltip
text={
<div data-testid="fiat-on-ramp-unavailable-tooltip">
<Trans>Crypto purchases are not available in your region. </Trans>
<TraceEvent
@@ -126,7 +125,7 @@ export default function SwapBuyFiatButton() {
</div>
}
placement="bottom"
disableHover={fiatOnRampsUnavailableTooltipDisabled}
disabled={fiatOnRampsUnavailableTooltipDisabled}
>
<TraceEvent
events={[BrowserEvent.onClick]}
@@ -139,6 +138,6 @@ export default function SwapBuyFiatButton() {
{!buyFiatFlowCompleted && <Dot data-testid="buy-fiat-flow-incomplete-indicator" />}
</StyledTextButton>
</TraceEvent>
</MouseoverTooltipContent>
</MouseoverTooltip>
)
}

View File

@@ -38,6 +38,5 @@ describe('SwapDetailsDropdown.tsx', () => {
expect(screen.getByTestId('trade-price-container')).toBeInTheDocument()
await act(() => userEvent.click(screen.getByTestId('swap-details-header-row')))
expect(screen.getByTestId('advanced-swap-details')).toBeInTheDocument()
expect(screen.getByTestId('swap-route-info')).toBeInTheDocument()
})
})

View File

@@ -4,10 +4,9 @@ import { BrowserEvent, InterfaceElementName, SwapEventName } from '@uniswap/anal
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import AnimatedDropdown from 'components/AnimatedDropdown'
import { OutlineCard } from 'components/Card'
import { AutoColumn } from 'components/Column'
import Column from 'components/Column'
import { LoadingOpacityContainer } from 'components/Loader/styled'
import Row, { RowBetween, RowFixed } from 'components/Row'
import { RowBetween, RowFixed } from 'components/Row'
import { SUPPORTED_GAS_ESTIMATE_CHAIN_IDS } from 'constants/chains'
import { useState } from 'react'
import { ChevronDown } from 'react-feather'
@@ -16,24 +15,9 @@ import styled, { keyframes, useTheme } from 'styled-components/macro'
import { ThemedText } from 'theme'
import { AdvancedSwapDetails } from './AdvancedSwapDetails'
import GasEstimateBadge from './GasEstimateBadge'
import SwapRoute from './SwapRoute'
import GasEstimateTooltip from './GasEstimateTooltip'
import TradePrice from './TradePrice'
const Wrapper = styled(Row)`
width: 100%;
justify-content: center;
border-radius: inherit;
padding: 8px 12px;
margin-top: 0;
min-height: 32px;
`
const StyledCard = styled(OutlineCard)`
padding: 12px;
border: 1px solid ${({ theme }) => theme.backgroundOutline};
`
const StyledHeaderRow = styled(RowBetween)<{ disabled: boolean; open: boolean }>`
padding: 0;
align-items: center;
@@ -97,6 +81,16 @@ const Spinner = styled.div`
top: -3px;
`
const SwapDetailsWrapper = styled.div`
padding-top: ${({ theme }) => theme.grids.md};
`
const Wrapper = styled(Column)`
border: 1px solid ${({ theme }) => theme.backgroundOutline};
border-radius: 16px;
padding: 12px 16px;
`
interface SwapDetailsInlineProps {
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined
syncing: boolean
@@ -110,68 +104,58 @@ export default function SwapDetailsDropdown({ trade, syncing, loading, allowedSl
const [showDetails, setShowDetails] = useState(false)
return (
<Wrapper style={{ marginTop: '0' }}>
<AutoColumn gap="sm" style={{ width: '100%', marginBottom: '-8px' }}>
<TraceEvent
events={[BrowserEvent.onClick]}
name={SwapEventName.SWAP_DETAILS_EXPANDED}
element={InterfaceElementName.SWAP_DETAILS_DROPDOWN}
shouldLogImpression={!showDetails}
<Wrapper>
<TraceEvent
events={[BrowserEvent.onClick]}
name={SwapEventName.SWAP_DETAILS_EXPANDED}
element={InterfaceElementName.SWAP_DETAILS_DROPDOWN}
shouldLogImpression={!showDetails}
>
<StyledHeaderRow
data-testid="swap-details-header-row"
onClick={() => setShowDetails(!showDetails)}
disabled={!trade}
open={showDetails}
>
<StyledHeaderRow
data-testid="swap-details-header-row"
onClick={() => setShowDetails(!showDetails)}
disabled={!trade}
open={showDetails}
>
<RowFixed style={{ position: 'relative' }} align="center">
{Boolean(loading || syncing) && (
<StyledPolling>
<StyledPollingDot>
<Spinner />
</StyledPollingDot>
</StyledPolling>
)}
{trade ? (
<LoadingOpacityContainer $loading={syncing} data-testid="trade-price-container">
<TradePrice price={trade.executionPrice} />
</LoadingOpacityContainer>
) : loading || syncing ? (
<ThemedText.DeprecatedMain fontSize={14}>
<Trans>Fetching best price...</Trans>
</ThemedText.DeprecatedMain>
) : null}
</RowFixed>
<RowFixed>
{!trade?.gasUseEstimateUSD ||
showDetails ||
!chainId ||
!SUPPORTED_GAS_ESTIMATE_CHAIN_IDS.includes(chainId) ? null : (
<GasEstimateBadge
trade={trade}
loading={syncing || loading}
showRoute={!showDetails}
disableHover={showDetails}
/>
)}
<RotatingArrow
stroke={trade ? theme.textTertiary : theme.deprecated_bg3}
open={Boolean(trade && showDetails)}
/>
</RowFixed>
</StyledHeaderRow>
</TraceEvent>
<AnimatedDropdown open={showDetails}>
<AutoColumn gap="sm" style={{ padding: '0', paddingBottom: '8px' }}>
<RowFixed>
{Boolean(loading || syncing) && (
<StyledPolling>
<StyledPollingDot>
<Spinner />
</StyledPollingDot>
</StyledPolling>
)}
{trade ? (
<StyledCard data-testid="advanced-swap-details">
<AdvancedSwapDetails trade={trade} allowedSlippage={allowedSlippage} syncing={syncing} />
</StyledCard>
<LoadingOpacityContainer $loading={syncing} data-testid="trade-price-container">
<TradePrice price={trade.executionPrice} />
</LoadingOpacityContainer>
) : loading || syncing ? (
<ThemedText.DeprecatedMain fontSize={14}>
<Trans>Fetching best price...</Trans>
</ThemedText.DeprecatedMain>
) : null}
{trade ? <SwapRoute data-testid="swap-route-info" trade={trade} syncing={syncing} /> : null}
</AutoColumn>
</RowFixed>
<RowFixed>
{!trade?.gasUseEstimateUSD ||
showDetails ||
!chainId ||
!SUPPORTED_GAS_ESTIMATE_CHAIN_IDS.includes(chainId) ? null : (
<GasEstimateTooltip trade={trade} loading={syncing || loading} disabled={showDetails} />
)}
<RotatingArrow
stroke={trade ? theme.textTertiary : theme.deprecated_bg3}
open={Boolean(trade && showDetails)}
/>
</RowFixed>
</StyledHeaderRow>
</TraceEvent>
{trade && (
<AnimatedDropdown open={showDetails}>
<SwapDetailsWrapper data-testid="advanced-swap-details">
<AdvancedSwapDetails trade={trade} allowedSlippage={allowedSlippage} syncing={syncing} />
</SwapDetailsWrapper>
</AnimatedDropdown>
</AutoColumn>
)}
</Wrapper>
)
}

View File

@@ -25,7 +25,7 @@ const TextHeader = styled.div`
align-items: center;
`
export default function SwapHeader({ allowedSlippage }: { allowedSlippage: Percent }) {
export default function SwapHeader({ autoSlippage }: { autoSlippage: Percent }) {
const fiatOnRampButtonEnabled = useFiatOnRampButtonEnabled()
return (
@@ -38,7 +38,7 @@ export default function SwapHeader({ allowedSlippage }: { allowedSlippage: Perce
{fiatOnRampButtonEnabled && <SwapBuyFiatButton />}
</RowFixed>
<RowFixed>
<SettingsTab placeholderSlippage={allowedSlippage} />
<SettingsTab autoSlippage={autoSlippage} />
</RowFixed>
</RowBetween>
</StyledSwapHeader>

View File

@@ -13,14 +13,15 @@ import {
} from 'lib/utils/analytics'
import { ReactNode } from 'react'
import { Text } from 'rebass'
import { RouterPreference } from 'state/routing/slice'
import { InterfaceTrade } from 'state/routing/types'
import { useClientSideRouter, useUserSlippageTolerance } from 'state/user/hooks'
import { useRouterPreference, useUserSlippageTolerance } from 'state/user/hooks'
import getRoutingDiagramEntries, { RoutingDiagramEntry } from 'utils/getRoutingDiagramEntries'
import { computeRealizedPriceImpact } from 'utils/prices'
import { ButtonError } from '../Button'
import { AutoRow } from '../Row'
import { SwapCallbackError } from './styleds'
import { getTokenPath, RoutingDiagramEntry } from './SwapRoute'
interface AnalyticsEventProps {
trade: InterfaceTrade<Currency, Currency, TradeType>
@@ -123,8 +124,8 @@ export default function SwapModalFooter({
}) {
const transactionDeadlineSecondsSinceEpoch = useTransactionDeadline()?.toNumber() // in seconds since epoch
const isAutoSlippage = useUserSlippageTolerance()[0] === 'auto'
const [clientSideRouter] = useClientSideRouter()
const routes = getTokenPath(trade)
const [routerPreference] = useRouterPreference()
const routes = getRoutingDiagramEntries(trade)
return (
<>
@@ -139,7 +140,7 @@ export default function SwapModalFooter({
allowedSlippage,
transactionDeadlineSecondsSinceEpoch,
isAutoSlippage,
isAutoRouterApi: !clientSideRouter,
isAutoRouterApi: routerPreference === RouterPreference.AUTO || routerPreference === RouterPreference.API,
swapQuoteReceivedDate,
routes,
fiatValueInput: fiatValueInput.data,

View File

@@ -1,157 +1,71 @@
import { Trans } from '@lingui/macro'
import { TraceEvent } from '@uniswap/analytics'
import { BrowserEvent, InterfaceElementName, SwapEventName } from '@uniswap/analytics-events'
import { Protocol } from '@uniswap/router-sdk'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { Pair } from '@uniswap/v2-sdk'
import { FeeAmount } from '@uniswap/v3-sdk'
import { Currency, TradeType } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import AnimatedDropdown from 'components/AnimatedDropdown'
import { AutoColumn } from 'components/Column'
import Column from 'components/Column'
import { LoadingRows } from 'components/Loader/styled'
import RoutingDiagram from 'components/RoutingDiagram/RoutingDiagram'
import { AutoRow, RowBetween } from 'components/Row'
import { SUPPORTED_GAS_ESTIMATE_CHAIN_IDS } from 'constants/chains'
import useAutoRouterSupported from 'hooks/useAutoRouterSupported'
import { memo, useState } from 'react'
import { Plus } from 'react-feather'
import { InterfaceTrade } from 'state/routing/types'
import styled from 'styled-components/macro'
import { Separator, ThemedText } from 'theme'
import { useDarkModeManager } from 'theme/components/ThemeToggle'
import getRoutingDiagramEntries from 'utils/getRoutingDiagramEntries'
import { AutoRouterLabel, AutoRouterLogo } from './RouterLabel'
import RouterLabel from './RouterLabel'
const Wrapper = styled(AutoColumn)<{ darkMode?: boolean; fixedOpen?: boolean }>`
padding: ${({ fixedOpen }) => (fixedOpen ? '12px' : '12px 8px 12px 12px')};
border-radius: 16px;
border: 1px solid ${({ theme, fixedOpen }) => (fixedOpen ? 'transparent' : theme.backgroundOutline)};
cursor: pointer;
`
const OpenCloseIcon = styled(Plus)<{ open?: boolean }>`
margin-left: 8px;
height: 20px;
stroke-width: 2px;
transition: transform 0.1s;
transform: ${({ open }) => (open ? 'rotate(45deg)' : 'none')};
stroke: ${({ theme }) => theme.textTertiary};
cursor: pointer;
:hover {
opacity: 0.8;
}
`
interface SwapRouteProps extends React.HTMLAttributes<HTMLDivElement> {
export default function SwapRoute({
trade,
syncing,
}: {
trade: InterfaceTrade<Currency, Currency, TradeType>
syncing: boolean
fixedOpen?: boolean // fixed in open state, hide open/close icon
}
export default memo(function SwapRoute({ trade, syncing, fixedOpen = false, ...rest }: SwapRouteProps) {
const autoRouterSupported = useAutoRouterSupported()
const routes = getTokenPath(trade)
const [open, setOpen] = useState(false)
}) {
const { chainId } = useWeb3React()
const autoRouterSupported = useAutoRouterSupported()
const [darkMode] = useDarkModeManager()
const routes = getRoutingDiagramEntries(trade)
const formattedGasPriceString = trade?.gasUseEstimateUSD
? trade.gasUseEstimateUSD.toFixed(2) === '0.00'
? '<$0.01'
: '$' + trade.gasUseEstimateUSD.toFixed(2)
: undefined
const gasPrice =
// 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'
? '<$0.01'
: '$' + trade.gasUseEstimateUSD.toFixed(2)
: undefined
return (
<Wrapper {...rest} darkMode={darkMode} fixedOpen={fixedOpen}>
<TraceEvent
events={[BrowserEvent.onClick]}
name={SwapEventName.SWAP_AUTOROUTER_VISUALIZATION_EXPANDED}
element={InterfaceElementName.AUTOROUTER_VISUALIZATION_ROW}
shouldLogImpression={!open}
>
<RowBetween onClick={() => setOpen(!open)}>
<AutoRow gap="4px" width="auto">
<AutoRouterLogo />
<AutoRouterLabel />
</AutoRow>
{fixedOpen ? null : <OpenCloseIcon open={open} />}
</RowBetween>
</TraceEvent>
<AnimatedDropdown open={open || fixedOpen}>
<AutoRow gap="4px" width="auto" style={{ paddingTop: '12px', margin: 0 }}>
<Column gap="md">
<RouterLabel />
<Separator />
{syncing ? (
<LoadingRows>
<div style={{ width: '100%', height: '30px' }} />
</LoadingRows>
) : (
<RoutingDiagram
currencyIn={trade.inputAmount.currency}
currencyOut={trade.outputAmount.currency}
routes={routes}
/>
)}
{autoRouterSupported && (
<>
<Separator />
{syncing ? (
<LoadingRows>
<div style={{ width: '400px', height: '30px' }} />
<div style={{ width: '100%', height: '15px' }} />
</LoadingRows>
) : (
<RoutingDiagram
currencyIn={trade.inputAmount.currency}
currencyOut={trade.outputAmount.currency}
routes={routes}
/>
<ThemedText.Caption color="textSecondary">
{gasPrice ? <Trans>Best price route costs ~{gasPrice} in gas.</Trans> : null}{' '}
<Trans>
This route optimizes your total output by considering split routes, multiple hops, and the gas cost of
each step.
</Trans>
</ThemedText.Caption>
)}
{autoRouterSupported && (
<>
<Separator />
{syncing ? (
<LoadingRows>
<div style={{ width: '250px', height: '15px' }} />
</LoadingRows>
) : (
<ThemedText.DeprecatedMain fontSize={12} width={400} margin={0}>
{trade?.gasUseEstimateUSD && chainId && SUPPORTED_GAS_ESTIMATE_CHAIN_IDS.includes(chainId) ? (
<Trans>Best price route costs ~{formattedGasPriceString} in gas. </Trans>
) : null}{' '}
<Trans>
This route optimizes your total output by considering split routes, multiple hops, and the gas cost
of each step.
</Trans>
</ThemedText.DeprecatedMain>
)}
</>
)}
</AutoRow>
</AnimatedDropdown>
</Wrapper>
</>
)}
</Column>
)
})
export interface RoutingDiagramEntry {
percent: Percent
path: [Currency, Currency, FeeAmount][]
protocol: Protocol
}
const V2_DEFAULT_FEE_TIER = 3000
/**
* Loops through all routes on a trade and returns an array of diagram entries.
*/
export function getTokenPath(trade: InterfaceTrade<Currency, Currency, TradeType>): RoutingDiagramEntry[] {
return trade.swaps.map(({ route: { path: tokenPath, pools, protocol }, inputAmount, outputAmount }) => {
const portion =
trade.tradeType === TradeType.EXACT_INPUT
? inputAmount.divide(trade.inputAmount)
: outputAmount.divide(trade.outputAmount)
const percent = new Percent(portion.numerator, portion.denominator)
const path: RoutingDiagramEntry['path'] = []
for (let i = 0; i < pools.length; i++) {
const nextPool = pools[i]
const tokenIn = tokenPath[i]
const tokenOut = tokenPath[i + 1]
const entry: RoutingDiagramEntry['path'][0] = [
tokenIn,
tokenOut,
nextPool instanceof Pair ? V2_DEFAULT_FEE_TIER : nextPool.fee,
]
path.push(entry)
}
return {
percent,
path,
protocol,
}
})
}

View 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()
})
})

View 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>
)
}

View File

@@ -25,7 +25,6 @@ const StyledPriceContainer = styled.button`
flex-direction: row;
text-align: left;
flex-wrap: wrap;
padding: 8px 0;
user-select: text;
`
@@ -60,9 +59,9 @@ export default function TradePrice({ price }: TradePriceProps) {
>
<ThemedText.BodySmall>{text}</ThemedText.BodySmall>{' '}
{usdPrice && (
<ThemedText.DeprecatedDarkGray>
<ThemedText.BodySmall color="textSecondary">
<Trans>({formatNumber(usdPrice, NumberType.FiatTokenPrice)})</Trans>
</ThemedText.DeprecatedDarkGray>
</ThemedText.BodySmall>
)}
</StyledPriceContainer>
)

View File

@@ -2,13 +2,13 @@
exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
<DocumentFragment>
.c0 {
.c2 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c4 {
.c3 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
@@ -25,138 +25,127 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
justify-content: flex-start;
}
.c5 {
.c4 {
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
}
.c6 {
.c5 {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c7 {
color: #7780A0;
}
.c8 {
color: #0D111C;
}
.c10 {
.c1 {
width: 100%;
height: 1px;
background-color: #D2D9EE;
}
.c1 {
width: 100%;
padding: 1rem;
border-radius: 16px;
.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;
}
.c3 {
display: grid;
grid-auto-rows: auto;
grid-row-gap: 8px;
}
.c7 {
.c6 {
display: inline-block;
height: inherit;
}
.c9 {
color: #7780A0;
}
.c2 {
padding: 0;
}
<div
class="c0 c1 c2"
class="c0"
>
<div
class="c3"
class="c1"
/>
<div
class="c2 c3 c4"
>
<div
class="c0 c4 c5"
class="c2 c3 c5"
>
<div
class="c0 c4 c6"
class="c6"
>
<div
class="c7"
>
<div>
<div
class="css-zhpkf8"
>
Expected Output
</div>
<div>
<div
class="c7 css-zhpkf8"
>
Minimum output
</div>
</div>
</div>
<div
class="c8 css-q4yjm0"
>
0.000000000000001 DEF
</div>
</div>
<div
class="c0 c4 c5"
>
<div
class="c0 c4 c6"
>
<div
class="c7"
>
<div>
<div
class="css-zhpkf8"
>
Price Impact
</div>
</div>
</div>
</div>
<div
class="c8 css-q4yjm0"
>
<div
class="c9 css-1aekuku"
>
105567.37%
</div>
</div>
</div>
<div
class="c10"
/>
class="c8 css-zhpkf8"
>
0.00000000000000098 DEF
</div>
</div>
<div
class="c2 c3 c4"
>
<div
class="c0 c4 c5"
class="c2 c3 c5"
>
<div
class="c0 c4 c6"
style="margin-right: 20px;"
class="c6"
>
<div
class="c7"
>
<div>
<div
class="css-zhpkf8"
>
Minimum received after slippage (2.00%)
</div>
<div>
<div
class="c7 css-zhpkf8"
>
Expected output
</div>
</div>
</div>
<div
class="css-q4yjm0"
>
0.00000000000000098 DEF
</div>
<div
class="c8 css-zhpkf8"
>
0.000000000000001 DEF
</div>
</div>
<div
class="c1"
/>
<div
class="c2 c3 c4"
>
<div
class="c7 css-zhpkf8"
>
Order routing
</div>
<div
class="c6"
>
<div>
<div
class="c8 css-zhpkf8"
>
Uniswap API
</div>
</div>
</div>
</div>

View File

@@ -2,27 +2,13 @@
exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
<DocumentFragment>
.c0 {
.c2 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c20 {
box-sizing: border-box;
margin: 0;
min-width: 0;
width: auto;
}
.c37 {
box-sizing: border-box;
margin: 0;
min-width: 0;
width: 100%;
}
.c1 {
.c3 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
@@ -39,42 +25,6 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
justify-content: flex-start;
}
.c21 {
width: auto;
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: 4px;
}
.c38 {
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: 1px;
}
.c4 {
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
@@ -82,28 +32,6 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
justify-content: space-between;
}
.c22 {
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
margin: -4px;
}
.c22 > * {
margin: 4px !important;
}
.c39 {
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
margin: -1px;
}
.c39 > * {
margin: 1px !important;
}
.c6 {
width: -webkit-fit-content;
width: -moz-fit-content;
@@ -114,35 +42,43 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
color: #0D111C;
}
.c44 {
.c15 {
color: #7780A0;
}
.c17 {
.c13 {
width: 100%;
height: 1px;
background-color: #D2D9EE;
}
.c11 {
width: 100%;
padding: 1rem;
border-radius: 16px;
.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;
}
.c12 {
border: 1px solid #B8C0DC;
}
.c3 {
display: grid;
grid-auto-rows: auto;
grid-row-gap: 8px;
}
.c18 {
display: grid;
grid-auto-rows: auto;
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 {
@@ -153,201 +89,11 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
transition: opacity 0.2s ease-in-out;
}
.c15 {
.c14 {
display: inline-block;
height: inherit;
}
.c16 {
color: #7780A0;
}
.c14 {
padding: 0;
}
.c33 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
background: #E8ECFB;
border: unset;
border-radius: 0.5rem;
color: #000;
display: -webkit-inline-box;
display: -webkit-inline-flex;
display: -ms-inline-flexbox;
display: inline-flex;
padding: 4px 6px;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
font-weight: 500;
}
.c29 {
width: 20px;
height: 20px;
border-radius: 50%;
background: radial-gradient(white 60%,#ffffff00 calc(70% + 1px));
box-shadow: 0 0 1px white;
}
.c28 {
position: relative;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
}
.c41 {
position: relative;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
}
.c42 {
z-index: 1;
}
.c43 {
position: absolute;
left: -10px !important;
}
.c26 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
width: 100%;
}
.c27 {
display: grid;
grid-template-columns: 24px 1fr 24px;
}
.c30 {
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
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;
padding: 0.1rem 0.5rem;
position: relative;
}
.c40 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 4px 4px;
}
.c31 {
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;
position: absolute;
width: calc(100%);
z-index: 1;
opacity: 0.5;
}
.c32 path {
stroke: #98A1C0;
}
.c34 {
background-color: #E8ECFB;
border-radius: 8px;
display: grid;
font-size: 12px;
grid-gap: 4px;
grid-auto-flow: column;
-webkit-box-pack: start;
-webkit-justify-content: start;
-ms-flex-pack: start;
justify-content: start;
padding: 4px 6px 4px 4px;
z-index: 1020;
}
.c35 {
background-color: #B8C0DC;
border-radius: 4px;
color: #7780A0;
font-size: 10px;
padding: 2px 4px;
z-index: 1021;
}
.c36 {
word-break: normal;
}
.c23 {
height: 16px;
width: 16px;
}
.c23:hover {
-webkit-filter: brightness(1.3);
filter: brightness(1.3);
}
.c24 {
line-height: 1rem;
color: #40B66B;
}
.c19 {
padding: 12px 8px 12px 12px;
border-radius: 16px;
border: 1px solid #D2D9EE;
cursor: pointer;
}
.c25 {
margin-left: 8px;
height: 20px;
stroke-width: 2px;
-webkit-transition: -webkit-transform 0.1s;
-webkit-transition: transform 0.1s;
transition: transform 0.1s;
-webkit-transform: none;
-ms-transform: none;
transform: none;
stroke: #98A1C0;
cursor: pointer;
}
.c25:hover {
opacity: 0.8;
}
.c8 {
background-color: transparent;
border: none;
@@ -374,30 +120,12 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
padding: 8px 0;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
.c2 {
width: 100%;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
border-radius: inherit;
padding: 8px 12px;
margin-top: 0;
min-height: 32px;
}
.c13 {
padding: 12px;
border: 1px solid #D2D9EE;
}
.c5 {
padding: 0;
-webkit-align-items: center;
@@ -416,384 +144,145 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
transition: transform 0.1s linear;
}
@supports (-webkit-background-clip:text) and (-webkit-text-fill-color:transparent) {
.c24 {
background-image: linear-gradient(90deg,#2172e5 0%,#54e521 163.16%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.c11 {
padding-top: 12px;
}
.c1 {
border: 1px solid #D2D9EE;
border-radius: 16px;
padding: 12px 16px;
}
<div
class="c0 c1 c2"
style="margin-top: 0px;"
class="c0 c1"
>
<div
class="c3"
style="width: 100%; margin-bottom: -8px;"
class="c2 c3 c4 c5"
data-testid="swap-details-header-row"
>
<div
class="c0 c1 c4 c5"
data-testid="swap-details-header-row"
class="c2 c3 c6"
>
<div
class="c0 c1 c6"
style="position: relative;"
class="c7"
data-testid="trade-price-container"
>
<div
class="c7"
data-testid="trade-price-container"
<button
class="c8"
title="1 DEF = 1.00 ABC "
>
<button
class="c8"
title="1 DEF = 1.00 ABC "
<div
class="c9 css-zhpkf8"
>
<div
class="c9 css-zhpkf8"
>
1 DEF = 1.00 ABC
</div>
</button>
</div>
</div>
<div
class="c0 c1 c6"
>
<svg
class="c10"
fill="none"
height="24"
stroke="#98A1C0"
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>
1 DEF = 1.00 ABC
</div>
</button>
</div>
</div>
<div
style="height: 0px; overflow: hidden; width: 100%; will-change: height;"
class="c2 c3 c6"
>
<div>
<svg
class="c10"
fill="none"
height="24"
stroke="#98A1C0"
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
style="height: 0px; overflow: hidden; width: 100%; will-change: height;"
>
<div>
<div
class="c11"
data-testid="advanced-swap-details"
>
<div
class="c3"
style="padding: 0px 0px 8px 0px;"
class="c12"
>
<div
class="c0 c11 c12 c13"
data-testid="advanced-swap-details"
class="c13"
/>
<div
class="c2 c3 c4"
>
<div
class="c0 c11 c14"
class="c2 c3 c6"
>
<div
class="c3"
class="c14"
>
<div
class="c0 c1 c4"
>
<div>
<div
class="c0 c1 c6"
class="c15 css-zhpkf8"
>
<div
class="c15"
>
<div>
<div
class="css-zhpkf8"
>
Expected Output
</div>
</div>
</div>
</div>
<div
class="c9 css-q4yjm0"
>
0.000000000000001 DEF
</div>
</div>
<div
class="c0 c1 c4"
>
<div
class="c0 c1 c6"
>
<div
class="c15"
>
<div>
<div
class="css-zhpkf8"
>
Price Impact
</div>
</div>
</div>
</div>
<div
class="c9 css-q4yjm0"
>
<div
class="c16 css-1aekuku"
>
105567.37%
</div>
</div>
</div>
<div
class="c17"
/>
<div
class="c0 c1 c4"
>
<div
class="c0 c1 c6"
style="margin-right: 20px;"
>
<div
class="c15"
>
<div>
<div
class="css-zhpkf8"
>
Minimum received after slippage (2.00%)
</div>
</div>
</div>
</div>
<div
class="css-q4yjm0"
>
0.00000000000000098 DEF
Minimum output
</div>
</div>
</div>
</div>
<div
class="c9 css-zhpkf8"
>
0.00000000000000098 DEF
</div>
</div>
<div
class="c18 c19"
data-testid="swap-route-info"
class="c2 c3 c4"
>
<div
class="c0 c1 c4"
class="c2 c3 c6"
>
<div
class="c20 c21 c22"
width="auto"
class="c14"
>
<svg
class="c23"
fill="none"
height="20"
viewBox="0 0 23 20"
width="23"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<lineargradient
gradientTransform="rotate(95)"
id="AutoRouterIconGradient0"
x1="0"
x2="1"
y1="0"
y2="0"
>
<stop
id="stop1"
offset="0"
stop-color="#2274E2"
/>
<stop
id="stop1"
offset="0.5"
stop-color="#2274E2"
/>
<stop
id="stop2"
offset="1"
stop-color="#3FB672"
/>
</lineargradient>
</defs>
<path
d="M16 16C10 16 9 10 5 10M16 16C16 17.6569 17.3431 19 19 19C20.6569 19 22 17.6569 22 16C22 14.3431 20.6569 13 19 13C17.3431 13 16 14.3431 16 16ZM5 10C9 10 10 4 16 4M5 10H1.5M16 4C16 5.65685 17.3431 7 19 7C20.6569 7 22 5.65685 22 4C22 2.34315 20.6569 1 19 1C17.3431 1 16 2.34315 16 4Z"
stroke="url(#AutoRouterIconGradient0)"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
<div
class="c9 c24 css-1aekuku"
>
Auto Router
<div>
<div
class="c15 css-zhpkf8"
>
Expected output
</div>
</div>
</div>
<svg
class="c25"
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"
>
<line
x1="12"
x2="12"
y1="5"
y2="19"
/>
<line
x1="5"
x2="19"
y1="12"
y2="12"
/>
</svg>
</div>
<div
style="height: 0px; overflow: hidden; width: 100%; will-change: height;"
class="c9 css-zhpkf8"
>
0.000000000000001 DEF
</div>
</div>
<div
class="c13"
/>
<div
class="c2 c3 c4"
>
<div
class="c15 css-zhpkf8"
>
Order routing
</div>
<div
class="c14"
>
<div>
<div
class="c20 c21 c22"
style="padding-top: 12px; margin: 0px;"
width="auto"
class="c9 css-zhpkf8"
>
<div
class="c26 css-vurnku"
>
<div
class="c0 c1 c27"
>
<div
class="c28"
>
<img
alt="ABC logo"
class="c29"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000001/logo.png"
/>
</div>
<div
class="c0 c1 c30"
>
<div
class="c31"
>
<svg
class="c32"
>
dot_line.svg
</svg>
</div>
<div
class="c33 c34"
>
<div
class="c33 c35"
>
<div
class="c36 css-15li2d9"
>
V3
</div>
</div>
<div
class="c36 css-1aekuku"
style="min-width: auto;"
>
100%
</div>
</div>
<div
class="c37 c38 c39"
style="justify-content: space-evenly; z-index: 2;"
width="100%"
>
<div
class="c15"
>
<div>
<div
class="c33 c40"
>
<div
class="css-mbnpt3"
>
<div
class="c41"
>
<div
class="c42"
>
<div
class="c28"
>
<img
alt="DEF logo"
class="c29"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000002/logo.png"
/>
</div>
</div>
<div
class="c43"
>
<div
class="c28"
>
<img
alt="ABC logo"
class="c29"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000001/logo.png"
/>
</div>
</div>
</div>
</div>
<div
class="css-1aekuku"
>
1%
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="c28"
>
<img
alt="DEF logo"
class="c29"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000002/logo.png"
/>
</div>
</div>
</div>
<div
class="c17"
/>
<div
class="c44 css-65u4ng"
>
This route optimizes your total output by considering split routes, multiple hops, and the gas cost of each step.
</div>
Uniswap API
</div>
</div>
</div>

View File

@@ -81,7 +81,7 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
margin: -0px;
}
.c21 {
.c22 {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
@@ -91,11 +91,11 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
color: #0D111C;
}
.c26 {
.c24 {
color: #7780A0;
}
.c24 {
.c21 {
width: 100%;
height: 1px;
background-color: #D2D9EE;
@@ -118,6 +118,21 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
background-color: #F5F6FC;
}
.c20 {
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;
}
.c0 {
display: grid;
grid-auto-rows: auto;
@@ -152,7 +167,7 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
background-size: 400%;
}
.c22 {
.c23 {
display: inline-block;
height: inherit;
}
@@ -205,17 +220,12 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
padding: 8px 0;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
.c23 {
color: #7780A0;
}
.c10 {
text-overflow: ellipsis;
max-width: 220px;
@@ -223,10 +233,6 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
text-align: right;
}
.c20 {
padding: 0;
}
.c15 {
padding: 4px;
border-radius: 12px;
@@ -415,89 +421,79 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
style="padding: .75rem; margin-top: 0.5rem;"
>
<div
class="c5 c19 c20"
class="c20"
>
<div
class="c4"
class="c21"
/>
<div
class="c5 c6 c7"
>
<div
class="c5 c6 c7"
class="c5 c6 c22"
>
<div
class="c5 c6 c21"
class="c23"
>
<div
class="c22"
>
<div>
<div
class="css-zhpkf8"
>
Expected Output
</div>
<div>
<div
class="c24 css-zhpkf8"
>
Minimum output
</div>
</div>
</div>
<div
class="c18 css-q4yjm0"
>
0.000000000000001 DEF
</div>
</div>
<div
class="c5 c6 c7"
>
<div
class="c5 c6 c21"
>
<div
class="c22"
>
<div>
<div
class="css-zhpkf8"
>
Price Impact
</div>
</div>
</div>
</div>
<div
class="c18 css-q4yjm0"
>
<div
class="c23 css-1aekuku"
>
105567.37%
</div>
</div>
</div>
<div
class="c24"
/>
class="c18 css-zhpkf8"
>
0.00000000000000098 DEF
</div>
</div>
<div
class="c5 c6 c7"
>
<div
class="c5 c6 c7"
class="c5 c6 c22"
>
<div
class="c5 c6 c21"
style="margin-right: 20px;"
class="c23"
>
<div
class="c22"
>
<div>
<div
class="css-zhpkf8"
>
Minimum received after slippage (2.00%)
</div>
<div>
<div
class="c24 css-zhpkf8"
>
Expected output
</div>
</div>
</div>
<div
class="css-q4yjm0"
>
0.00000000000000098 DEF
</div>
<div
class="c18 css-zhpkf8"
>
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>
</div>
</div>
@@ -508,7 +504,7 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
style="padding: .75rem 1rem;"
>
<div
class="c26 css-k51stg"
class="c24 css-k51stg"
style="width: 100%;"
>
Output is estimated. You will receive at least
@@ -524,7 +520,7 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
style="padding: 12px 0px 0px 0px;"
>
<div
class="c26 css-8mokm4"
class="c24 css-8mokm4"
>
Output will be sent to
<b

View 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>
`;

View File

@@ -1,4 +1,3 @@
import { TooltipContainer } from 'components/Tooltip'
import { SupportedChainId } from 'constants/chains'
import { transparentize } from 'polished'
import { ReactNode } from 'react'
@@ -31,7 +30,7 @@ export const SwapWrapper = styled.main<{ chainId: number | undefined }>`
border: 1px solid ${({ theme }) => theme.backgroundOutline};
padding: 8px;
box-shadow: ${({ chainId }) => !!chainId && chainId === SupportedChainId.BNB && '0px 40px 120px 0px #f0b90b29'};
z-index: ${Z_INDEX.deprecated_content};
z-index: ${Z_INDEX.default};
transition: transform 250ms ease;
&:hover {
@@ -64,17 +63,6 @@ export const ArrowWrapper = styled.div<{ clickable: boolean }>`
: null}
`
export const ErrorText = styled(Text)<{ severity?: 0 | 1 | 2 | 3 | 4 }>`
color: ${({ theme, severity }) =>
severity === 3 || severity === 4
? theme.accentFailure
: severity === 2
? theme.deprecated_yellow2
: severity === 1
? theme.textPrimary
: theme.textSecondary};
`
export const TruncatedText = styled(Text)`
text-overflow: ellipsis;
max-width: 220px;
@@ -151,15 +139,3 @@ export const SwapShowAcceptChanges = styled(AutoColumn)`
border-radius: 12px;
margin-top: 8px;
`
export const ResponsiveTooltipContainer = styled(TooltipContainer)<{ origin?: string; width?: string }>`
background-color: ${({ theme }) => theme.backgroundSurface};
border: 1px solid ${({ theme }) => theme.backgroundInteractive};
padding: 1rem;
width: ${({ width }) => width ?? 'auto'};
${({ theme, origin }) => theme.deprecated_mediaWidth.deprecated_upToExtraSmall`
transform: scale(0.8);
transform-origin: ${origin ?? 'top left'};
`}
`

View File

@@ -3,7 +3,8 @@ export const UNI_EXTENDED_LIST = 'https://gateway.ipfs.io/ipns/extendedtokens.un
const UNI_UNSUPPORTED_LIST = 'https://gateway.ipfs.io/ipns/unsupportedtokens.uniswap.org'
const AAVE_LIST = 'tokenlist.aave.eth'
const BA_LIST = 'https://raw.githubusercontent.com/The-Blockchain-Association/sec-notice-list/master/ba-sec-list.json'
const CMC_ALL_LIST = 'https://s3.coinmarketcap.com/generated/dex/tokens/eth-tokens-all.json'
// TODO(INFRA-179): Re-enable CMC list once we have a better solution for handling large lists.
// const CMC_ALL_LIST = 'https://s3.coinmarketcap.com/generated/dex/tokens/eth-tokens-all.json'
const COINGECKO_LIST = 'https://tokens.coingecko.com/uniswap/all.json'
const COINGECKO_BNB_LIST = 'https://tokens.coingecko.com/binance-smart-chain/all.json'
const COINGECKO_ARBITRUM_LIST = 'https://tokens.coingecko.com/arbitrum-one/all.json'
@@ -29,7 +30,7 @@ export const DEFAULT_INACTIVE_LIST_URLS: string[] = [
UNI_EXTENDED_LIST,
COMPOUND_LIST,
AAVE_LIST,
CMC_ALL_LIST,
// CMC_ALL_LIST,
COINGECKO_LIST,
COINGECKO_BNB_LIST,
COINGECKO_ARBITRUM_LIST,

View File

@@ -3,6 +3,8 @@ import JSBI from 'jsbi'
export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
// TODO(WEB-3290): Convert the deadline to minutes and remove unecessary conversions from
// seconds to minutes in the codebase.
// 30 minutes, denominated in seconds
export const DEFAULT_DEADLINE_FROM_NOW = 60 * 30
export const L2_DEADLINE_FROM_NOW = 60 * 5

View File

@@ -6,5 +6,4 @@ export enum FeatureFlag {
permit2 = 'permit2',
fiatOnRampButtonOnSwap = 'fiat_on_ramp_button_on_swap_page',
detailsV2 = 'details_v2',
removeWidget = 'remove_widget_tdp',
}

View File

@@ -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 }

View File

@@ -0,0 +1,23 @@
import gql from 'graphql-tag'
import { useNftUniversalRouterAddressQuery } from '../__generated__/types-and-hooks'
gql`
query NftUniversalRouterAddress($chain: Chain = ETHEREUM) {
nftRoute(chain: $chain, senderAddress: "", nftTrades: []) {
toAddress
}
}
`
export function useNftUniversalRouterAddress() {
const { data, loading } = useNftUniversalRouterAddressQuery({
// no cache because a different version of nftRoute query is going to be called around the same time
fetchPolicy: 'no-cache',
})
return {
universalRouterAddress: data?.nftRoute?.toAddress,
universalRouterAddressIsLoading: loading,
}
}

View File

@@ -99,6 +99,7 @@ fragment TransactionParts on Transaction {
status
to
from
nonce
}
fragment AssetActivityParts on AssetActivity {

View File

@@ -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

View File

@@ -3,7 +3,7 @@ import { CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { DAI, USDC_MAINNET } from 'constants/tokens'
import { RouterPreference } from 'state/routing/slice'
import { TradeState } from 'state/routing/types'
import { useClientSideRouter } from 'state/user/hooks'
import { useRouterPreference } from 'state/user/hooks'
import { mocked } from 'test-utils/mocked'
import { useRoutingAPITrade } from '../state/routing/useRoutingAPITrade'
@@ -38,7 +38,7 @@ beforeEach(() => {
mocked(useIsWindowVisible).mockReturnValue(true)
mocked(useAutoRouterSupported).mockReturnValue(true)
mocked(useClientSideRouter).mockReturnValue([true, () => undefined])
mocked(useRouterPreference).mockReturnValue([RouterPreference.CLIENT, () => undefined])
})
describe('#useBestV3Trade ExactIn', () => {

View File

@@ -2,10 +2,9 @@ import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { WRAPPED_NATIVE_CURRENCY } from 'constants/tokens'
import { useMemo } from 'react'
import { RouterPreference } from 'state/routing/slice'
import { InterfaceTrade, TradeState } from 'state/routing/types'
import { useRoutingAPITrade } from 'state/routing/useRoutingAPITrade'
import { useClientSideRouter } from 'state/user/hooks'
import { useRouterPreference } from 'state/user/hooks'
import useAutoRouterSupported from './useAutoRouterSupported'
import { useClientSideV3Trade } from './useClientSideV3Trade'
@@ -46,12 +45,12 @@ export function useBestTrade(
const shouldGetTrade = !isAWrapTransaction && isWindowVisible
const [clientSideRouter] = useClientSideRouter()
const [routerPreference] = useRouterPreference()
const routingAPITrade = useRoutingAPITrade(
tradeType,
autoRouterSupported && shouldGetTrade ? debouncedAmount : undefined,
debouncedOtherCurrency,
clientSideRouter ? RouterPreference.CLIENT : RouterPreference.API
routerPreference
)
const isLoading = routingAPITrade.state === TradeState.LOADING

View File

@@ -3,7 +3,7 @@ import { useWeb3React } from '@web3-react/core'
import { SupportedChainId } from 'constants/chains'
import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount'
import { useMemo, useRef } from 'react'
import { RouterPreference } from 'state/routing/slice'
import { INTERNAL_ROUTER_PREFERENCE_PRICE } from 'state/routing/slice'
import { useRoutingAPITrade } from 'state/routing/useRoutingAPITrade'
import { CUSD_CELO, DAI_OPTIMISM, USDC_ARBITRUM, USDC_MAINNET, USDC_POLYGON, USDT_BSC } from '../constants/tokens'
@@ -28,7 +28,7 @@ export default function useStablecoinPrice(currency?: Currency): Price<Currency,
const amountOut = chainId ? STABLECOIN_AMOUNT_OUT[chainId] : undefined
const stablecoin = amountOut?.currency
const { trade } = useRoutingAPITrade(TradeType.EXACT_OUTPUT, amountOut, currency, RouterPreference.PRICE)
const { trade } = useRoutingAPITrade(TradeType.EXACT_OUTPUT, amountOut, currency, INTERNAL_ROUTER_PREFERENCE_PRICE)
const price = useMemo(() => {
if (!currency || !stablecoin) {
return undefined

View File

@@ -3,7 +3,7 @@ import { Currency, CurrencyAmount, Price, SupportedChainId, TradeType } from '@u
import { nativeOnChain } from 'constants/tokens'
import { Chain, useTokenSpotPriceQuery } from 'graphql/data/__generated__/types-and-hooks'
import { chainIdToBackendName, isGqlSupportedChain, PollingInterval } from 'graphql/data/util'
import { RouterPreference } from 'state/routing/slice'
import { INTERNAL_ROUTER_PREFERENCE_PRICE } from 'state/routing/slice'
import { TradeState } from 'state/routing/types'
import { useRoutingAPITrade } from 'state/routing/useRoutingAPITrade'
import { getNativeTokenDBAddress } from 'utils/nativeTokens'
@@ -30,7 +30,7 @@ function useETHValue(currencyAmount?: CurrencyAmount<Currency>): {
TradeType.EXACT_OUTPUT,
amountOut,
currencyAmount?.currency,
RouterPreference.PRICE
INTERNAL_ROUTER_PREFERENCE_PRICE
)
// Get ETH value of ETH or WETH

View File

@@ -52,77 +52,73 @@ export function useUniversalRouterSwapCallback(
const analyticsContext = useTrace()
return useCallback(async (): Promise<TransactionResponse> => {
return trace(
'swap.send',
async ({ setTraceData, setTraceStatus, setTraceError }) => {
return trace('swap.send', async ({ setTraceData, setTraceStatus, setTraceError }) => {
try {
if (!account) throw new Error('missing account')
if (!chainId) throw new Error('missing chainId')
if (!provider) throw new Error('missing provider')
if (!trade) throw new Error('missing trade')
setTraceData('slippageTolerance', options.slippageTolerance.toFixed(2))
const { calldata: data, value } = SwapRouter.swapERC20CallParameters(trade, {
slippageTolerance: options.slippageTolerance,
deadlineOrPreviousBlockhash: options.deadline?.toString(),
inputTokenPermit: options.permit,
fee: options.feeOptions,
})
const tx = {
from: account,
to: UNIVERSAL_ROUTER_ADDRESS(chainId),
data,
// TODO(https://github.com/Uniswap/universal-router-sdk/issues/113): universal-router-sdk returns a non-hexlified value.
...(value && !isZero(value) ? { value: toHex(value) } : {}),
}
let gasEstimate: BigNumber
try {
if (!account) throw new Error('missing account')
if (!chainId) throw new Error('missing chainId')
if (!provider) throw new Error('missing provider')
if (!trade) throw new Error('missing trade')
setTraceData('slippageTolerance', options.slippageTolerance.toFixed(2))
const { calldata: data, value } = SwapRouter.swapERC20CallParameters(trade, {
slippageTolerance: options.slippageTolerance,
deadlineOrPreviousBlockhash: options.deadline?.toString(),
inputTokenPermit: options.permit,
fee: options.feeOptions,
})
const tx = {
from: account,
to: UNIVERSAL_ROUTER_ADDRESS(chainId),
data,
// TODO(https://github.com/Uniswap/universal-router-sdk/issues/113): universal-router-sdk returns a non-hexlified value.
...(value && !isZero(value) ? { value: toHex(value) } : {}),
}
let gasEstimate: BigNumber
try {
gasEstimate = await provider.estimateGas(tx)
} catch (gasError) {
setTraceStatus('failed_precondition')
setTraceError(gasError)
console.warn(gasError)
throw new GasEstimationError()
}
const gasLimit = calculateGasMargin(gasEstimate)
setTraceData('gasLimit', gasLimit.toNumber())
const response = await provider
.getSigner()
.sendTransaction({ ...tx, gasLimit })
.then((response) => {
sendAnalyticsEvent(SwapEventName.SWAP_SIGNED, {
...formatSwapSignedAnalyticsEventProperties({
trade,
fiatValues,
txHash: response.hash,
}),
gasEstimate = await provider.estimateGas(tx)
} catch (gasError) {
setTraceStatus('failed_precondition')
setTraceError(gasError)
console.warn(gasError)
throw new GasEstimationError()
}
const gasLimit = calculateGasMargin(gasEstimate)
setTraceData('gasLimit', gasLimit.toNumber())
const response = await provider
.getSigner()
.sendTransaction({ ...tx, gasLimit })
.then((response) => {
sendAnalyticsEvent(SwapEventName.SWAP_SIGNED, {
...formatSwapSignedAnalyticsEventProperties({
trade,
fiatValues,
txHash: response.hash,
}),
...analyticsContext,
})
if (tx.data !== response.data) {
sendAnalyticsEvent(SwapEventName.SWAP_MODIFIED_IN_WALLET, {
txHash: response.hash,
...analyticsContext,
})
if (tx.data !== response.data) {
sendAnalyticsEvent(SwapEventName.SWAP_MODIFIED_IN_WALLET, {
txHash: response.hash,
...analyticsContext,
})
throw new ModifiedSwapError()
}
return response
})
return response
} catch (swapError: unknown) {
if (swapError instanceof ModifiedSwapError) throw swapError
throw new ModifiedSwapError()
}
return response
})
return response
} catch (swapError: unknown) {
if (swapError instanceof ModifiedSwapError) throw swapError
// Cancellations are not failures, and must be accounted for as 'cancelled'.
if (didUserReject(swapError)) setTraceStatus('cancelled')
// Cancellations are not failures, and must be accounted for as 'cancelled'.
if (didUserReject(swapError)) setTraceStatus('cancelled')
// GasEstimationErrors are already traced when they are thrown.
if (!(swapError instanceof GasEstimationError)) setTraceError(swapError)
// GasEstimationErrors are already traced when they are thrown.
if (!(swapError instanceof GasEstimationError)) setTraceError(swapError)
throw new Error(swapErrorToUserReadableMessage(swapError))
}
},
{ tags: { is_widget: false } }
)
throw new Error(swapErrorToUserReadableMessage(swapError))
}
})
}, [
account,
analyticsContext,

View File

@@ -1,6 +1,6 @@
import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { useMemo } from 'react'
import { RouterPreference } from 'state/routing/slice'
import { INTERNAL_ROUTER_PREFERENCE_PRICE, RouterPreference } from 'state/routing/slice'
/**
* Returns query arguments for the Routing API query or undefined if the
@@ -18,7 +18,7 @@ export function useRoutingAPIArguments({
tokenOut: Currency | undefined
amount: CurrencyAmount<Currency> | undefined
tradeType: TradeType
routerPreference: RouterPreference
routerPreference: RouterPreference | typeof INTERNAL_ROUTER_PREFERENCE_PRICE
}) {
return useMemo(
() =>

View File

@@ -4,7 +4,10 @@ import { SupportedChainId } from 'constants/chains'
import useBlockNumber, { useFastForwardBlockNumber } from 'lib/hooks/useBlockNumber'
import ms from 'ms.macro'
import { useCallback, useEffect } from 'react'
import { retry, RetryableError, RetryOptions } from 'utils/retry'
import { useTransactionRemover } from 'state/transactions/hooks'
import { TransactionDetails } from 'state/transactions/types'
import { retry, RetryableError, RetryOptions } from './retry'
interface Transaction {
addedTime: number
@@ -39,16 +42,17 @@ const RETRY_OPTIONS_BY_CHAIN_ID: { [chainId: number]: RetryOptions } = {
const DEFAULT_RETRY_OPTIONS: RetryOptions = { n: 1, minWait: 0, maxWait: 0 }
interface UpdaterProps {
pendingTransactions: { [hash: string]: Transaction }
pendingTransactions: { [hash: string]: TransactionDetails }
onCheck: (tx: { chainId: number; hash: string; blockNumber: number }) => void
onReceipt: (tx: { chainId: number; hash: string; receipt: TransactionReceipt }) => void
}
export default function Updater({ pendingTransactions, onCheck, onReceipt }: UpdaterProps): null {
const { chainId, provider } = useWeb3React()
const { account, chainId, provider } = useWeb3React()
const lastBlockNumber = useBlockNumber()
const fastForwardBlockNumber = useFastForwardBlockNumber()
const removeTransaction = useTransactionRemover()
const getReceipt = useCallback(
(hash: string) => {
@@ -56,8 +60,18 @@ export default function Updater({ pendingTransactions, onCheck, onReceipt }: Upd
const retryOptions = RETRY_OPTIONS_BY_CHAIN_ID[chainId] ?? DEFAULT_RETRY_OPTIONS
return retry(
() =>
provider.getTransactionReceipt(hash).then((receipt) => {
provider.getTransactionReceipt(hash).then(async (receipt) => {
if (receipt === null) {
if (account) {
const transactionCount = await provider.getTransactionCount(account)
const tx = pendingTransactions[hash]
// We check for the presence of a nonce because we haven't always saved them,
// so this code may run against old store state where nonce is undefined.
if (tx.nonce && tx.nonce < transactionCount) {
// We remove pending transactions from redux if they are no longer the latest nonce.
removeTransaction(hash)
}
}
console.debug(`Retrying tranasaction receipt for ${hash}`)
throw new RetryableError()
}
@@ -66,7 +80,7 @@ export default function Updater({ pendingTransactions, onCheck, onReceipt }: Upd
retryOptions
)
},
[chainId, provider]
[account, chainId, pendingTransactions, provider, removeTransaction]
)
useEffect(() => {

Some files were not shown because too many files have changed in this diff Show More