Compare commits

...

66 Commits

Author SHA1 Message Date
Jordan Frankfurt
bd4042aa16 chore: update pr template (#6634)
Co-authored-by: Jordan Frankfurt <jordan@CORN-Jordan-949.frankfurt>
2023-05-23 14:53:07 -07:00
github-actions[bot]
1dcafd2f2d chore(i18n): new Crowdin translations (#6215)
* chore(i18n): synchronize translations from crowdin [skip ci]

* chore: trigger actions

---------

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
2023-05-23 14:39:58 -07:00
Jordan Frankfurt
66fcdb4465 feat: improve yarn prepare scripts (#6609)
* feat: improve yarn prepare scripts

* reset yarn.lock to main

* pr feedback

* Update scripts/prepare.js

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

* Update scripts/prepare.js

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

* Update scripts/prepare.js

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

* Update scripts/prepare.js

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

* Update scripts/prepare.js

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

* Update scripts/prepare.js

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

* Update scripts/prepare.js

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

* pr feedback

* switch to using concurrently

* yarn dedupe

---------

Co-authored-by: Jordan Frankfurt <jordan@CORN-Jordan-949.frankfurt>
Co-authored-by: Jordan Frankfurt <jordan@corn-jordan-949.lan>
Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
2023-05-23 16:07:05 -05:00
Jack Short
e398e8b950 fix: allow unsupported chain in pwat (#6629)
chore: allow unsupported chain in pwat
2023-05-23 13:36:28 -04:00
Tina
6424fdfbcd fix: Simplify event logging for SWAP_QUOTE_RECEIVED (#6628)
* simplify event logging

* remove unused function parameter
2023-05-23 11:06:18 -04:00
eddie
95814e3271 fix: prevent race condition for swap state (#6624) 2023-05-22 15:57:17 -07:00
Jack Short
caa2524e27 feat: [DetailsV2] instant buy (#6599)
* initial impl

* removing isopen change

* stopping refetching

* shared button

* pending animiation

* updating shared

* updating snapshots

* adding disabled state

* isLoading in hook

* pulling out ternary

* removing fragment

* separate file for offer button

* fixing price diff check

* remove unnecessary export

* changing name to useBuyAssetCallback
2023-05-22 18:28:48 -04:00
Zach Pomerantz
d28a4b34cd fix: do not attempt to cache i18n:extract (#6616) 2023-05-22 14:03:11 -07:00
cartcrom
f3a80c6272 feat: special case arb search (#6584)
* feat: special case arb search

* fix: check both current and existing token
2023-05-22 12:40:46 -04:00
Zach Pomerantz
b89ee36448 test(e2e): attempt to de-flake (#6611)
* test(e2e): improve memory mgmt

* test(e2e): record flakes

* test(e2e): simplify tests in attempt to de-flake

* test(e2e): more simplification

* test(e2e): disable transaction popup checks

* test(e2e): better wrap assertions

* test(e2e): always assert both inputs
2023-05-22 09:02:54 -07:00
Vignesh Mohankumar
fbc55db937 chore: remove chunkResponseStatus tag (#6586)
* chore: remove chunkResponseStatus tag

* lint
2023-05-21 17:25:58 -04:00
Jordan Frankfurt
835c62acfa fix: use ephemeral props for styled component (#6607)
* fix: use ephemeral props for styled component

* add
2023-05-20 16:55:37 -05:00
Zach Pomerantz
8fe7c7a0a7 build: notify from notify/test (#6597)
* build: notify from notify/test

* debug

* debug2

* revert debugs
2023-05-19 12:24:11 -07:00
Tina
41113e6e41 fix: Use client side router only for price fetching (#6604)
use client side router only for price fetching
2023-05-19 13:35:29 -04:00
Mike Grabowski
58b25d29a9 feat: expand settings by default when custom values are set (#6603)
feat: expand by default
2023-05-19 21:35:02 +04:00
Zach Pomerantz
a2db3e2719 test(e2e): configure Cypress to post PR status comments (#6591) 2023-05-19 06:32:42 -07:00
Mike Grabowski
b62f9066a7 fix: add price impact back (#6581)
* feat: add price impact back

* chore: update tes tname

* chore: update snapshot for price impact

* fix

* fix

* update snapshot after rebase

* update snapshot
2023-05-19 09:24:06 +04:00
Zach Pomerantz
258f22e037 build: continue-on-error for slack notifications (#6600) 2023-05-18 15:30:43 -07:00
Zach Pomerantz
38b306a80f build: pin github-tag-action (#6598) 2023-05-18 15:30:24 -07:00
Zach Pomerantz
9050f09bfe build: notify from notify/releases (#6596) 2023-05-18 15:30:03 -07:00
Zach Pomerantz
77d46c361a test(e2e): de-flake wrap (#6589)
* test(e2e): mv swap to dir

* test(e2e): split swap/wrap/errors

* test(e2e): de-flake wrap
2023-05-18 13:47:15 -07:00
Charles Bachmeier
4fb48bdd1f fix: only request 1 listing on NFTDetails page (#6602)
only request 1 listing

Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
2023-05-18 13:35:01 -07:00
cartcrom
cf2b6bf568 fix: portfolio balances after switching accounts (#6527)
* fix: handle portfolio staleness upon account change

* fix: only consider current account for tx updates

* test: add e2e test for account switching

* fix: remove unnused data-testid

* fix: added todo comment

* refactor: move test into existing folder

* fix: add account to dependency array

* todo: tx reducer

* test: update cypress config to pass in env variables

* fix: undo unintended change

* fix: use process.env

* fix: use regex instead of env
2023-05-18 16:32:35 -04:00
eddie
03095f4e48 feat: add feature flag for URA (#6593) 2023-05-18 10:14:18 -07:00
lavalamp
b2966f8d29 ci: Fix YAML spacing (#6592)
Fix YAML spacing
2023-05-18 09:43:02 -07:00
Charles Bachmeier
ef6d1f20ed feat: [DetailsV2] Show data page header when nft scrolled out of view (#6585)
* show data page header when nft scrolled out of view

* add new snapshot test

* useRef for observer

* add comment

---------

Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
2023-05-18 09:41:04 -07:00
lavalamp
10b156ff2b ci: Final CI fixes pass (#6556)
* Final CI fixes pass

* Change cut to awk

* Remove workflow_dispatch

* Start, success, failure messges

* Deploy from main and add comment

* Update .github/workflows/2-deploy-to-staging.yml

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

* Update jobs to have correct comments, simplification in prod

---------

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
2023-05-17 14:29:06 -06:00
Zach Pomerantz
146c5f29cf feat: only pre-cache the document (#6580)
* test(e2e): de-flake service-worker

* feat: rm stale cache storage

* fix: put not del

* fix: staging and test

* test: include staging

* fix: log

* test: rm console.log

* fix: unregister before

* test: deflake by restoring state afterwards
2023-05-17 12:10:28 -07:00
Zach Pomerantz
66a3475bf6 test(e2e): split swap tests (#6587)
* test(e2e): mv swap to dir

* test(e2e): split swap/wrap/errors
2023-05-17 09:43:52 -07:00
Zach Pomerantz
f6c393b016 test(e2e): de-flake activity-history (#6583) 2023-05-17 09:43:26 -07:00
cartcrom
15f8d34320 fix: update nonce deduplication logic (#6588)
* fix: update nonce-deduplication logic
* lint
2023-05-16 21:28:19 -04:00
eddie
504e09d3dc feat: new review design (#6451)
* test: swap flow cypress tests

* fix: use default parameter

* feat: use Swap Component on TDP

* feat: auto nav for TDP tokens

* chore: merge

* chore: merge

* chore: merge

* chore: merge

* fix: remove extra inputCurrency URL parsing logic

* fix: undo last change

* fix: pass expected chain id to swap component

* fix: search for default tokens on unconnected networks if needed

* test: e2e test for l2 token

* fix: delete irrelevant tests

* fix: address comments

* fix: lint error

* test: update TDP e2e tests

* fix: use pageChainId for filter

* fix: rename chainId

* fix: typecheck

* fix: chainId bug

* fix: chainId required fixes

* fix: bad merge in e2e test

* fix: remove unused test util

* fix: remove unnecessary variable

* fix: token defaults

* fix: address comments

* fix: address comments and fix tests

* fix: e2e test formatting, remove Maybe<>

* fix: remove unused variable

* fix: use feature flag for swap component on TDP

* fix: back button

* feat: copy review screen UI from widgetg

* fix: modal padding

* feat: add final detail row

* fix: remove widget comment

* fix: update unit tests

* fix: code style consistency

* fix: remove padding from AutoColumn

* fix: update snapshots

* fix: use semantic gaps

* fix: more px and gaps

* fix: design feedbacks

* fix: button radius in summary modal

* fix: design nits

* feat: update design of summary modal

* fix: font weight and vertical spacing

* fix: update snapshots

* fix: css nits

* fix: modal flicker when refetching trade

* fix: comments

* fix: code style improvements

* feat: require trade to be defined

* fix: remove extra props from ThemedTexts

* fix: one more trans

* fix: remove unused export

* feat: remove undefined checks and other fixes

* fix: update test

* fix: add missing dollar sign

* fix: remove null check and update test

* fix: remove max width from detail row value

* fix: remove isOpen prop

* fix: isopen
2023-05-16 15:15:30 -07:00
Vignesh Mohankumar
1f755e8b0d feat: add retry logic for dynamic imports (#6512)
* feat: add retry logic for lazy import

* try again

* add tests

* refactor: moves retry helper to subfolder

* missing-files

* fix

* doc comment

* tsdoc

* fake timers

* fix

* add eslint rule

* try again?

* try again?

* only dynamic

* try again

* try again

* IT WORKS

* add retry

* fix

* add test

* warn -> error

* lint

* lint

* lint

* add back cache

* rm test

* try again

* real timers but really short intervals

* try returning the promise?

* try returning the promise?

* try this package

* retry

* Update src/utils/retry.ts

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

* Update rules/enforce-retry-on-import.js

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

* Update rules/enforce-retry-on-import.js

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

* eslint_rules

* test fixes

* name

* fix

---------

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
2023-05-16 16:53:22 -04:00
Vignesh Mohankumar
f45a7f921b fix: handle switchChain failure in swap (#6507)
* fix: handle switchChain failure in swap

* comment

* fix
2023-05-16 16:47:13 -04:00
Vignesh Mohankumar
29db61ff90 fix: filter error caused by missing meta tags (#6546)
* fix: filter error caused by missing meta tags

* fix
2023-05-16 16:40:25 -04:00
Tina
8431ad9161 chore: Refactor swap request flow (#6499)
* Refactor swap quote flow with widget logic

* remove console logging

* add ignore path for serialization check and pass in native currencies for client side routing

* apply stashed changes

* revert node version change

* remove TODO comment because maybe no longer relevant

* update unit tests

* wip: add snapshot test

* add snapshot test for gas estimate badge

* address PR comments: rename variables, fix client side router initialization

* update Trade type

* add TODO comment about isExactInput util

* change | undefined convention to ?

* PR comments

* update type

* remove client side initialization logic and replace with TODO

* use routing-api for price fetching trades too

* remove QuoteType.Initialized
2023-05-16 16:33:46 -04:00
Vignesh Mohankumar
fd1aded517 fix: remove trailing slash from request url (#6542)
* fix: remove trailing slash from request url

* moves cast

* {}
2023-05-16 12:36:24 -04:00
Vignesh Mohankumar
27ad7cbd41 test: move all tests to beforeSend (#6513)
* beforeSend tests

* fix

* refactor: filterKnownErrors -> shouldRejectError (#6547)

* refactor: filterKnownErrors -> shouldRejectError

* no unknown

* comments
2023-05-16 12:36:17 -04:00
Joshua DeCristi
01e5de436a fix: "Minimum output" should be "Maximum output" when trade is Exact Output (#6565)
* fix: minimum output should be showing maximum input when trade type is exact output

* fix: test for minimum output should be showing maximum input when trade type is exact output

---------

Co-authored-by: Josh DeCristi <joshdecristi@Joshs-MacBook-Pro.local>
2023-05-16 12:07:39 -04:00
Charles Bachmeier
fd5aa1b51e fix: use padding component (#6579)
* fix: rm unused const

* use component

* update snapshot

---------

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
2023-05-16 08:56:51 -07:00
Mike Grabowski
a6e1a7e6d9 feat: add slippage warning to MenuButton (#6548)
* feat: initial commit

* chore: add unit tests

* chore: move menubutton to sep. component

* chore: simplify styles and add real focused state

* chore: fix tests + some other tweaks

* chore: rename

* test: add snapshot tests

* tweaks
2023-05-16 11:41:14 +04:00
Jack Short
629fe2c144 feat: [DetailsV2] trait bubbles (#6552) 2023-05-15 19:17:45 -04:00
Vignesh Mohankumar
d73763ce75 refactor: imports shared polyfills in setupTests (#6571) 2023-05-15 17:11:28 -04:00
Zach Pomerantz
fe6df38997 build: upgrade to webpack5 with polyfilled Buffer (#6568)
* fix: Revert "fix: Revert "build: upgrade to webpack 5 (#6459)" (#6566)"

This reverts commit 5e591455b3.

* build: polyfill Buffer

* docs: fix comment negation
2023-05-15 14:07:05 -07:00
eddie
719ee0f5b5 fix: loosen permit2 expiration tolerance in e2e tests (#6573) 2023-05-15 10:58:05 -07:00
Charles Bachmeier
75bdf9a8d4 feat: [DetailsV2] Add left padding to trait rows and headers (#6534)
* feat: [DetailsV2] Add left padding to trait rows

* update snapshot

---------

Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
2023-05-15 10:40:38 -07:00
Tina
efbe3994bb fix: catch RouterPreference.AUTO case for routing-api usage (#6572)
catch AUTO case for routing-api
2023-05-15 13:38:59 -04:00
Vignesh Mohankumar
93fe8e4349 fix: polyfill ResizeObserver (#6553)
* polyfill

* lint

* polyfill test

* dedupe
2023-05-15 12:39:31 -04:00
Vignesh Mohankumar
6062f615a0 build: change automated release to 16:00 UTC (#6567) 2023-05-15 12:25:49 -04:00
Charles Bachmeier
42e3af7b5c feat: [DetailsV2] Offer and Listing Tables (#6515)
* added home icon, basic content container with scroll behaviour

* add more struct

* add timeUntil util, add main structure of generic component, basic mock data

* propagate asset

* actual fake data

* working scroll

* proper alignment

* 1155 quantity

* small window sizes

* more action buttons

* cleanup

* update snapshot

* add tests

* add new test files

* add outline and hide usd price for certain screen sizes

* use sell order data

* update tests

* fetch multiple listings

* better price width on select screens

* mobile icon for approve

* bottom padding on mobile

* update snapshot

* use test objs in tests

* update query

* add border between rows

* update page padding

* breakpoint overlap

* simplified sellOrder check

* external link

* upstream button and better mobile padding

* add file and update tests

---------

Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
2023-05-15 09:14:09 -07:00
Vignesh Mohankumar
57274a800d fix: don't console.error for WalletConnect modal close (#6559) 2023-05-15 12:13:34 -04:00
Tina
5e591455b3 fix: Revert "build: upgrade to webpack 5 (#6459)" (#6566)
Revert "build: upgrade to webpack 5 (#6459)"

This reverts commit ec547ab100.
2023-05-15 09:48:27 -04:00
Zach Pomerantz
ec547ab100 build: upgrade to webpack 5 (#6459)
* fix: import default from json

* fix: fallback to path-browserify

* build: upgrade to webpack5

* test: update size-tests to reflect single entry

* test: increase service-worker timeout

* docs: improve comments

* build: rm MiniCssExtract workaround

* docs: even better comments

* test: back out longer test

* build: vendor chunk

* test: increase sw timeout

* fix: justified splitChunks config

* better explanation

* fix: longer timeout

* fix: caching

* merge and rm duplication

* build
2023-05-12 12:55:01 -07:00
Charles Bachmeier
9de76c69ae feat: [DetailsV2] Data Page Header (#6549)
* hide header on mobile

* add buy and offer buttons, thumbnail, text

* handle no sell orders and add tests

* rehide on mobile

* breakpoint optimizations

* design feedback

---------

Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
2023-05-12 14:48:26 -04:00
Charles Bachmeier
85d1b90197 feat: [DetailsV2] Mobile support for traits (#6535)
Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
2023-05-12 13:39:31 -04: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
237 changed files with 49688 additions and 27763 deletions

View File

@@ -2,13 +2,18 @@
require('@uniswap/eslint-config/load')
const rulesDirPlugin = require('eslint-plugin-rulesdir')
rulesDirPlugin.RULES_DIR = 'eslint_rules'
module.exports = {
extends: '@uniswap/eslint-config/react',
extends: ['@uniswap/eslint-config/react'],
plugins: ['rulesdir'],
overrides: [
{
files: ['**/*'],
rules: {
'multiline-comment-style': ['error', 'separate-lines'],
'rulesdir/enforce-retry-on-import': 'error',
},
},
{

2
.github/CODEOWNERS vendored
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

@@ -53,16 +53,6 @@ runs:
shell: bash
# Messages are extracted from source.
# A record of source file content hashes is maintained in node_modules/.cache/lingui by a custom extractor.
# Messages are always extracted, but extraction may rely on the custom extractor's loaded cache.
- uses: actions/cache@v3
id: i18n-extract-cache
with:
path: |
src/locales/en-US.po
node_modules/.cache
key: ${{ runner.os }}-i18n-extract-${{ github.run_id }}
restore-keys: ${{ runner.os }}-i18n-extract-
- run: yarn i18n:extract
shell: bash

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'

View File

@@ -6,7 +6,7 @@
<!-- Delete inapplicable lines: -->
_JIRA ticket:_
_Linear ticket:_
_Slack thread:_
_Relevant docs:_
@@ -14,9 +14,16 @@ _Relevant docs:_
<!-- Delete this section if your change does not affect UI. -->
## Screen capture
| Before | After (Desktop) | After (Mobile) |
| ------------ |---------------- | -------------- |
| paste_before | past_after | paste_after |
### Before
| Mobile | Desktop |
| ------------ | ------------ |
| paste_before | paste_before |
### After
| Mobile | Desktop |
| ------------ | ----------- |
| paste_after | paste_after |
## Test plan

View File

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

View File

@@ -1,4 +1,4 @@
name: 2 | Deploy to staging
name: 2 | Deploy staging
on:
push:
branches:
@@ -8,8 +8,19 @@ jobs:
deploy-to-staging:
runs-on: ubuntu-latest
environment:
name: staging
name: deploy/staging
steps:
- name: Send Slack message that deploy is starting
uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
continue-on-error: true
with:
payload: |
{
"text": "Staging deploy started for branch: ${{ github.ref_name }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: yarn prepare
@@ -21,6 +32,7 @@ jobs:
with:
node-version: 16
- name: Update Cloudflare Pages deployment
id: pages-deployment
uses: cloudflare/pages-action@364c7ca09a4b57837c5967871d64a2c31adb8c0d
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
@@ -28,6 +40,20 @@ jobs:
projectName: interface-staging
directory: build
githubToken: ${{ secrets.GITHUB_TOKEN }}
# Cloudflare uses `main` as the default production branch, so we push using the `main` branch so that it can be aliased by a custom domain.
branch: main
- name: Send Slack message about deployment outcome
uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
continue-on-error: true
if: always()
with:
payload: |
{
"text": "Staging deploy **${{ steps.pages-deployment.outcome }}** for: ${{ github.ref_name }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: Upload source maps to Sentry
uses: getsentry/action-release@bd5f874fcda966ba48139b0140fb3ec0cb3aabdd
continue-on-error: true

View File

@@ -1,63 +1,27 @@
name: 3 | Generate PR for releases/staging to releases/prod
name: 3 | Push staging -> prod
# This CI job is responsible for generating PRs that bring the HEAD of `releases/staging` into `releases/prod`.
# These PRs are meant to be the only (standard) way that code is merged into the `releases/prod` branch.
# This CI job is responsible for force pushing the content of releases/staging to releases/prod. It
# is restricted to web-reviewers through virtue of the GitHub environment protection rules for the
# prod environment.
on:
workflow_dispatch:
# https://github.com/peter-evans/create-pull-request/blob/main/docs/examples.md#keep-a-branch-up-to-date-with-another
jobs:
prod-gen-pr:
name: 'Generate PR for merging to releases/prod branch'
push-prod:
name: 'Push to prod branch'
runs-on: ubuntu-latest
environment:
name: release
name: push/prod
steps:
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
with:
token: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
ref: main
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab
with:
token: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
ref: releases/prod
- name: Reset promotion branch
ref: releases/staging
- name: Git config
run: |
git fetch origin releases/staging:releases/staging
git reset --hard releases/staging
- name: Setup git
run: |
git config user.name "UL Mobile Service Account"
git config user.name "UL Service Account"
git config user.email "hello-happy-puppy@users.noreply.github.com"
- name: Add CODEOWNERS file
- name: Git push
run: |
echo "@uniswap/web-reviewers" > CODEOWNERS
git add CODEOWNERS
git commit -m "ci: add CODEOWNERS file"
- uses: peter-evans/create-pull-request@ea54357f43e3d1cf1125471d0814f4d02cc0d364
id: create-pull-request
with:
token: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
base: 'releases/prod'
title: 'ci: promotes staging to prod'
delete-branch: 'true'
committer: 'UL Service Account <hello-happy-puppy@users.noreply.github.com>'
author: 'UL Service Account <hello-happy-puppy@users.noreply.github.com>'
branch: 'approvals/staging-to-prod'
- name: Update PR body
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "### Description" > /tmp/pr_desc
echo "" >> /tmp/pr_desc
echo "This PR promotes the following commits from `releases/staging` to `releases/prod`." >> /tmp/pr_desc
echo "" >> /tmp/pr_desc
gh pr view ${{ steps.create-pull-request.outputs.pull-request-number }} --json commits | jq '.commits[] | [.oid, .messageHeadline] | @tsv' | sed 's/"//g' | sed 's/\\t/ - /g' >> /tmp/pr_desc
echo "" >> /tmp/pr_desc
echo "**Once approved this PR will be automatically merged via a merge commit.**" >> /tmp/pr_desc
gh pr edit ${{ steps.create-pull-request.outputs.pull-request-number }} -b "$(cat /tmp/pr_desc)"
- name: Enable PR automerge
env:
GH_TOKEN: ${{ secrets.RELEASE_SERVICE_ACCESS_TOKEN }}
run: gh pr merge --delete-branch --merge --auto "${{ steps.create-pull-request.outputs.pull-request-number }}"
git push origin releases/staging:releases/prod --force

View File

@@ -1,4 +1,4 @@
name: 4 | Deploy to prod
name: 4 | Deploy prod
on:
push:
branches:
@@ -6,16 +6,28 @@ on:
jobs:
deploy-to-prod:
runs-on: ubuntu-latest
environment:
name: prod
name: deploy/prod
steps:
- name: Send Slack message that build is starting
uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
continue-on-error: true
with:
payload: |
{
"text": "Production deploy started for branch: ${{ github.ref_name }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: yarn prepare
- run: yarn build
- name: Bump and tag
id: github-tag-action
uses: mathieudutour/github-tag-action@v6.0
uses: mathieudutour/github-tag-action@d745f2e74aaf1ee82e747b181f7a0967978abee0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
release_branches: releases/prod
@@ -69,14 +81,28 @@ jobs:
- name: Update Cloudflare Pages deployment
uses: cloudflare/pages-action@364c7ca09a4b57837c5967871d64a2c31adb8c0d
id: pages-deployment
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
directory: build
githubToken: ${{ secrets.GITHUB_TOKEN }}
# Cloudflare uses `main` as the default production branch, so we push using the `main` branch so that it can be aliased by a custom domain.
branch: main
- name: Send Slack message about deployment outcome
uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844
continue-on-error: true
if: always()
with:
payload: |
{
"text": "Production deploy **${{ steps.pages-deployment.outcome }}** for: ${{ github.ref_name }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: Upload source maps to Sentry
uses: getsentry/action-release@4744f6a65149f441c5f396d5b0877307c0db52c7
continue-on-error: true
@@ -88,4 +114,3 @@ jobs:
environment: production
sourcemaps: './build/static/js'
url_prefix: '~/static/js'

View File

@@ -11,8 +11,10 @@ name: Slack notifications for releases/* merges
# | sed 's/"//g' | sed 's/\\t/;/g' | sed 's/\\n/;/g' | sed 's/\\//g' \
# We then use awk to format the TSV into a Slack message
# | awk -F';' '{print "• <"$1"|"$2"> (<https://github.com/"$3"|"$3">, "$4") - "$5}' \
# Finally, we need to deal with some escaping issues with newlines so that we don't break the Slack message format
# We need to deal with some escaping issues with newlines so that we don't break the Slack message format
# | sed 's/$/\\n/g' | tr -d '\n' \
# Finally we have to truncate the message to 3,000 characters max, otherwise Slack will reject it
# | awk '{print substr($0,0,3000);}' \
# Then shove the bytes into a file to store them in their exact format
# > /tmp/parsed_github_context
@@ -25,6 +27,8 @@ jobs:
notify-slack:
name: 'Emit Slack notification(s)'
runs-on: ubuntu-latest
environment:
name: notify/releases
steps:
- name: Parse event to slug
id: parse-slug
@@ -38,6 +42,7 @@ jobs:
| sed 's/"//g' | sed 's/\\t/;/g' | sed 's/\\n/;/g' | sed 's/\\//g' \
| awk -F';' '{print "• <"$1"|"$2"> (<https://github.com/"$3"|"$3">, "$4") - "$5}' \
| sed 's/$/\\n/g' | tr -d '\n' \
| awk '{print substr($0,0,3000);}' \
> /tmp/parsed_github_context
echo "SLACK_COMMITS=$(cat /tmp/parsed_github_context)" >> "$GITHUB_OUTPUT"
- name: Send custom JSON data to Slack workflow

View File

@@ -1,7 +1,7 @@
name: Release
on:
schedule:
- cron: '0 12 * * 1-4' # every day 12:00 UTC Monday-Thursday
- cron: '0 16 * * 1-4' # every day 16:00 UTC Monday-Thursday
# manual trigger
workflow_dispatch:

View File

@@ -16,6 +16,8 @@ on:
jobs:
lint:
runs-on: ubuntu-latest
environment:
name: ${{ github.ref_name == 'main' && 'notify/test' }}
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
@@ -26,9 +28,16 @@ jobs:
key: ${{ runner.os }}-eslint-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
restore-keys: ${{ runner.os }}-eslint-${{ hashFiles('**/yarn.lock') }}-
- run: yarn lint
- if: failure() && github.ref_name == 'main'
uses: ./.github/actions/report
with:
name: Lint
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
typecheck:
runs-on: ubuntu-latest
environment:
name: ${{ github.ref_name == 'main' && 'notify/test' }}
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
@@ -39,16 +48,30 @@ jobs:
key: ${{ runner.os }}-tsc-${{ hashFiles('**/yarn.lock') }}-${{ github.run_id }}
restore-keys: ${{ runner.os }}-tsc-${{ hashFiles('**/yarn.lock') }}-
- run: yarn typecheck
- if: failure() && github.ref_name == 'main'
uses: ./.github/actions/report
with:
name: Typecheck
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
deps-tests:
runs-on: ubuntu-latest
environment:
name: ${{ github.ref_name == 'main' && 'notify/test' }}
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
- run: yarn yarn-deduplicate --strategy=highest --list --fail
- if: failure() && github.ref_name == 'main'
uses: ./.github/actions/report
with:
name: Dependency checks
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
unit-tests:
runs-on: ubuntu-latest
environment:
name: ${{ github.ref_name == 'main' && 'notify/test' }}
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup
@@ -65,6 +88,11 @@ jobs:
fail_ci_if_error: false
verbose: true
flags: unit-tests
- if: failure() && github.ref_name == 'main'
uses: ./.github/actions/report
with:
name: Unit tests
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
build-e2e:
runs-on: ubuntu-latest
@@ -95,6 +123,8 @@ jobs:
cypress-test-matrix:
needs: [build-e2e, cypress-rerun]
runs-on: ubuntu-latest
environment:
name: ${{ github.ref_name == 'main' && 'notify/test' }}
container: cypress/browsers:node-18.14.1-chrome-111.0.5563.64-1-ff-111.0-edge-111.0.1661.43-1
strategy:
fail-fast: false
@@ -107,7 +137,7 @@ jobs:
id: cypress-cache
with:
path: /root/.cache/Cypress
key: ${{ runner.os }}-cypress
key: ${{ runner.os }}-cypress-${{ hashFiles('**/node_modules/cypress/package.json') }}
- run: |
yarn cypress install
yarn cypress info
@@ -120,14 +150,25 @@ jobs:
- uses: cypress-io/github-action@v4
with:
install: false
record: true
parallel: true
start: yarn serve
wait-on: 'http://localhost:3000'
browser: chrome
record: true
parallel: true
group: e2e
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMIT_INFO_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }}
COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title || github.event.head_commit.message }}
COMMIT_INFO_AUTHOR: ${{ github.event.sender.login || github.event.head_commit.author.login }}
# Cypress requires an email for filtering by author, but GitHub does not expose one.
# GitHub's public profile email can be deterministically produced from user id/login.
COMMIT_INFO_EMAIL: ${{ github.event.sender.id || github.event.head_commit.author.id }}+${{ github.event.sender.login || github.event.head_commit.author.login }}@users.noreply.github.com
COMMIT_INFO_SHA: ${{ github.event.pull_request.head.sha || github.event.head_commit.sha }}
COMMIT_INFO_TIMESTAMP: ${{ github.event.pull_request.updated_at || github.event.head_commit.timestamp }}
CYPRESS_PULL_REQUEST_ID: ${{ github.event.pull_request.number }}
CYPRESS_PULL_REQUEST_URL: ${{ github.event.pull_request.html_url }}
- uses: codecov/codecov-action@v3
with:
@@ -136,9 +177,15 @@ jobs:
verbose: true
flags: e2e-tests
- if: failure() && github.ref_name == 'main'
uses: ./.github/actions/report
with:
name: Cypress tests
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
# Included as a single job to check for cypress-test-matrix success, as a matrix cannot be checked.
cypress-tests:
if: ${{ always() }}
if: always()
needs: [cypress-test-matrix]
runs-on: ubuntu-latest
steps:

View File

@@ -1,9 +1,11 @@
/* eslint-env node */
const { VanillaExtractPlugin } = require('@vanilla-extract/webpack-plugin')
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
const { execSync } = require('child_process')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin')
const { DefinePlugin, IgnorePlugin } = require('webpack')
const path = require('path')
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin')
const { DefinePlugin, IgnorePlugin, ProvidePlugin } = require('webpack')
const commitHash = execSync('git rev-parse HEAD').toString().trim()
const isProduction = process.env.NODE_ENV === 'production'
@@ -12,6 +14,11 @@ const isProduction = process.env.NODE_ENV === 'production'
// Omit them from production builds, as they slow down the feedback loop.
const shouldLintOrTypeCheck = !isProduction
function getCacheDirectory(cacheName) {
// Include the trailing slash to denote that this is a directory.
return `${path.join(__dirname, 'node_modules/.cache/', cacheName)}/`
}
module.exports = {
babel: {
plugins: [
@@ -44,8 +51,13 @@ module.exports = {
pluginOptions(eslintConfig) {
return Object.assign(eslintConfig, {
cache: true,
cacheLocation: 'node_modules/.cache/eslint/',
cacheLocation: getCacheDirectory('eslint'),
ignorePath: '.gitignore',
// Use our own eslint/plugins/config, as overrides interfere with caching.
// This ensures that `yarn start` and `yarn lint` share one cache.
eslintPath: require.resolve('eslint'),
resolvePluginsRelativeTo: null,
baseConfig: null,
})
},
},
@@ -55,11 +67,13 @@ module.exports = {
jest: {
configure(jestConfig) {
return Object.assign(jestConfig, {
transform: {
'\\.css\\.ts$': './vanilla.transform.cjs',
...jestConfig.transform,
},
cacheDirectory: 'node_modules/.cache/jest',
cacheDirectory: getCacheDirectory('jest'),
transform: Object.assign(jestConfig.transform, {
// Transform vanilla-extract using its own transformer.
// See https://sandroroth.com/blog/vanilla-extract-cra#jest-transform.
'\\.css\\.ts$': '@vanilla-extract/jest-transform',
}),
// Use @uniswap/conedison's build directly, as jest does not support its exports.
transformIgnorePatterns: ['@uniswap/conedison/format', '@uniswap/conedison/provider'],
moduleNameMapper: {
'@uniswap/conedison/format': '@uniswap/conedison/dist/format',
@@ -69,8 +83,19 @@ module.exports = {
},
},
webpack: {
plugins: [new VanillaExtractPlugin({ identifiers: 'short' })],
plugins: [
// Webpack 5 does not polyfill node globals, so we do so for those necessary:
new ProvidePlugin({
// - react-markdown requires process.cwd
process: 'process/browser',
}),
// vanilla-extract has poor performance on M1 machines with 'debug' identifiers, so we use 'short' instead.
// See https://vanilla-extract.style/documentation/integrations/webpack/#identifiers for docs.
// See https://github.com/vanilla-extract-css/vanilla-extract/issues/771#issuecomment-1249524366.
new VanillaExtractPlugin({ identifiers: 'short' }),
],
configure: (webpackConfig) => {
// Configure webpack plugins:
webpackConfig.plugins = webpackConfig.plugins
.map((plugin) => {
// Extend process.env with dynamic values (eg commit hash).
@@ -87,10 +112,16 @@ module.exports = {
plugin.options.ignoreOrder = true
}
// Disable TypeScript's config overwrite, as it interferes with incremental build caching.
// This ensures that `yarn start` and `yarn typecheck` share one cache.
if (plugin.constructor.name == 'ForkTsCheckerWebpackPlugin') {
delete plugin.options.typescript.configOverwrite
}
return plugin
})
.filter((plugin) => {
// Case sensitive paths are enforced by TypeScript.
// Case sensitive paths are already enforced by TypeScript.
// See https://www.typescriptlang.org/tsconfig#forceConsistentCasingInFileNames.
if (plugin instanceof CaseSensitivePathsPlugin) return false
@@ -100,20 +131,67 @@ module.exports = {
return true
})
// We're currently on Webpack 4.x which doesn't support the `exports` field in package.json.
// Instead, we need to manually map the import path to the correct exports path (eg dist or build folder).
// See https://github.com/webpack/webpack/issues/9509.
webpackConfig.resolve.alias['@uniswap/conedison'] = '@uniswap/conedison/dist'
// Configure webpack resolution:
webpackConfig.resolve = Object.assign(webpackConfig.resolve, {
plugins: webpackConfig.resolve.plugins.map((plugin) => {
// Allow vanilla-extract in production builds.
// This is necessary because create-react-app guards against external imports.
// See https://sandroroth.com/blog/vanilla-extract-cra#production-build.
if (plugin instanceof ModuleScopePlugin) {
plugin.allowedPaths.push(path.join(__dirname, 'node_modules/@vanilla-extract/webpack-plugin'))
}
return plugin
}),
// Webpack 5 does not resolve node modules, so we do so for those necessary:
fallback: {
// - react-markdown requires path
path: require.resolve('path-browserify'),
},
})
// Configure webpack transpilation (create-react-app specifies transpilation rules in a oneOf):
webpackConfig.module.rules[1].oneOf = webpackConfig.module.rules[1].oneOf.map((rule) => {
// The fallback rule (eg for dependencies).
if (rule.loader && rule.loader.match(/babel-loader/) && !rule.include) {
// Allow not-fully-specified modules so that legacy packages are still able to build.
rule.resolve = { fullySpecified: false }
// The class properties transform is required for @uniswap/analytics to build.
rule.options.plugins.push('@babel/plugin-proposal-class-properties')
}
return rule
})
// Configure webpack optimization:
webpackConfig.optimization.splitChunks = Object.assign(webpackConfig.optimization.splitChunks, {
// Cap the chunk size to 5MB.
// react-scripts suggests a chunk size under 1MB after gzip, but we can only measure maxSize before gzip.
// react-scripts also caps cacheable chunks at 5MB, which gzips to below 1MB, so we cap chunk size there.
// See https://github.com/facebook/create-react-app/blob/d960b9e/packages/react-scripts/config/webpack.config.js#L713-L716.
maxSize: 5 * 1024 * 1024,
webpackConfig.optimization = Object.assign(
webpackConfig.optimization,
isProduction
? {
splitChunks: {
// Cap the chunk size to 5MB.
// react-scripts suggests a chunk size under 1MB after gzip, but we can only measure maxSize before gzip.
// react-scripts also caps cacheable chunks at 5MB, which gzips to below 1MB, so we cap chunk size there.
// See https://github.com/facebook/create-react-app/blob/d960b9e/packages/react-scripts/config/webpack.config.js#L713-L716.
maxSize: 5 * 1024 * 1024,
// Optimize over all chunks, instead of async chunks (the default), so that initial chunks are also optimized.
chunks: 'all',
},
}
: {}
)
// Configure webpack caching:
webpackConfig.cache = Object.assign(webpackConfig.cache, {
cacheDirectory: getCacheDirectory('webpack'),
})
// Ignore failed source mappings to avoid spamming the console.
// Source mappings for a package will fail if the package does not provide them, but the build will still succeed,
// so it is unnecessary (and bothersome) to log it. This should be turned off when debugging missing sourcemaps.
// See https://webpack.js.org/loaders/source-map-loader#ignoring-warnings.
webpackConfig.ignoreWarnings = [/Failed to parse source map/]
return webpackConfig
},
},

View File

@@ -1,17 +1,29 @@
import codeCoverageTask from '@cypress/code-coverage/task'
import { defineConfig } from 'cypress'
import { setupHardhatEvents } from 'cypress-hardhat'
import { unlinkSync } from 'fs'
export default defineConfig({
projectId: 'yp82ef',
videoUploadOnPasses: false,
defaultCommandTimeout: 24000, // 2x average block time
chromeWebSecurity: false,
experimentalMemoryManagement: true, // better memory management, see https://github.com/cypress-io/cypress/pull/25462
retries: { runMode: 2 },
e2e: {
async setupNodeEvents(on, config) {
await setupHardhatEvents(on, config)
codeCoverageTask(on, config)
// Delete recorded videos for specs that passed without flakes.
on('after:spec', async (spec, results) => {
if (results && results.video) {
// If there were no failures (including flakes), delete the recorded video.
if (!results.tests?.some((test) => test.attempts.some((attempt) => attempt?.state === 'failed'))) {
unlinkSync(results.video)
}
}
})
return {
...config,
// Only enable Chrome.

View File

@@ -21,6 +21,7 @@ describe('Landing Page', () => {
})
it('shows landing page when the unicorn icon in nav is selected', () => {
cy.visit('/swap')
cy.get(getTestSelector('uniswap-logo')).click()
cy.get(getTestSelector('landing-page'))
})

View File

@@ -0,0 +1,58 @@
import { getTestSelector } from '../../utils'
describe('Mini Portfolio account drawer', () => {
beforeEach(() => {
cy.intercept(/api.uniswap.org\/v1\/graphql/, cy.spy().as('gqlSpy'))
cy.visit('/swap', { ethereum: 'hardhat' })
})
it('fetches balances when account button is first hovered', () => {
// The balances should not be fetched before the account button is hovered
cy.get('@gqlSpy').should('not.have.been.called')
// Balances should have been fetched once after hover
cy.get(getTestSelector('web3-status-connected')).trigger('mouseover')
cy.get('@gqlSpy').should('have.been.calledOnce')
// Balances should not be refetched upon second hover
cy.get(getTestSelector('web3-status-connected')).trigger('mouseover')
cy.get('@gqlSpy').should('have.been.calledOnce')
// Balances should not be refetched upon opening drawer
cy.get(getTestSelector('web3-status-connected')).click()
cy.get('@gqlSpy').should('have.been.calledOnce')
// Balances should not be refetched upon closing & reopening drawer
cy.get(getTestSelector('close-account-drawer')).click()
cy.get(getTestSelector('web3-status-connected')).click()
cy.get('@gqlSpy').should('have.been.calledOnce')
})
it('refetches balances when account changes', () => {
cy.hardhat().then((hardhat) => {
const accountA = hardhat.wallets[0].address
const accountB = hardhat.wallets[1].address
// Opens the account drawer
cy.get(getTestSelector('web3-status-connected')).click()
// A shortened version of the first account's address should be shown
cy.contains(accountA.slice(0, 6)).should('exist')
// Stores the current portfolio balance to later compare to next account's balance
cy.get(getTestSelector('portfolio-total-balance'))
.invoke('text')
.then((originalBalance) => {
// TODO(INFRA-3) Replace window.ethereum access below with cypress-hardhat utility
// Simulates the wallet changing accounts via eip-1193 event
cy.window().then((win) => win.ethereum.emit('accountsChanged', [accountB]))
// The second account's address should now be shown
cy.contains(accountB.slice(0, 6)).should('exist')
// The second account's portfolio balance should differ from the original balance
cy.get(getTestSelector('portfolio-total-balance')).should('not.have.text', originalBalance)
})
})
})
})

View File

@@ -0,0 +1,116 @@
import { USDC_MAINNET } from '../../../src/constants/tokens'
import { getTestSelector } from '../../utils'
describe('mini-portfolio activity history', () => {
beforeEach(() => {
cy.hardhat()
.then((hardhat) => hardhat.wallet.getTransactionCount())
.then((nonce) => {
// Mock graphql response to include specific nonces.
cy.intercept(
{
method: 'POST',
url: 'https://beta.api.uniswap.org/v1/graphql',
},
{
body: {
data: {
portfolios: [
{
id: 'UG9ydGZvbGlvOjB4NUNlYUI3NGU0NDZkQmQzYkY2OUUyNzcyMDBGMTI5ZDJiQzdBMzdhMQ==',
assetActivities: [
{
id: 'QXNzZXRBY3Rpdml0eTpWSEpoYm5OaFkzUnBiMjQ2TUhnME5tUm1PVGs0T0RrNVl6UmtNR1kzWTJNNE9HRTVNVFEzTURBME9EWmtOVGhrTURnNFpqbG1NelkxTnpRM1l6WXdZek15WVRFNE4yWXlaRFEwWVdVNFh6QjRZV1EyWXpCa05XTmlOVEZsWWpjMU5qUTFaRGszT1RneE4yRTJZVEkxTmpreU1UbG1ZbVE1Wmw4d2VEQXpOR0UwTURjMk5EUTROV1kzWlRBNFkyRXhOak0yTm1VMU1ETTBPVEZoTm1GbU56ZzFNR1E9',
timestamp: 1681150079,
type: 'UNKNOWN',
chain: 'ETHEREUM',
transaction: {
id: 'VHJhbnNhY3Rpb246MHg0NmRmOTk4ODk5YzRkMGY3Y2M4OGE5MTQ3MDA0ODZkNThkMDg4ZjlmMzY1NzQ3YzYwYzMyYTE4N2YyZDQ0YWU4XzB4YWQ2YzBkNWNiNTFlYjc1NjQ1ZDk3OTgxN2E2YTI1NjkyMTlmYmQ5Zl8weDAzNGE0MDc2NDQ4NWY3ZTA4Y2ExNjM2NmU1MDM0OTFhNmFmNzg1MGQ=',
blockNumber: 17019453,
hash: '0x46df998899c4d0f7cc88a914700486d58d088f9f365747c60c32a187f2d44ae8',
status: 'CONFIRMED',
to: '0x034a40764485f7e08ca16366e503491a6af7850d',
from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
nonce,
__typename: 'Transaction',
},
assetChanges: [],
__typename: 'AssetActivity',
},
{
id: 'QXNzZXRBY3Rpdml0eTpWSEpoYm5OaFkzUnBiMjQ2TUhneE16UXpaR1ppTlROaE9XRmpNR00yWW1aaVpqUTNNRFEyWWpObFkyRXhORGN3TUdZd00yWXhOMkV3WWpnM1pqWXpPRFpsWVRnNU16QTRNVFZtWmpoaFh6QjRZMkUzTXpOalkySm1OelZoTXpnME1ERXhPR1ZpT1RjNU9EVTJOemRpTkdRMk56TTBZemMwWmw4d2VERmlOVEUxTkdGaE5HSTRaakF5TjJJNVptUXhPVE0wTVRFek1tWmpPV1JoWlRFd1pqY3pOVGs9',
timestamp: 1681149995,
type: 'SEND',
chain: 'ETHEREUM',
transaction: {
id: 'VHJhbnNhY3Rpb246MHgxMzQzZGZiNTNhOWFjMGM2YmZiZjQ3MDQ2YjNlY2ExNDcwMGYwM2YxN2EwYjg3ZjYzODZlYTg5MzA4MTVmZjhhXzB4Y2E3MzNjY2JmNzVhMzg0MDExOGViOTc5ODU2NzdiNGQ2NzM0Yzc0Zl8weDFiNTE1NGFhNGI4ZjAyN2I5ZmQxOTM0MTEzMmZjOWRhZTEwZjczNTk=',
blockNumber: 17019446,
hash: '0x1343dfb53a9ac0c6bfbf47046b3eca14700f03f17a0b87f6386ea8930815ff8a',
status: 'CONFIRMED',
to: '0x1b5154aa4b8f027b9fd19341132fc9dae10f7359',
from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
nonce: nonce + 1,
__typename: 'Transaction',
},
assetChanges: [
{
__typename: 'TokenTransfer',
id: 'VG9rZW5UcmFuc2ZlcjoweDVjZWFiNzRlNDQ2ZGJkM2JmNjllMjc3MjAwZjEyOWQyYmM3YTM3YTFfMHhiMWRjNDlmMDY1N2FkNTA1YjUzNzUyN2RkOWE1MDk0YTM2NTkzMWMxXzB4MTM0M2RmYjUzYTlhYzBjNmJmYmY0NzA0NmIzZWNhMTQ3MDBmMDNmMTdhMGI4N2Y2Mzg2ZWE4OTMwODE1ZmY4YQ==',
asset: {
id: 'VG9rZW46RVRIRVJFVU1fMHgxY2MyYjA3MGNhZjAxNmE3ZGRjMzA0N2Y5MzI3MmU4Yzc3YzlkZGU5',
name: 'USD Coin (USDC)',
symbol: 'USDC',
address: '0x1cc2b070caf016a7ddc3047f93272e8c77c9dde9',
decimals: 6,
chain: 'ETHEREUM',
standard: null,
project: {
id: 'VG9rZW5Qcm9qZWN0OkVUSEVSRVVNXzB4MWNjMmIwNzBjYWYwMTZhN2RkYzMwNDdmOTMyNzJlOGM3N2M5ZGRlOQ==',
isSpam: true,
logo: null,
__typename: 'TokenProject',
},
__typename: 'Token',
},
tokenStandard: 'ERC20',
quantity: '18011.212084',
sender: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
recipient: '0xb1dc49f0657ad505b537527dd9a5094a365931c1',
direction: 'OUT',
transactedValue: null,
},
],
__typename: 'AssetActivity',
},
],
__typename: 'Portfolio',
},
],
},
},
}
).as('graphql')
})
})
it('should deduplicate activity history by nonce', () => {
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`, { ethereum: 'hardhat' }).hardhat({
automine: false,
})
// Input swap info.
cy.get('#swap-currency-input .token-amount-input').clear().type('1').should('have.value', '1')
cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
// Check activity history tab.
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('mini-portfolio-nav-activity')).click()
// Assert that the local pending transaction is replaced by a remote transaction with the same nonce.
cy.contains('Swapping').should('not.exist')
})
})

View File

@@ -51,9 +51,9 @@ describe('Permit2', () => {
.then((hardhat) => hardhat.approval.getPermit2Allowance({ owner: hardhat.wallet, token: INPUT_TOKEN }))
.then((allowance) => {
cy.wrap(MaxUint160.eq(allowance.amount)).should('eq', true)
// Asserts that the on-chain expiration is in 30 days, within a tolerance of 20 seconds.
// Asserts that the on-chain expiration is in 30 days, within a tolerance of 40 seconds.
const expected = Math.floor((approvalTime + 2_592_000_000) / 1000)
cy.wrap(allowance.expiration).should('be.closeTo', expected, 20)
cy.wrap(allowance.expiration).should('be.closeTo', expected, 40)
})
}

View File

@@ -9,7 +9,7 @@ describe('Service Worker', () => {
throw new Error(
'\n' +
'Service Worker tests must be run on a production-like build\n' +
'To test, build with `yarn build:e2e` and serve with `yarn serve`'
'To test, build with `yarn build` and serve with `yarn serve`'
)
}
})
@@ -20,66 +20,78 @@ describe('Service Worker', () => {
}
})
function unregister() {
return cy.log('unregister service worker').then(async () => {
const cacheKeys = await window.caches.keys()
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
if (cacheKey) {
await window.caches.delete(cacheKey)
}
function unregisterServiceWorker() {
return cy.log('unregisters service worker').then(async () => {
const sw = await window.navigator.serviceWorker.getRegistration(Cypress.config().baseUrl ?? undefined)
await sw?.unregister()
})
}
before(unregister)
after(unregister)
before(unregisterServiceWorker)
after(unregisterServiceWorker)
beforeEach(() => {
cy.intercept({ hostname: 'www.google-analytics.com' }, (req) => {
const body = req.body.toString()
if (req.query['ep.event_category'] === 'Service Worker' || body.includes('Service%20Worker')) {
if (req.query['en'] === 'Not Installed' || body.includes('Not%20Installed')) {
req.alias = 'NotInstalled'
} else if (req.query['en'] === 'Cache Hit' || body.includes('Cache%20Hit')) {
req.alias = 'CacheHit'
} else if (req.query['en'] === 'Cache Miss' || body.includes('Cache%20Miss')) {
req.alias = 'CacheMiss'
}
cy.intercept('https://api.uniswap.org/v1/amplitude-proxy', (req) => {
const body = JSON.stringify(req.body)
const serviceWorkerStatus = body.match(/"service_worker":"(\w+)"/)?.[1]
if (serviceWorkerStatus) {
req.alias = `ServiceWorker:${serviceWorkerStatus}`
}
})
})
it('installs a ServiceWorker', () => {
it('installs a ServiceWorker and reports the uninstalled status to analytics', () => {
cy.visit('/', { serviceWorker: true })
.get('#swap-page')
// This is emitted after caching the entry file, which takes some time to load.
.wait('@NotInstalled', { timeout: 60000 })
.window()
.and((win) => {
expect(win.navigator.serviceWorker.controller?.state).to.equal('activated')
})
cy.wait('@ServiceWorker:uninstalled')
cy.window().should(
'have.nested.property',
// The parent is checked instead of the AUT because it is on the same origin,
// and the AUT will not be considered "activated" until the parent is idle.
'parent.navigator.serviceWorker.controller.state',
'activated'
)
})
it('records a cache hit', () => {
cy.visit('/', { serviceWorker: true }).get('#swap-page').wait('@CacheHit', { timeout: 20000 })
})
it('records a cache miss', () => {
cy.then(async () => {
const cacheKeys = await window.caches.keys()
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
assert(cacheKey)
const cache = await window.caches.open(cacheKey)
const keys = await cache.keys()
const key = keys.find((key) => key.url.match(/index/))
assert(key)
await cache.put(key, new Response())
describe('cache hit', () => {
it('reports the hit to analytics', () => {
cy.visit('/', { serviceWorker: true })
cy.wait('@ServiceWorker:hit')
})
})
describe('cache miss', () => {
let cache: Cache | undefined
let request: Request | undefined
let response: Response | undefined
before(() => {
// Mocks the index.html in the cache to force a cache miss.
cy.visit('/', { serviceWorker: true }).then(async () => {
const cacheKeys = await window.caches.keys()
const cacheKey = cacheKeys.find((key) => key.match(/precache/))
assert(cacheKey)
cache = await window.caches.open(cacheKey)
const keys = await cache.keys()
request = keys.find((key) => key.url.match(/index/))
assert(request)
response = await cache.match(request)
assert(response)
await cache.put(request, new Response())
})
})
after(() => {
// Restores the index.html in the cache so that re-runs behave as expected.
// This is necessary because the Service Worker will not re-populate the cache.
cy.then(async () => {
if (cache && request && response) {
await cache.put(request, response)
}
})
})
it('reports the miss to analytics', () => {
cy.visit('/', { serviceWorker: true })
cy.wait('@ServiceWorker:miss')
})
.visit('/', { serviceWorker: true })
.get('#swap-page')
.wait('@CacheMiss', { timeout: 20000 })
})
})

View File

@@ -1,358 +0,0 @@
import { BigNumber } from '@ethersproject/bignumber'
import { parseEther } from '@ethersproject/units'
import { SupportedChainId, WETH9 } from '@uniswap/sdk-core'
import { UNI, USDC_MAINNET } from '../../src/constants/tokens'
import { getTestSelector } from '../utils'
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
describe('Swap', () => {
describe('Swap on main page', () => {
before(() => {
cy.visit('/swap', { ethereum: 'hardhat' })
})
it('starts with ETH selected by default', () => {
cy.get(`#swap-currency-input .token-amount-input`).should('have.value', '')
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'ETH')
cy.get(`#swap-currency-output .token-amount-input`).should('not.have.value')
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'Select token')
})
it('can enter an amount into input', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('0.001').should('have.value', '0.001')
})
it('zero swap amount', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('0.0').should('have.value', '0.0')
})
it('invalid swap amount', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('\\').should('have.value', '')
})
it('can enter an amount into output', () => {
cy.get('#swap-currency-output .token-amount-input').clear().type('0.001').should('have.value', '0.001')
})
it('zero output amount', () => {
cy.get('#swap-currency-output .token-amount-input').clear().type('0.0').should('have.value', '0.0')
})
it('should render an error when a transaction fails due to a passed deadline', () => {
const DEADLINE_MINUTES = 1
const TEN_MINUTES_MS = 1000 * 60 * DEADLINE_MINUTES * 10
cy.visit('/swap', { ethereum: 'hardhat' })
.hardhat({ automine: false })
.then((hardhat) => {
cy.then(() => hardhat.getBalance(hardhat.wallet.address, USDC_MAINNET))
.then((balance) => Number(balance.toFixed(1)))
.then((initialBalance) => {
// Input swap info.
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.contains('USDC').click()
cy.get('#swap-currency-output .token-amount-input').clear().type('1')
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
// Set deadline to minimum. (1 minute)
cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.get(getTestSelector('transaction-deadline-settings')).click()
cy.get(getTestSelector('deadline-input')).clear().type(DEADLINE_MINUTES.toString())
cy.get('body').click('topRight')
cy.get(getTestSelector('deadline-input')).should('not.exist')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
// Dismiss the modal that appears when a transaction is broadcast to the network.
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
// The UI should show the transaction as pending.
cy.contains('1 Pending').should('exist')
// Mine a block past the deadline.
cy.then(() => hardhat.mine(1, TEN_MINUTES_MS)).then(() => {
// The UI should no longer show the transaction as pending.
cy.contains('1 Pending').should('not.exist')
// Check that the user is informed of the failure
cy.contains('Swap failed').should('exist')
// Check that the balance is unchanged in the UI
cy.get('#swap-currency-output [data-testid="balance-text"]').should(
'have.text',
`Balance: ${initialBalance}`
)
// Check that the balance is unchanged on chain
cy.then(() => hardhat.getBalance(hardhat.wallet.address, USDC_MAINNET))
.then((balance) => Number(balance.toFixed(1)))
.should('eq', initialBalance)
})
})
})
})
it('should default inputs from URL params ', () => {
cy.visit(`/swap?inputCurrency=${UNI_MAINNET.address}`, { ethereum: 'hardhat' })
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'UNI')
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'Select token')
cy.visit(`/swap?outputCurrency=${UNI_MAINNET.address}`, { ethereum: 'hardhat' })
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'Select token')
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${UNI_MAINNET.address}`, { ethereum: 'hardhat' })
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'ETH')
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
})
it('ETH to wETH is same value (wrapped swaps have no price impact)', () => {
cy.visit('/swap')
cy.get(`#swap-currency-output .open-currency-select-button`).click()
cy.contains('WETH').click()
cy.get('#swap-currency-input .token-amount-input').clear().type('0.01')
cy.get('#swap-currency-output .token-amount-input').should('have.value', '0.01')
})
it('Opens and closes the settings menu', () => {
cy.visit('/swap')
cy.contains('Settings').should('not.exist')
cy.get(getTestSelector('swap-settings-button')).click()
cy.contains('Max slippage').should('exist')
cy.contains('Transaction deadline').should('exist')
cy.contains('Auto Router API').should('exist')
cy.get(getTestSelector('swap-settings-button')).click()
cy.contains('Settings').should('not.exist')
})
it('inputs reset when navigating between pages', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('0.01')
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
cy.visit('/pool')
cy.visit('/swap')
cy.get('#swap-currency-input .token-amount-input').should('have.value', '')
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
})
it('can swap ETH for USDC', () => {
cy.visit('/swap', { ethereum: 'hardhat' })
const TOKEN_ADDRESS = USDC_MAINNET.address
const BALANCE_INCREMENT = 1
cy.hardhat().then((hardhat) => {
cy.then(() => hardhat.getBalance(hardhat.wallet.address, USDC_MAINNET))
.then((balance) => Number(balance.toFixed(1)))
.then((initialBalance) => {
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.get(getTestSelector('token-search-input')).clear().type(TOKEN_ADDRESS)
cy.contains('USDC').click()
cy.get('#swap-currency-output .token-amount-input').clear().type(BALANCE_INCREMENT.toString())
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
// ui check
cy.get('#swap-currency-output [data-testid="balance-text"]').should(
'have.text',
`Balance: ${initialBalance + BALANCE_INCREMENT}`
)
// chain state check
cy.then(() => hardhat.getBalance(hardhat.wallet.address, USDC_MAINNET))
.then((balance) => Number(balance.toFixed(1)))
.should('eq', initialBalance + BALANCE_INCREMENT)
})
})
})
})
it('should be able to wrap ETH', () => {
const BALANCE_INCREMENT = 1
cy.visit('/swap', { ethereum: 'hardhat' })
.hardhat()
.then((hardhat) => {
cy.then(() => hardhat.getBalance(hardhat.wallet.address, WETH9[SupportedChainId.MAINNET]))
.then((balance) => Number(balance.toFixed(1)))
.then((initialWethBalance) => {
// Select WETH for the token output.
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.contains('WETH').click()
// Enter the amount to wrap.
cy.get('#swap-currency-output .token-amount-input').clear().type(BALANCE_INCREMENT.toString())
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
// Click the wrap button.
cy.get(getTestSelector('wrap-button')).should('not.be.disabled')
cy.get(getTestSelector('wrap-button')).click()
// The pending transaction indicator should be visible.
cy.get(getTestSelector('web3-status-connected')).should('have.descendants', ':contains("1 Pending")')
// <automine transaction>
// The pending transaction indicator should be gone.
cy.get(getTestSelector('web3-status-connected')).should('not.have.descendants', ':contains("1 Pending")')
// The UI balance should have increased.
cy.get('#swap-currency-output [data-testid="balance-text"]').should(
'have.text',
`Balance: ${initialWethBalance + BALANCE_INCREMENT}`
)
// There should be a successful wrap notification.
cy.contains('Wrapped').should('exist')
// The user's WETH account balance should have increased
cy.then(() => hardhat.getBalance(hardhat.wallet.address, WETH9[SupportedChainId.MAINNET]))
.then((balance) => Number(balance.toFixed(1)))
.should('eq', initialWethBalance + BALANCE_INCREMENT)
})
})
})
it('should be able to unwrap WETH', () => {
const BALANCE_INCREMENT = 1
cy.visit('/swap', { ethereum: 'hardhat' })
.hardhat()
.then((hardhat) => {
cy.then(() => hardhat.getBalance(hardhat.wallet.address, WETH9[SupportedChainId.MAINNET])).then(
(initialBalance) => {
// Select WETH for the token output.
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.contains('WETH').click()
// Enter the amount to wrap.
cy.get('#swap-currency-output .token-amount-input').clear().type(BALANCE_INCREMENT.toString())
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
// Click the wrap button.
cy.get(getTestSelector('wrap-button')).should('not.be.disabled')
cy.get(getTestSelector('wrap-button')).click()
// <automine transaction>
// The pending transaction indicator should be visible.
cy.contains('1 Pending').should('exist')
// The user should see a notification telling them they successfully wrapped their ETH.
cy.contains('Wrapped').should('exist')
// Switch to unwrapping the ETH we just wrapped.
cy.get(getTestSelector('swap-currency-button')).click()
cy.get(getTestSelector('wrap-button')).should('not.be.disabled')
// Click the Unwrap button.
cy.get(getTestSelector('wrap-button')).click()
// The pending transaction indicator should be visible.
cy.contains('1 Pending').should('exist')
// <automine transaction>
// The pending transaction indicator should be gone.
cy.contains('1 Pending').should('not.exist')
// The user should see a notification telling them they successfully unwrapped their ETH.
cy.contains('Unwrapped').should('exist')
// The UI balance should have decreased.
cy.get('#swap-currency-input [data-testid="balance-text"]').should(
'have.text',
`Balance: ${initialBalance.toFixed(0)}`
)
// There should be a successful unwrap notification.
cy.contains('Unwrapped').should('exist')
// The user's WETH account balance should not have changed from the initial balance
cy.then(() => hardhat.getBalance(hardhat.wallet.address, WETH9[SupportedChainId.MAINNET]))
.then((balance) => balance.toFixed(0))
.should('eq', initialBalance.toFixed(0))
}
)
})
})
it('should render and dismiss the wallet rejection modal', () => {
cy.visit('/swap', { ethereum: 'hardhat' })
.hardhat()
.then((hardhat) => {
cy.stub(hardhat.wallet, 'sendTransaction').log(false).rejects(new Error('user cancelled'))
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.get(getTestSelector('token-search-input')).clear().type(USDC_MAINNET.address)
cy.contains('USDC').click()
cy.get('#swap-currency-output .token-amount-input').clear().type('1')
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.contains('Transaction rejected').should('exist')
cy.contains('Dismiss').click()
cy.contains('Transaction rejected').should('not.exist')
})
})
it.skip('should render an error for slippage failure', () => {
cy.visit('/swap', { ethereum: 'hardhat' })
.hardhat({ automine: false })
.then((hardhat) => {
cy.then(() => hardhat.provider.getBalance(hardhat.wallet.address)).then((initialBalance) => {
// Gas estimation fails for this transaction (that would normally fail), so we stub it.
const send = cy.stub(hardhat.provider, 'send').log(false)
send.withArgs('eth_estimateGas').resolves(BigNumber.from(2_000_000))
send.callThrough()
// Set slippage to a very low value.
cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.get(getTestSelector('max-slippage-settings')).click()
cy.get(getTestSelector('slippage-input')).clear().type('0.01')
cy.get('body').click('topRight')
cy.get(getTestSelector('slippage-input')).should('not.exist')
// Open the currency select modal.
cy.get('#swap-currency-output .open-currency-select-button').click()
// Select UNI as output token
cy.get(getTestSelector('token-search-input')).clear().type('Uniswap')
cy.get(getTestSelector('currency-list-wrapper'))
.contains(/^Uniswap$/)
.first()
// Our scrolling library (react-window) seems to freeze when acted on by cypress, with this element set to
// `pointer-events: none`. This can be ignored using `{force: true}`.
.click({ force: true })
// Swap 2 times.
const AMOUNT_TO_SWAP = 400
const NUMBER_OF_SWAPS = 2
const INDIVIDUAL_SWAP_INPUT = AMOUNT_TO_SWAP / NUMBER_OF_SWAPS
cy.get('#swap-currency-input .token-amount-input').clear().type(INDIVIDUAL_SWAP_INPUT.toString())
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
cy.get('#swap-currency-input .token-amount-input').clear().type(INDIVIDUAL_SWAP_INPUT.toString())
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
// The pending transaction indicator should be visible.
cy.contains('Pending').should('exist')
cy.then(() => hardhat.mine()).then(() => {
// The pending transaction indicator should not be visible.
cy.contains('Pending').should('not.exist')
// Check for a failed transaction notification.
cy.contains('Swap failed').should('exist')
// Assert that at least one of the swaps failed due to slippage.
cy.then(() => hardhat.provider.getBalance(hardhat.wallet.address)).then((finalBalance) => {
expect(finalBalance.gt(initialBalance.sub(parseEther(AMOUNT_TO_SWAP.toString())))).to.be.true
})
})
})
})
})
})

View File

@@ -0,0 +1,114 @@
import { BigNumber } from '@ethersproject/bignumber'
import { SupportedChainId } from '@uniswap/sdk-core'
import { UNI, USDC_MAINNET } from '../../../src/constants/tokens'
import { getBalance, getTestSelector } from '../../utils'
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
describe('Swap errors', () => {
it('wallet rejection', () => {
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`, { ethereum: 'hardhat' })
cy.hardhat().then((hardhat) => {
// Stub the wallet to reject any transaction.
cy.stub(hardhat.wallet, 'sendTransaction').log(false).rejects(new Error('user cancelled'))
// Attempt to swap.
cy.get('#swap-currency-output .token-amount-input').clear().type('1').should('have.value', '1')
cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.contains('Transaction rejected').should('exist')
cy.contains('Dismiss').click()
cy.contains('Transaction rejected').should('not.exist')
})
})
it('transaction past deadline', () => {
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${USDC_MAINNET.address}`, { ethereum: 'hardhat' })
cy.hardhat({ automine: false })
getBalance(USDC_MAINNET).then((initialBalance) => {
// Set deadline to minimum. (1 minute)
cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.get(getTestSelector('transaction-deadline-settings')).click()
cy.get(getTestSelector('deadline-input')).clear().type('1') // 1 minute
// Click outside of modal to dismiss it.
cy.get('body').click('topRight')
cy.get(getTestSelector('deadline-input')).should('not.exist')
// Attempt to swap.
cy.get('#swap-currency-output .token-amount-input').clear().type('1').should('have.value', '1')
cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
// The pending transaction indicator should reflect the state.
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
cy.hardhat().then((hardhat) => hardhat.mine(1, /* 10 minutes */ 1000 * 60 * 10)) // mines past the deadline
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
// TODO(WEB-2085): Fix this test - transaction popups are flakey.
// cy.get(getTestSelector('transaction-popup')).contains('Swap failed')
// Verify the balance is unchanged.
cy.get('#swap-currency-output [data-testid="balance-text"]').should('have.text', `Balance: ${initialBalance}`)
getBalance(USDC_MAINNET).should('eq', initialBalance)
})
})
it('slippage failure', () => {
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${UNI_MAINNET.address}`, { ethereum: 'hardhat' })
cy.hardhat({ automine: false })
getBalance(USDC_MAINNET).then((initialBalance) => {
// Gas estimation fails for this transaction (that would normally fail), so we stub it.
cy.hardhat().then((hardhat) => {
const send = cy.stub(hardhat.provider, 'send').log(false)
send.withArgs('eth_estimateGas').resolves(BigNumber.from(2_000_000))
send.callThrough()
})
// Set slippage to a very low value.
cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.get(getTestSelector('max-slippage-settings')).click()
cy.get(getTestSelector('slippage-input')).clear().type('0.01')
// Click outside of modal to dismiss it.
cy.get('body').click('topRight')
cy.get(getTestSelector('slippage-input')).should('not.exist')
// Swap 2 times.
const AMOUNT_TO_SWAP = 200
cy.get('#swap-currency-input .token-amount-input')
.clear()
.type(AMOUNT_TO_SWAP.toString())
.should('have.value', AMOUNT_TO_SWAP.toString())
cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
cy.get('#swap-currency-input .token-amount-input')
.clear()
.type(AMOUNT_TO_SWAP.toString())
.should('have.value', AMOUNT_TO_SWAP.toString())
cy.get('#swap-currency-output .token-amount-input').should('not.have.value', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
// The pending transaction indicator should reflect the state.
cy.get(getTestSelector('web3-status-connected')).should('contain', '2 Pending')
cy.hardhat().then((hardhat) => hardhat.mine())
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
// TODO(WEB-2085): Fix this test - transaction popups are flakey.
// cy.get(getTestSelector('transaction-popup')).contains('Swap failed')
// Assert that the transactions were unsuccessful by checking on-chain balance.
getBalance(UNI_MAINNET).should('equal', initialBalance)
})
})
})

View File

@@ -0,0 +1,14 @@
import { getTestSelector } from '../../utils'
describe('Swap settings', () => {
it('Opens and closes the settings menu', () => {
cy.visit('/swap')
cy.contains('Settings').should('not.exist')
cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.contains('Max slippage').should('exist')
cy.contains('Transaction deadline').should('exist')
cy.contains('Auto Router API').should('exist')
cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.contains('Settings').should('not.exist')
})
})

View File

@@ -0,0 +1,70 @@
import { SupportedChainId } from '@uniswap/sdk-core'
import { UNI, USDC_MAINNET } from '../../../src/constants/tokens'
import { getBalance, getTestSelector } from '../../utils'
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
describe('Swap', () => {
describe('Swap on main page', () => {
it('starts with ETH selected by default', () => {
cy.visit('/swap')
cy.get(`#swap-currency-input .token-amount-input`).should('have.value', '')
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'ETH')
cy.get(`#swap-currency-output .token-amount-input`).should('not.have.value')
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'Select token')
})
it('should default inputs from URL params ', () => {
cy.visit(`/swap?inputCurrency=${UNI_MAINNET.address}`)
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'UNI')
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'Select token')
cy.visit(`/swap?outputCurrency=${UNI_MAINNET.address}`)
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'Select token')
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${UNI_MAINNET.address}`)
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'ETH')
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
})
it('inputs reset when navigating between pages', () => {
cy.visit('/swap')
cy.get('#swap-currency-input .token-amount-input').should('have.value', '')
cy.get('#swap-currency-input .token-amount-input').type('0.01').should('have.value', '0.01')
cy.visit('/pool').visit('/swap')
cy.get('#swap-currency-input .token-amount-input').should('have.value', '')
})
it('swaps ETH for USDC', () => {
cy.visit('/swap', { ethereum: 'hardhat' })
cy.hardhat({ automine: false })
getBalance(USDC_MAINNET).then((initialBalance) => {
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.get(getTestSelector('token-search-input')).clear().type(USDC_MAINNET.address)
cy.contains('USDC').click()
cy.get('#swap-currency-output .token-amount-input').clear().type('1').should('have.value', '1')
cy.get('#swap-currency-input .token-amount-input').should('not.have.value', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
// The pending transaction indicator should reflect the state.
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
cy.hardhat().then((hardhat) => hardhat.mine())
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
// TODO(WEB-2085): Fix this test - transaction popups are flakey.
// cy.get(getTestSelector('transaction-popup')).contains('Swapped')
// Verify the balance is updated.
cy.get('#swap-currency-output [data-testid="balance-text"]').should(
'have.text',
`Balance: ${initialBalance + 1}`
)
getBalance(USDC_MAINNET).should('eq', initialBalance + 1)
})
})
})
})

View File

@@ -0,0 +1,86 @@
import { CurrencyAmount, SupportedChainId, WETH9 } from '@uniswap/sdk-core'
import { getBalance, getTestSelector } from '../../utils'
const WETH = WETH9[SupportedChainId.MAINNET]
describe('Swap wrap', () => {
beforeEach(() => {
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${WETH.address}`, { ethereum: 'hardhat' }).hardhat({
automine: false,
})
})
it('ETH to wETH is same value (wrapped swaps have no price impact)', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('0.01').should('have.value', '0.01')
cy.get('#swap-currency-output .token-amount-input').should('have.value', '0.01')
cy.get('#swap-currency-output .token-amount-input').clear().type('0.02').should('have.value', '0.02')
cy.get('#swap-currency-input .token-amount-input').should('have.value', '0.02')
})
it('should be able to wrap ETH', () => {
getBalance(WETH).then((initialWethBalance) => {
cy.contains('Enter ETH amount')
// Enter the amount to wrap.
cy.get('#swap-currency-output .token-amount-input').click().type('1').should('have.value', 1)
// This also ensures we don't click "Wrap" before the UI has caught up.
cy.get('#swap-currency-input .token-amount-input').should('have.value', 1)
// Click the wrap button.
cy.contains('Wrap').click()
// The pending transaction indicator should reflect the state.
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
cy.hardhat().then((hardhat) => hardhat.mine())
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
// TODO(WEB-2085): Fix this test - transaction popups are flakey.
// cy.get(getTestSelector('transaction-popup')).contains('Wrapped')
// cy.get(getTestSelector('transaction-popup')).contains('1.00 ETH for 1.00 WETH')
// The UI balance should have increased.
cy.get('#swap-currency-output').should('contain', `Balance: ${initialWethBalance + 1}`)
// The user's WETH account balance should have increased
getBalance(WETH).should('equal', initialWethBalance + 1)
})
})
it('should be able to unwrap WETH', () => {
cy.hardhat().then(async (hardhat) => {
await hardhat.fund(hardhat.wallet, CurrencyAmount.fromRawAmount(WETH, 1e18))
await hardhat.mine()
})
getBalance(WETH).then((initialWethBalance) => {
// Swap input/output to unwrap WETH.
cy.get(getTestSelector('swap-currency-button')).click()
cy.contains('Enter WETH amount')
// Enter the amount to unwrap.
cy.get('#swap-currency-output .token-amount-input').click().type('1').should('have.value', 1)
// This also ensures we don't click "Wrap" before the UI has caught up.
cy.get('#swap-currency-input .token-amount-input').should('have.value', 1)
// Click the unwrap button.
cy.contains('Unwrap').click()
// The pending transaction indicator should reflect the state.
cy.get(getTestSelector('web3-status-connected')).should('contain', '1 Pending')
cy.hardhat().then((hardhat) => hardhat.mine())
cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending')
// TODO(WEB-2085): Fix this test - transaction popups are flakey.
// cy.get(getTestSelector('transaction-popup')).contains('Unwrapped')
// cy.get(getTestSelector('transaction-popup')).contains('1.00 WETH for 1.00 ETH')
// The UI balance should have increased.
cy.get('#swap-currency-input').should('contain', `Balance: ${initialWethBalance - 1}`)
// The user's WETH account balance should have increased
getBalance(WETH).should('equal', initialWethBalance - 1)
})
})
})

View File

@@ -1,7 +1,6 @@
import { SupportedChainId, WETH9 } from '@uniswap/sdk-core'
import { UNI } from '../../src/constants/tokens'
import { FeatureFlag } from '../../src/featureFlags/flags/featureFlags'
import { getTestSelector } from '../utils'
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
@@ -96,7 +95,6 @@ describe('Token details', () => {
cy.viewport(1200, 800)
cy.visit(`/tokens/ethereum/${UNI_MAINNET.address}`, {
ethereum: 'hardhat',
featureFlags: [FeatureFlag.removeWidget],
}).then(() => {
cy.wait('@eth_blockNumber')
cy.scrollTo('top')
@@ -121,7 +119,7 @@ describe('Token details', () => {
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
cy.get(`#swap-currency-input .open-currency-select-button`).click()
cy.contains('WETH').click()
cy.visit('/swap', { featureFlags: [FeatureFlag.removeWidget] })
cy.visit('/swap')
cy.contains('UNI').should('not.exist')
cy.contains('WETH').should('not.exist')
})
@@ -147,7 +145,7 @@ describe('Token details', () => {
})
it('should show a L2 token even if the user is connected to a different network', () => {
cy.visit('/tokens', { ethereum: 'hardhat', featureFlags: [FeatureFlag.removeWidget] })
cy.visit('/tokens', { ethereum: 'hardhat' })
cy.get(getTestSelector('tokens-network-filter-selected')).click()
cy.get(getTestSelector('tokens-network-filter-option-arbitrum')).click()
cy.get(getTestSelector('tokens-network-filter-selected')).should('contain', 'Arbitrum')

View File

@@ -1,96 +1,108 @@
import { getTestSelector } from '../utils'
function visit(darkMode: boolean) {
cy.visit('/swap', {
onBeforeLoad(win) {
cy.stub(win, 'matchMedia')
.withArgs('(prefers-color-scheme: dark)')
.returns({
matches: darkMode,
addEventListener() {
// do nothing
},
})
},
})
}
describe('Wallet Dropdown', () => {
before(() => {
cy.visit('/pools')
function itShouldChangeTheTheme() {
it('should change the theme', () => {
cy.get(getTestSelector('theme-lightmode')).click()
cy.get(getTestSelector('theme-lightmode')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-darkmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-auto')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-darkmode')).click()
cy.get(getTestSelector('theme-lightmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-darkmode')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-auto')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-auto')).click()
cy.get(getTestSelector('theme-lightmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-darkmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-auto')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
})
}
function itShouldChangeTheLanguage() {
it('should select a language', () => {
cy.get(getTestSelector('wallet-language-item')).contains('Deutsch').click({ force: true })
cy.get(getTestSelector('wallet-header')).should('contain', 'Sprache')
cy.get(getTestSelector('wallet-language-item')).contains('English').click({ force: true })
cy.get(getTestSelector('wallet-header')).should('contain', 'Language')
cy.get(getTestSelector('wallet-back')).click()
})
}
describe('connected', () => {
beforeEach(() => {
cy.visit('/')
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('wallet-settings')).click()
})
itShouldChangeTheTheme()
itShouldChangeTheLanguage()
})
it('should change the theme', () => {
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('wallet-settings')).click()
cy.get(getTestSelector('theme-lightmode')).click()
cy.get(getTestSelector('theme-lightmode')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-darkmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-auto')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-darkmode')).click()
cy.get(getTestSelector('theme-lightmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-darkmode')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-auto')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-auto')).click()
cy.get(getTestSelector('theme-lightmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-darkmode')).should('have.css', 'background-color', 'rgba(0, 0, 0, 0)')
cy.get(getTestSelector('theme-auto')).should('not.have.css', 'background-color', 'rgba(0, 0, 0, 0)')
describe('disconnected', () => {
beforeEach(() => {
cy.visit('/')
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('wallet-disconnect')).click()
cy.get(getTestSelector('wallet-settings')).click()
})
itShouldChangeTheTheme()
itShouldChangeTheLanguage()
})
it('should select a language', () => {
cy.get(getTestSelector('wallet-language-item')).contains('Deutsch').click({ force: true })
cy.get(getTestSelector('wallet-header')).should('contain', 'Sprache')
cy.get(getTestSelector('wallet-language-item')).contains('English').click({ force: true })
cy.get(getTestSelector('wallet-header')).should('contain', 'Language')
cy.get(getTestSelector('wallet-back')).click()
describe('with color theme', () => {
function visitSwapWithColorTheme({ dark }: { dark: boolean }) {
cy.visit('/swap', {
onBeforeLoad(win) {
cy.stub(win, 'matchMedia')
.withArgs('(prefers-color-scheme: dark)')
.returns({
matches: dark,
addEventListener() {
/* noop */
},
removeEventListener() {
/* noop */
},
})
},
})
}
it('should properly use dark system theme when auto theme setting is selected', () => {
visitSwapWithColorTheme({ dark: true })
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('wallet-settings')).click()
cy.get(getTestSelector('theme-auto')).click()
cy.get(getTestSelector('wallet-header')).should('have.css', 'color', 'rgb(152, 161, 192)')
})
it('should properly use light system theme when auto theme setting is selected', () => {
visitSwapWithColorTheme({ dark: false })
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('wallet-settings')).click()
cy.get(getTestSelector('theme-auto')).click()
cy.get(getTestSelector('wallet-header')).should('have.css', 'color', 'rgb(119, 128, 160)')
})
})
it('should change the theme when not connected', () => {
cy.get(getTestSelector('wallet-disconnect')).click()
cy.get(getTestSelector('wallet-settings')).click()
cy.get(getTestSelector('theme-lightmode')).should('exist')
})
describe('mobile', () => {
beforeEach(() => {
cy.viewport('iphone-6').visit('/')
})
it('should select a language when not connected', () => {
cy.get(getTestSelector('wallet-language-item')).contains('Deutsch').click({ force: true })
cy.get(getTestSelector('wallet-header')).should('contain', 'Sprache')
cy.get(getTestSelector('wallet-language-item')).contains('English').click({ force: true })
cy.get(getTestSelector('wallet-header')).should('contain', 'Language')
cy.get(getTestSelector('wallet-back')).click()
})
it('should dismiss the wallet bottom sheet when clicking buy crypto', () => {
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('wallet-buy-crypto')).click()
cy.contains('Buy crypto').should('not.be.visible')
})
it('should properly use dark system theme when auto theme setting is selected', () => {
visit(true)
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('wallet-settings')).click()
cy.get(getTestSelector('theme-auto')).click()
cy.get(getTestSelector('wallet-header')).should('have.css', 'color', 'rgb(152, 161, 192)')
})
it('should properly use light system theme when auto theme setting is selected', () => {
visit(false)
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('wallet-settings')).click()
cy.get(getTestSelector('theme-auto')).click()
cy.get(getTestSelector('wallet-header')).should('have.css', 'color', 'rgb(119, 128, 160)')
})
it('should dismiss the wallet bottom sheet when clicking buy crypto', () => {
visit(false)
cy.viewport('iphone-6')
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('wallet-buy-crypto')).click()
cy.contains('Buy crypto').should('not.be.visible')
})
it('should use a bottom sheet and dismiss when on a mobile screen size', () => {
visit(true)
cy.viewport('iphone-6')
cy.get(getTestSelector('web3-status-connected')).click()
cy.root().click(15, 40)
cy.get(getTestSelector('wallet-settings')).should('not.be.visible')
it('should use a bottom sheet and dismiss when on a mobile screen size', () => {
cy.get(getTestSelector('web3-status-connected')).click()
cy.root().click(15, 40)
cy.get(getTestSelector('wallet-settings')).should('not.be.visible')
})
})
})

View File

@@ -1,3 +1,13 @@
import { Currency } from '@uniswap/sdk-core'
export const getTestSelector = (selectorId: string) => `[data-testid=${selectorId}]`
export const getTestSelectorStartsWith = (selectorId: string) => `[data-testid^=${selectorId}]`
/** Gets the balance of a token as a Chainable. */
export function getBalance(token: Currency) {
return cy
.hardhat()
.then((hardhat) => hardhat.getBalance(hardhat.wallet, token))
.then((balance) => Number(balance.toFixed(1)))
}

View File

@@ -0,0 +1,36 @@
/* eslint-env node */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'enforce use of retry() for dynamic imports',
category: 'Best Practices',
recommended: false,
},
schema: [],
},
create(context) {
return {
ImportExpression(node) {
const grandParent = node.parent.parent
if (
!(
grandParent &&
grandParent.type === 'CallExpression' &&
// Technically, we are only checking that a function named `retry` wraps the dynamic import.
// We do not go as far as enforcing that it is import('utils/retry').retry
grandParent.callee.name === 'retry' &&
grandParent.arguments.length === 1 &&
grandParent.arguments[0].type === 'ArrowFunctionExpression'
)
) {
context.report({
node,
message: 'Dynamic import should be wrapped in retry (see `utils/retry.ts`): `retry(() => import(...))`',
})
}
},
}
},
}

View File

@@ -19,7 +19,7 @@ module.exports = {
chainId: 1,
forking: mainnetFork,
accounts: {
count: 1,
count: 2,
},
mining: {
auto: true, // automine to make tests easier to write.

View File

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

View File

@@ -18,7 +18,7 @@
"i18n:pseudo": "lingui extract --locale pseudo",
"i18n:compile": "lingui compile",
"i18n": "yarn i18n:extract --clean && yarn i18n:compile",
"prepare": "yarn ajv && yarn contracts && yarn graphql && yarn i18n",
"prepare": "concurrently \"npm:ajv\" \"npm:contracts\" \"npm:graphql\" \"npm:i18n\"",
"start": "craco start",
"build": "craco build",
"build:e2e": "REACT_APP_CSP_ALLOW_UNSAFE_EVAL=true REACT_APP_ADD_COVERAGE_INSTRUMENTATION=true craco build",
@@ -67,7 +67,7 @@
]
},
"devDependencies": {
"@craco/craco": "6.4.3",
"@craco/craco": "^7.1.0",
"@ethersproject/experimental": "^5.4.0",
"@lingui/cli": "^3.9.0",
"@testing-library/jest-dom": "^5.16.4",
@@ -98,24 +98,29 @@
"@types/ua-parser-js": "^0.7.35",
"@types/uuid": "^8.3.4",
"@types/wcag-contrast": "^3.0.0",
"@uniswap/eslint-config": "^1.2.0",
"@uniswap/default-token-list": "^9.4.0",
"@uniswap/eslint-config": "^1.2.0",
"@vanilla-extract/babel-plugin": "^1.1.7",
"@vanilla-extract/jest-transform": "^1.1.1",
"@vanilla-extract/webpack-plugin": "^2.1.11",
"babel-plugin-istanbul": "^6.1.1",
"cypress": "10.3.1",
"buffer": "^6.0.3",
"concurrently": "^8.0.1",
"cypress": "12.12.0",
"cypress-hardhat": "^2.3.0",
"env-cmd": "^10.1.0",
"eslint": "^7.11.0",
"eslint-plugin-import": "^2.27",
"eslint-plugin-rulesdir": "^0.2.2",
"hardhat": "^2.14.0",
"jest-fail-on-console": "^3.1.1",
"jest-fetch-mock": "^3.0.3",
"jest-styled-components": "^7.0.8",
"ms.macro": "^2.0.0",
"path-browserify": "^1.0.1",
"prettier": "^2.7.1",
"react-scripts": "^4.0.3",
"process": "^0.11.10",
"react-scripts": "^5.0.1",
"resize-observer-polyfill": "^1.5.1",
"serve": "^11.3.2",
"source-map-explorer": "^2.5.3",
@@ -136,6 +141,7 @@
"@graphql-codegen/typescript-operations": "^2.5.8",
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
"@graphql-codegen/typescript-resolvers": "^2.7.8",
"@juggle/resize-observer": "^3.4.0",
"@lingui/core": "^3.14.0",
"@lingui/macro": "^3.14.0",
"@lingui/react": "^3.14.0",
@@ -169,7 +175,6 @@
"@uniswap/v3-core": "1.0.0",
"@uniswap/v3-periphery": "^1.1.1",
"@uniswap/v3-sdk": "^3.9.0",
"@uniswap/widgets": "^2.49.0",
"@vanilla-extract/css": "^1.7.2",
"@vanilla-extract/css-utils": "^0.1.2",
"@vanilla-extract/dynamic": "^2.0.2",
@@ -254,18 +259,6 @@
"workbox-routing": "^6.1.0",
"zustand": "^4.3.6"
},
"resolutions": {
"@web3-react/coinbase-wallet": "^8.2.0",
"@web3-react/core": "^8.2.0",
"@web3-react/eip1193": "^8.2.0",
"@web3-react/empty": "^8.2.0",
"@web3-react/gnosis-safe": "^8.2.0",
"@web3-react/metamask": "^8.2.0",
"@web3-react/network": "^8.2.0",
"@web3-react/types": "^8.2.0",
"@web3-react/url": "^8.2.0",
"@web3-react/walletconnect": "^8.2.0"
},
"engines": {
"npm": "please-use-yarn",
"node": "14",

View File

@@ -10,14 +10,14 @@ export enum CardType {
Secondary = 'Secondary',
}
const StyledCard = styled.div<{ isDarkMode: boolean; backgroundImgSrc?: string; type: CardType }>`
const StyledCard = styled.div<{ $isDarkMode: boolean; $backgroundImgSrc?: string; $type: CardType }>`
display: flex;
background: ${({ isDarkMode, backgroundImgSrc, type, theme }) =>
isDarkMode
? `${type === CardType.Primary ? theme.backgroundModule : theme.backgroundSurface} ${
backgroundImgSrc ? ` url(${backgroundImgSrc})` : ''
background: ${({ $isDarkMode, $backgroundImgSrc, $type, theme }) =>
$isDarkMode
? `${$type === CardType.Primary ? theme.backgroundModule : theme.backgroundSurface} ${
$backgroundImgSrc ? ` url(${$backgroundImgSrc})` : ''
}`
: `${type === CardType.Primary ? 'white' : theme.backgroundModule} url(${backgroundImgSrc})`};
: `${$type === CardType.Primary ? 'white' : theme.backgroundModule} url(${$backgroundImgSrc})`};
background-size: auto 100%;
background-position: right;
background-repeat: no-repeat;
@@ -30,15 +30,15 @@ const StyledCard = styled.div<{ isDarkMode: boolean; backgroundImgSrc?: string;
padding: 24px;
height: 212px;
border-radius: 24px;
border: 1px solid ${({ theme, type }) => (type === CardType.Primary ? 'transparent' : theme.backgroundOutline)};
border: 1px solid ${({ theme, $type }) => ($type === CardType.Primary ? 'transparent' : theme.backgroundOutline)};
box-shadow: 0px 10px 24px 0px rgba(51, 53, 72, 0.04);
transition: ${({ theme }) => `${theme.transition.duration.medium} ${theme.transition.timing.ease} border`};
&:hover {
border: 1px solid ${({ theme, isDarkMode }) => (isDarkMode ? theme.backgroundInteractive : theme.textTertiary)};
border: 1px solid ${({ theme, $isDarkMode }) => ($isDarkMode ? theme.backgroundInteractive : theme.textTertiary)};
}
@media screen and (min-width: ${BREAKPOINTS.sm}px) {
height: ${({ backgroundImgSrc }) => (backgroundImgSrc ? 360 : 260)}px;
height: ${({ $backgroundImgSrc }) => ($backgroundImgSrc ? 360 : 260)}px;
}
@media screen and (min-width: ${BREAKPOINTS.xl}px) {
padding: 32px;
@@ -125,14 +125,14 @@ const Card = ({
return (
<TraceEvent events={[BrowserEvent.onClick]} name={SharedEventName.ELEMENT_CLICKED} element={elementName}>
<StyledCard
type={type}
as={external ? 'a' : Link}
to={external ? undefined : to}
href={external ? to : undefined}
target={external ? '_blank' : undefined}
rel={external ? 'noopenener noreferrer' : undefined}
isDarkMode={isDarkMode}
backgroundImgSrc={backgroundImgSrc}
$backgroundImgSrc={backgroundImgSrc}
$isDarkMode={isDarkMode}
$type={type}
>
<TitleRow>
<CardTitle>{title}</CardTitle>

View File

@@ -266,7 +266,7 @@ export default function AuthenticatedHeader({ account, openSettings }: { account
<PortfolioDrawerContainer>
{totalBalance !== undefined ? (
<FadeInColumn gap="xs">
<ThemedText.HeadlineLarge fontWeight={500}>
<ThemedText.HeadlineLarge fontWeight={500} data-testid="portfolio-total-balance">
{formatNumber(totalBalance, NumberType.PortfolioBalance)}
</ThemedText.HeadlineLarge>
<AutoRow marginBottom="20px">

View File

@@ -82,15 +82,38 @@ const ActivityGroupWrapper = styled(Column)`
gap: 8px;
`
function combineActivities(localMap: ActivityMap = {}, remoteMap: ActivityMap = {}): Array<Activity> {
/* Detects transactions from same account with the same nonce and different hash */
function wasTxCancelled(localActivity: Activity, remoteMap: ActivityMap, account: string): boolean {
// handles locally cached tx's that were stored before we started tracking nonces
if (!localActivity.nonce || localActivity.status !== TransactionStatus.Pending) return false
return Object.values(remoteMap).some((remoteTx) => {
if (!remoteTx) return false
// Cancellations are only possible when both nonce and tx.from are the same
if (remoteTx.nonce === localActivity.nonce && remoteTx.receipt?.from.toLowerCase() === account.toLowerCase()) {
// If the remote tx has a different hash than the local tx, the local tx was cancelled
return remoteTx.hash.toLowerCase() !== localActivity.hash.toLowerCase()
}
return false
})
}
function combineActivities(localMap: ActivityMap = {}, remoteMap: ActivityMap = {}, account: string): Array<Activity> {
const txHashes = [...new Set([...Object.keys(localMap), ...Object.keys(remoteMap)])]
// Merges local and remote activities w/ same hash, preferring remote data
return txHashes.reduce((acc: Array<Activity>, hash) => {
const localActivity = localMap?.[hash] ?? {}
const remoteActivity = remoteMap?.[hash] ?? {}
// TODO(cartcrom): determine best logic for which fields to prefer from which sources, i.e. prefer remote exact swap output instead of local estimated output
acc.push({ ...remoteActivity, ...localActivity } as Activity)
const localActivity = (localMap?.[hash] ?? {}) as Activity
const remoteActivity = (remoteMap?.[hash] ?? {}) as Activity
// TODO(WEB-2064): Display cancelled status in UI rather than completely hiding cancelled TXs
if (wasTxCancelled(localActivity, remoteMap, account)) return acc
// TODO(cartcrom): determine best logic for which fields to prefer from which sources
// i.e.prefer remote exact swap output instead of local estimated output
acc.push({ ...localActivity, ...remoteActivity } as Activity)
return acc
}, [])
}
@@ -122,9 +145,9 @@ export function ActivityTab({ account }: { account: string }) {
const activityGroups = useMemo(() => {
const remoteMap = parseRemoteActivities(data?.portfolios?.[0].assetActivities)
const allActivities = combineActivities(localMap, remoteMap)
const allActivities = combineActivities(localMap, remoteMap, account)
return createGroups(allActivities)
}, [data?.portfolios, localMap])
}, [data?.portfolios, localMap, account])
if (!data && loading)
return (

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

@@ -1,7 +1,7 @@
import { Token } from '@uniswap/sdk-core'
import { AddressMap } from '@uniswap/smart-order-router'
import { abi as MulticallABI } from '@uniswap/v3-periphery/artifacts/contracts/lens/UniswapInterfaceMulticall.sol/UniswapInterfaceMulticall.json'
import { abi as NFTPositionManagerABI } from '@uniswap/v3-periphery/artifacts/contracts/NonfungiblePositionManager.sol/NonfungiblePositionManager.json'
import MulticallJSON from '@uniswap/v3-periphery/artifacts/contracts/lens/UniswapInterfaceMulticall.sol/UniswapInterfaceMulticall.json'
import NFTPositionManagerJSON from '@uniswap/v3-periphery/artifacts/contracts/NonfungiblePositionManager.sol/NonfungiblePositionManager.json'
import { useWeb3React } from '@web3-react/core'
import { MULTICALL_ADDRESS, NONFUNGIBLE_POSITION_MANAGER_ADDRESSES as V3NFT_ADDRESSES } from 'constants/addresses'
import { isSupportedChain, SupportedChainId } from 'constants/chains'
@@ -43,11 +43,11 @@ function useContractMultichain<T extends BaseContract>(
}
export function useV3ManagerContracts(chainIds: SupportedChainId[]): ContractMap<NonfungiblePositionManager> {
return useContractMultichain<NonfungiblePositionManager>(V3NFT_ADDRESSES, NFTPositionManagerABI, chainIds)
return useContractMultichain<NonfungiblePositionManager>(V3NFT_ADDRESSES, NFTPositionManagerJSON.abi, chainIds)
}
export function useInterfaceMulticallContracts(chainIds: SupportedChainId[]): ContractMap<UniswapInterfaceMulticall> {
return useContractMultichain<UniswapInterfaceMulticall>(MULTICALL_ADDRESS, MulticallABI, chainIds)
return useContractMultichain<UniswapInterfaceMulticall>(MULTICALL_ADDRESS, MulticallJSON.abi, chainIds)
}
type PriceMap = { [key: CurrencyKey]: number | undefined }

View File

@@ -1,5 +1,5 @@
import { CurrencyAmount, Token } from '@uniswap/sdk-core'
import { abi as IUniswapV3PoolStateABI } from '@uniswap/v3-core/artifacts/contracts/interfaces/pool/IUniswapV3PoolState.sol/IUniswapV3PoolState.json'
import IUniswapV3PoolStateJSON from '@uniswap/v3-core/artifacts/contracts/interfaces/pool/IUniswapV3PoolState.sol/IUniswapV3PoolState.json'
import { computePoolAddress, Pool, Position } from '@uniswap/v3-sdk'
import { V3_CORE_FACTORY_ADDRESSES } from 'constants/addresses'
import { SupportedChainId } from 'constants/chains'
@@ -118,7 +118,7 @@ export default function useMultiChainPositions(account: string, chains = DEFAULT
// Combines PositionDetails with Pool data to build our return type
const fetchPositionInfo = useCallback(
async (positionDetails: PositionDetails[], chainId: SupportedChainId, multicall: UniswapInterfaceMulticall) => {
const poolInterface = new Interface(IUniswapV3PoolStateABI) as UniswapV3PoolInterface
const poolInterface = new Interface(IUniswapV3PoolStateJSON.abi) as UniswapV3PoolInterface
const tokens = await getTokens(
positionDetails.flatMap((details) => [details.token0, details.token1]),
chainId

View File

@@ -22,12 +22,14 @@ const EndColumn = styled(Column)`
`
export default function PortfolioRow({
['data-testid']: testId,
left,
title,
descriptor,
right,
onClick,
}: {
'data-testid'?: string
left: React.ReactNode
title: React.ReactNode
descriptor?: React.ReactNode
@@ -36,7 +38,7 @@ export default function PortfolioRow({
onClick?: () => void
}) {
return (
<PortfolioRowWrapper onClick={onClick}>
<PortfolioRowWrapper data-testid={testId} onClick={onClick}>
{left}
<AutoColumn grow>
{title}

View File

@@ -13,7 +13,7 @@ function wasPending(previousTxs: { [hash: string]: TransactionDetails | undefine
return previousTx && isTxPending(previousTx)
}
function useHasUpdatedTx() {
function useHasUpdatedTx(account: string | undefined) {
// TODO: consider monitoring tx's on chains other than the wallet's current chain
const currentChainTxs = useAllTransactions()
@@ -27,12 +27,12 @@ function useHasUpdatedTx() {
const previousPendingTxs = usePrevious(pendingTxs)
return useMemo(() => {
if (!previousPendingTxs) return false
if (!previousPendingTxs || !account) return false
return Object.values(currentChainTxs).some(
(tx) => !isTxPending(tx) && wasPending(previousPendingTxs, tx),
(tx) => tx.from === account && !isTxPending(tx) && wasPending(previousPendingTxs, tx),
[currentChainTxs, previousPendingTxs]
)
}, [currentChainTxs, previousPendingTxs])
}, [account, currentChainTxs, previousPendingTxs])
}
/* Prefetches & caches portfolio balances when the wrapped component is hovered or the user completes a transaction */
@@ -49,16 +49,22 @@ export default function PrefetchBalancesWrapper({ children }: PropsWithChildren)
}
}, [account, prefetchPortfolioBalances])
// TODO(cartcrom): add delay for refetching on optimism, as there is high latency in new balances being available
const hasUpdatedTx = useHasUpdatedTx()
// Listens for recently updated transactions to keep portfolio balances fresh in apollo cache
useEffect(() => {
if (!hasUpdatedTx) return
const prevAccount = usePrevious(account)
// If the drawer is open, fetch balances immediately, else set a flag to fetch on next hover
if (drawerOpen) fetchBalances()
else setHasUnfetchedBalances(true)
}, [drawerOpen, fetchBalances, hasUpdatedTx])
// TODO(cartcrom): add delay for refetching on optimism, as there is high latency in new balances being available
const hasUpdatedTx = useHasUpdatedTx(account)
// Listens for account changes & recently updated transactions to keep portfolio balances fresh in apollo cache
useEffect(() => {
const accountChanged = prevAccount !== undefined && prevAccount !== account
if (hasUpdatedTx || accountChanged) {
// If the drawer is open, fetch balances immediately, else set a flag to fetch on next hover
if (drawerOpen) {
fetchBalances()
} else {
setHasUnfetchedBalances(true)
}
}
}, [account, prevAccount, drawerOpen, fetchBalances, hasUpdatedTx])
const onHover = useCallback(() => {
if (hasUnfetchedBalances) fetchBalances()

View File

@@ -205,7 +205,7 @@ function AccountDrawer() {
name={InterfaceEventName.MINI_PORTFOLIO_TOGGLED}
properties={{ type: 'close' }}
>
<CloseDrawer onClick={toggleWalletDrawer}>
<CloseDrawer onClick={toggleWalletDrawer} data-testid="close-account-drawer">
<CloseIcon />
</CloseDrawer>
</TraceEvent>

View File

@@ -296,7 +296,7 @@ export function ButtonConfirmed({
}
}
export function ButtonError({ error, ...rest }: { error?: boolean } & ButtonProps) {
export function ButtonError({ error, ...rest }: { error?: boolean } & BaseButtonProps) {
if (error) {
return <ButtonErrorStyle {...rest} />
} else {

View File

@@ -1,6 +1,5 @@
import styled, { DefaultTheme } from 'styled-components/macro'
type Gap = keyof DefaultTheme['grids']
import styled from 'styled-components/macro'
import { Gap } from 'theme'
export const Column = styled.div<{
gap?: Gap

View File

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

View File

@@ -1,20 +1,31 @@
import { fireEvent, render, screen } from 'test-utils/render'
import noop from 'utils/noop'
import Expand from './index'
describe('Expand', () => {
it('renders correctly', () => {
const { asFragment } = render(
<Expand header={<span>Header</span>} button={<span>Button</span>}>
it('does not render children when closed', () => {
render(
<Expand header={<span>Header</span>} isOpen={false} onToggle={noop} button={<span>Button</span>}>
Body
</Expand>
)
expect(asFragment()).toMatchSnapshot()
expect(screen.queryByText('Body')).not.toBeInTheDocument()
})
it('toggles children on button press', () => {
it('renders children when open', () => {
render(
<Expand header={<span>Header</span>} button={<span>Button</span>}>
<Expand header={<span>Header</span>} isOpen={true} onToggle={noop} button={<span>Button</span>}>
Body
</Expand>
)
expect(screen.queryByText('Body')).toBeInTheDocument()
})
it('calls `onToggle` when button is pressed', () => {
const onToggle = jest.fn()
render(
<Expand header={<span>Header</span>} isOpen={false} onToggle={onToggle} button={<span>Button</span>}>
Body
</Expand>
)
@@ -22,9 +33,6 @@ describe('Expand', () => {
const button = screen.getByText('Button')
fireEvent.click(button)
expect(screen.queryByText('Body')).not.toBeNull()
fireEvent.click(button)
expect(screen.queryByText('Body')).toBeNull()
expect(onToggle).toHaveBeenCalled()
})
})

View File

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

View File

@@ -1,7 +1,7 @@
import { BaseVariant, FeatureFlag, featureFlagSettings, useUpdateFlag } from 'featureFlags'
import { DetailsV2Variant, useDetailsV2Flag } from 'featureFlags/flags/nftDetails'
import { useWidgetRemovalFlag, WidgetRemovalVariant } from 'featureFlags/flags/removeWidgetTdp'
import { TraceJsonRpcVariant, useTraceJsonRpcFlag } from 'featureFlags/flags/traceJsonRpc'
import { UnifiedRouterVariant, useUnifiedRoutingAPIFlag } from 'featureFlags/flags/unifiedRouter'
import { useUpdateAtom } from 'jotai/utils'
import { Children, PropsWithChildren, ReactElement, ReactNode, useCallback, useState } from 'react'
import { X } from 'react-feather'
@@ -209,10 +209,10 @@ export default function FeatureFlagModal() {
label="Use the new details page for nfts"
/>
<FeatureFlagOption
variant={WidgetRemovalVariant}
value={useWidgetRemovalFlag()}
featureFlag={FeatureFlag.removeWidget}
label="Swap Component on TDP"
variant={UnifiedRouterVariant}
value={useUnifiedRoutingAPIFlag()}
featureFlag={FeatureFlag.uraEnabled}
label="Enable the Unified Routing API"
/>
<FeatureFlagGroup name="Debug">
<FeatureFlagOption

View File

@@ -29,6 +29,7 @@ function TransactionPopupContent({ tx, chainId }: { tx: TransactionDetails; chai
return (
<PortfolioRow
data-testid="transaction-popup"
left={
success ? (
<Column>

View File

@@ -1,7 +1,6 @@
import { Box } from 'rebass/styled-components'
import styled, { DefaultTheme } from 'styled-components/macro'
type Gap = keyof DefaultTheme['grids']
import styled from 'styled-components/macro'
import { Gap } from 'theme'
// TODO(WEB-3289):
// Setting `width: 100%` by default prevents composability in complex flex layouts.
@@ -14,7 +13,7 @@ const Row = styled(Box)<{
padding?: string
border?: string
borderRadius?: string
gap?: string
gap?: Gap | string
}>`
width: ${({ width }) => width ?? '100%'};
display: flex;

View File

@@ -8,8 +8,12 @@ import MaxSlippageSettings from '.'
const AUTO_SLIPPAGE = new Percent(5, 10_000)
const renderAndExpandSlippageSettings = () => {
const renderSlippageSettings = () => {
render(<MaxSlippageSettings autoSlippage={AUTO_SLIPPAGE} />)
}
const renderAndExpandSlippageSettings = () => {
renderSlippageSettings()
// By default, the button to expand Slippage component and show `input` will have `Auto` label
fireEvent.click(screen.getByText('Auto'))
@@ -20,7 +24,7 @@ const switchToCustomSlippage = () => {
fireEvent.click(screen.getByText('Custom'))
}
const getSlippageInput = () => screen.getByTestId('slippage-input') as HTMLInputElement
const getSlippageInput = () => screen.queryByTestId('slippage-input') as HTMLInputElement
describe('MaxSlippageSettings', () => {
describe('input', () => {
@@ -28,6 +32,15 @@ describe('MaxSlippageSettings', () => {
beforeEach(() => {
store.dispatch(updateUserSlippageTolerance({ userSlippageTolerance: SlippageTolerance.Auto }))
})
it('is not expanded by default', () => {
renderSlippageSettings()
expect(getSlippageInput()).not.toBeInTheDocument()
})
it('is expanded by default when custom slippage is set', () => {
store.dispatch(updateUserSlippageTolerance({ userSlippageTolerance: 10 }))
renderSlippageSettings()
expect(getSlippageInput()).toBeInTheDocument()
})
it('does not render auto slippage as a value, but a placeholder', () => {
renderAndExpandSlippageSettings()
switchToCustomSlippage()

View File

@@ -53,6 +53,9 @@ export default function MaxSlippageSettings({ autoSlippage }: { autoSlippage: Pe
const [slippageInput, setSlippageInput] = useState(defaultSlippageInputValue)
const [slippageError, setSlippageError] = useState<SlippageError | false>(false)
// If user has previously entered a custom slippage, we want to show the settings expanded by default.
const [isOpen, setIsOpen] = useState(defaultSlippageInputValue.length > 0)
const parseSlippageInput = (value: string) => {
// Do not allow non-numerical characters in the input field or more than two decimals
if (value.length > 0 && !NUMBER_WITH_MAX_TWO_DECIMAL_PLACES.test(value)) {
@@ -93,6 +96,8 @@ export default function MaxSlippageSettings({ autoSlippage }: { autoSlippage: Pe
return (
<Expand
testId="max-slippage-settings"
isOpen={isOpen}
onToggle={() => setIsOpen(!isOpen)}
header={
<Row width="auto">
<ThemedText.BodySecondary>

View File

@@ -0,0 +1,36 @@
import { Percent } from '@uniswap/sdk-core'
import { useUserSlippageTolerance } from 'state/user/hooks'
import { SlippageTolerance } from 'state/user/types'
import { mocked } from 'test-utils/mocked'
import { render, screen } from 'test-utils/render'
import { lightTheme } from 'theme/colors'
import noop from 'utils/noop'
import MenuButton from '.'
jest.mock('state/user/hooks')
const renderButton = () => {
render(<MenuButton disabled={false} onClick={noop} isActive={false} />)
}
describe('MenuButton', () => {
it('should render an icon when slippage is Auto', () => {
mocked(useUserSlippageTolerance).mockReturnValue([SlippageTolerance.Auto, noop])
renderButton()
expect(screen.queryByText('slippage')).not.toBeInTheDocument()
})
it('should render an icon with a custom slippage value', () => {
mocked(useUserSlippageTolerance).mockReturnValue([new Percent(5, 10_000), noop])
renderButton()
expect(screen.queryByText('0.05% slippage')).toBeInTheDocument()
})
it('should render an icon with a custom slippage and a warning when value is out of bounds', () => {
mocked(useUserSlippageTolerance).mockReturnValue([new Percent(1, 10_000), noop])
renderButton()
expect(screen.getByTestId('settings-icon-with-slippage')).toHaveStyleRule(
'background-color',
lightTheme.accentWarningSoft
)
})
})

View File

@@ -0,0 +1,91 @@
import { t, Trans } from '@lingui/macro'
import Row from 'components/Row'
import { Settings } from 'react-feather'
import { useUserSlippageTolerance } from 'state/user/hooks'
import { SlippageTolerance } from 'state/user/types'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
import validateUserSlippageTolerance, { SlippageValidationResult } from 'utils/validateUserSlippageTolerance'
const Icon = styled(Settings)`
height: 20px;
width: 20px;
> * {
stroke: ${({ theme }) => theme.textSecondary};
}
`
const Button = styled.button<{ isActive: boolean }>`
border: none;
background-color: transparent;
margin: 0;
padding: 0;
cursor: pointer;
outline: none;
:not([disabled]):hover {
opacity: 0.7;
}
${({ isActive }) => isActive && `opacity: 0.7`}
`
const IconContainer = styled(Row)`
padding: 6px 12px;
border-radius: 16px;
`
const IconContainerWithSlippage = styled(IconContainer)<{ displayWarning?: boolean }>`
div {
color: ${({ theme, displayWarning }) => (displayWarning ? theme.accentWarning : theme.textSecondary)};
}
background-color: ${({ theme, displayWarning }) =>
displayWarning ? theme.accentWarningSoft : theme.backgroundModule};
`
const ButtonContent = () => {
const [userSlippageTolerance] = useUserSlippageTolerance()
if (userSlippageTolerance === SlippageTolerance.Auto) {
return (
<IconContainer>
<Icon />
</IconContainer>
)
}
const isInvalidSlippage = validateUserSlippageTolerance(userSlippageTolerance) !== SlippageValidationResult.Valid
return (
<IconContainerWithSlippage data-testid="settings-icon-with-slippage" gap="sm" displayWarning={isInvalidSlippage}>
<ThemedText.Caption>
<Trans>{userSlippageTolerance.toFixed(2)}% slippage</Trans>
</ThemedText.Caption>
<Icon />
</IconContainerWithSlippage>
)
}
export default function MenuButton({
disabled,
onClick,
isActive,
}: {
disabled: boolean
onClick: () => void
isActive: boolean
}) {
return (
<Button
disabled={disabled}
onClick={onClick}
isActive={isActive}
id="open-settings-dialog-button"
data-testid="open-settings-dialog-button"
aria-label={t`Transaction Settings`}
>
<ButtonContent />
</Button>
)
}

View File

@@ -5,14 +5,18 @@ import { fireEvent, render, screen } from 'test-utils/render'
import TransactionDeadlineSettings from '.'
const renderAndExpandTransactionDeadlineSettings = () => {
const renderTransactionDeadlineSettings = () => {
render(<TransactionDeadlineSettings />)
}
const renderAndExpandTransactionDeadlineSettings = () => {
renderTransactionDeadlineSettings()
// By default, the button to expand Slippage component and show `input` will have `<deadline>m` label
fireEvent.click(screen.getByText(`${DEFAULT_DEADLINE_FROM_NOW / 60}m`))
}
const getDeadlineInput = () => screen.getByTestId('deadline-input') as HTMLInputElement
const getDeadlineInput = () => screen.queryByTestId('deadline-input') as HTMLInputElement
describe('TransactionDeadlineSettings', () => {
describe('input', () => {
@@ -20,6 +24,15 @@ describe('TransactionDeadlineSettings', () => {
beforeEach(() => {
store.dispatch(updateUserDeadline({ userDeadline: DEFAULT_DEADLINE_FROM_NOW }))
})
it('is not expanded by default', () => {
renderTransactionDeadlineSettings()
expect(getDeadlineInput()).not.toBeInTheDocument()
})
it('is expanded by default when custom deadline is set', () => {
store.dispatch(updateUserDeadline({ userDeadline: DEFAULT_DEADLINE_FROM_NOW * 2 }))
renderTransactionDeadlineSettings()
expect(getDeadlineInput()).toBeInTheDocument()
})
it('does not render default deadline as a value, but a placeholder', () => {
renderAndExpandTransactionDeadlineSettings()
expect(getDeadlineInput().value).toBe('')

View File

@@ -26,6 +26,9 @@ export default function TransactionDeadlineSettings() {
const [deadlineInput, setDeadlineInput] = useState(defaultInputValue)
const [deadlineError, setDeadlineError] = useState<DeadlineError | false>(false)
// If user has previously entered a custom deadline, we want to show the settings expanded by default.
const [isOpen, setIsOpen] = useState(defaultInputValue.length > 0)
function parseCustomDeadline(value: string) {
// Do not allow non-numerical characters in the input field
if (value.length > 0 && !NUMBERS_ONLY.test(value)) {
@@ -56,6 +59,8 @@ export default function TransactionDeadlineSettings() {
return (
<Expand
isOpen={isOpen}
onToggle={() => setIsOpen(!isOpen)}
testId="transaction-deadline-settings"
header={
<Row width="auto">

View File

@@ -1,5 +1,4 @@
// eslint-disable-next-line no-restricted-imports
import { t } from '@lingui/macro'
import { Percent } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { AutoColumn } from 'components/Column'
@@ -7,82 +6,39 @@ import { L2_CHAIN_IDS } from 'constants/chains'
import { useOnClickOutside } from 'hooks/useOnClickOutside'
import { isSupportedChainId } from 'lib/hooks/routing/clientSideSmartOrderRouter'
import { useRef } from 'react'
import { Settings } from 'react-feather'
import { useModalIsOpen, useToggleSettingsMenu } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer'
import styled from 'styled-components/macro'
import { Divider } from 'theme'
import MaxSlippageSettings from './MaxSlippageSettings'
import MenuButton from './MenuButton'
import RouterPreferenceSettings from './RouterPreferenceSettings'
import TransactionDeadlineSettings from './TransactionDeadlineSettings'
const StyledMenuIcon = styled(Settings)`
height: 20px;
width: 20px;
> * {
stroke: ${({ theme }) => theme.textSecondary};
}
`
const StyledMenuButton = styled.button<{ disabled: boolean }>`
const Menu = styled.div`
position: relative;
width: 100%;
height: 100%;
border: none;
background-color: transparent;
margin: 0;
padding: 0;
border-radius: 0.5rem;
height: 20px;
${({ disabled }) =>
!disabled &&
`
:hover,
:focus {
cursor: pointer;
outline: none;
opacity: 0.7;
}
`}
`
const StyledMenu = styled.div`
margin-left: 0.5rem;
display: flex;
justify-content: center;
align-items: center;
position: relative;
border: none;
text-align: left;
`
const MenuFlyout = styled.span`
const MenuFlyout = styled(AutoColumn)`
min-width: 20.125rem;
background-color: ${({ theme }) => theme.backgroundSurface};
border: 1px solid ${({ theme }) => theme.backgroundOutline};
box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.01), 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 16px 24px rgba(0, 0, 0, 0.04),
0px 24px 32px rgba(0, 0, 0, 0.01);
border-radius: 12px;
display: flex;
flex-direction: column;
font-size: 1rem;
position: absolute;
top: 2rem;
right: 0rem;
top: 100%;
margin-top: 10px;
right: 0;
z-index: 100;
color: ${({ theme }) => theme.textPrimary};
${({ theme }) => theme.deprecated_mediaWidth.deprecated_upToMedium`
min-width: 18.125rem;
`};
user-select: none;
`
const Divider = styled.div`
width: 100%;
height: 1px;
border-width: 0;
margin: 0;
background-color: ${({ theme }) => theme.backgroundOutline};
gap: 16px;
padding: 1rem;
`
export default function SettingsTab({ autoSlippage }: { autoSlippage: Percent }) {
@@ -90,37 +46,29 @@ export default function SettingsTab({ autoSlippage }: { autoSlippage: Percent })
const showDeadlineSettings = Boolean(chainId && !L2_CHAIN_IDS.includes(chainId))
const node = useRef<HTMLDivElement | null>(null)
const open = useModalIsOpen(ApplicationModal.SETTINGS)
const isOpen = useModalIsOpen(ApplicationModal.SETTINGS)
const toggle = useToggleSettingsMenu()
useOnClickOutside(node, open ? toggle : undefined)
const toggleMenu = useToggleSettingsMenu()
useOnClickOutside(node, isOpen ? toggleMenu : undefined)
const isSupportedChain = isSupportedChainId(chainId)
return (
<StyledMenu ref={node}>
<StyledMenuButton
disabled={!isSupportedChainId(chainId)}
onClick={toggle}
id="open-settings-dialog-button"
data-testid="open-settings-dialog-button"
aria-label={t`Transaction Settings`}
>
<StyledMenuIcon data-testid="swap-settings-button" />
</StyledMenuButton>
{open && (
<Menu ref={node}>
<MenuButton disabled={!isSupportedChain} isActive={isOpen} onClick={toggleMenu} />
{isOpen && (
<MenuFlyout>
<AutoColumn gap="16px" style={{ padding: '1rem' }}>
{isSupportedChainId(chainId) && <RouterPreferenceSettings />}
<Divider />
<MaxSlippageSettings autoSlippage={autoSlippage} />
{showDeadlineSettings && (
<>
<Divider />
<TransactionDeadlineSettings />
</>
)}
</AutoColumn>
<RouterPreferenceSettings />
<Divider />
<MaxSlippageSettings autoSlippage={autoSlippage} />
{showDeadlineSettings && (
<>
<Divider />
<TransactionDeadlineSettings />
</>
)}
</MenuFlyout>
)}
</StyledMenu>
</Menu>
)
}

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

@@ -8,10 +8,11 @@ import useAccountRiskCheck from 'hooks/useAccountRiskCheck'
import { lazy } from 'react'
import { useModalIsOpen, useToggleModal } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer'
import { retry } from 'utils/retry'
const Bag = lazy(() => import('nft/components/bag/Bag'))
const TransactionCompleteModal = lazy(() => import('nft/components/collection/TransactionCompleteModal'))
const AirdropModal = lazy(() => import('components/AirdropModal'))
const Bag = lazy(() => retry(() => import('nft/components/bag/Bag')))
const TransactionCompleteModal = lazy(() => retry(() => import('nft/components/collection/TransactionCompleteModal')))
const AirdropModal = lazy(() => retry(() => import('components/AirdropModal')))
export default function TopLevelModals() {
const addressClaimOpen = useModalIsOpen(ApplicationModal.ADDRESS_CLAIM)

View File

@@ -20,7 +20,7 @@ import { TransactionSummary } from '../AccountDetails/TransactionSummary'
import { ButtonLight, ButtonPrimary } from '../Button'
import { AutoColumn, ColumnCenter } from '../Column'
import Modal from '../Modal'
import { RowBetween, RowFixed } from '../Row'
import Row, { RowBetween, RowFixed } from '../Row'
import AnimatedConfirmation from './AnimatedConfirmation'
const Wrapper = styled.div`
@@ -28,16 +28,12 @@ const Wrapper = styled.div`
border-radius: 20px;
outline: 1px solid ${({ theme }) => theme.backgroundOutline};
width: 100%;
padding: 1rem;
`
const Section = styled(AutoColumn)<{ inline?: boolean }>`
padding: ${({ inline }) => (inline ? '0' : '0')};
padding: 16px;
`
const BottomSection = styled(Section)`
const BottomSection = styled(AutoColumn)`
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
padding-bottom: 10px;
`
const ConfirmedIcon = styled(ColumnCenter)<{ inline?: boolean }>`
@@ -50,6 +46,10 @@ const StyledLogo = styled.img`
margin-left: 6px;
`
const ConfirmationModalContentWrapper = styled(AutoColumn)`
padding-bottom: 12px;
`
function ConfirmationPendingContent({
onDismiss,
pendingText,
@@ -59,8 +59,6 @@ function ConfirmationPendingContent({
pendingText: ReactNode
inline?: boolean // not in modal
}) {
const theme = useTheme()
return (
<Wrapper>
<AutoColumn gap="md">
@@ -74,15 +72,15 @@ function ConfirmationPendingContent({
<CustomLightSpinner src={Circle} alt="loader" size={inline ? '40px' : '90px'} />
</ConfirmedIcon>
<AutoColumn gap="md" justify="center">
<Text fontWeight={500} fontSize={20} color={theme.textPrimary} textAlign="center">
<ThemedText.SubHeaderLarge color="textPrimary" textAlign="center">
<Trans>Waiting for confirmation</Trans>
</Text>
<Text fontWeight={600} fontSize={16} color={theme.textPrimary} textAlign="center">
</ThemedText.SubHeaderLarge>
<ThemedText.SubHeader color="textPrimary" textAlign="center">
{pendingText}
</Text>
<Text fontWeight={400} fontSize={12} color={theme.textSecondary} textAlign="center" marginBottom="12px">
</ThemedText.SubHeader>
<ThemedText.SubHeaderSmall color="textSecondary" textAlign="center" marginBottom="12px">
<Trans>Confirm this transaction in your wallet</Trans>
</Text>
</ThemedText.SubHeaderSmall>
</AutoColumn>
</AutoColumn>
</Wrapper>
@@ -125,7 +123,7 @@ function TransactionSubmittedContent({
return (
<Wrapper>
<Section inline={inline}>
<AutoColumn>
{!inline && (
<RowBetween>
<div />
@@ -135,7 +133,7 @@ function TransactionSubmittedContent({
<ConfirmedIcon inline={inline}>
<ArrowUpCircle strokeWidth={1} size={inline ? '40px' : '75px'} color={theme.accentActive} />
</ConfirmedIcon>
<AutoColumn gap="md" justify="center" style={{ paddingBottom: '12px' }}>
<ConfirmationModalContentWrapper gap="md" justify="center">
<ThemedText.MediumHeader textAlign="center">
<Trans>Transaction submitted</Trans>
</ThemedText.MediumHeader>
@@ -154,19 +152,19 @@ function TransactionSubmittedContent({
</ButtonLight>
)}
<ButtonPrimary onClick={onDismiss} style={{ margin: '20px 0 0 0' }} data-testid="dismiss-tx-confirmation">
<Text fontWeight={600} fontSize={20} color={theme.accentTextLightPrimary}>
<ThemedText.HeadlineSmall color={theme.accentTextLightPrimary}>
{inline ? <Trans>Return</Trans> : <Trans>Close</Trans>}
</Text>
</ThemedText.HeadlineSmall>
</ButtonPrimary>
{chainId && hash && (
<ExternalLink href={getExplorerLink(chainId, hash, ExplorerDataType.TRANSACTION)}>
<Text fontWeight={600} fontSize={14} color={theme.accentAction}>
<ThemedText.Link color={theme.accentAction}>
<Trans>View on {chainId === SupportedChainId.MAINNET ? 'Etherscan' : 'Block Explorer'}</Trans>
</Text>
</ThemedText.Link>
</ExternalLink>
)}
</AutoColumn>
</Section>
</ConfirmationModalContentWrapper>
</AutoColumn>
</Wrapper>
)
}
@@ -184,15 +182,15 @@ export function ConfirmationModalContent({
}) {
return (
<Wrapper>
<Section>
<RowBetween>
<Text fontWeight={500} fontSize={16}>
{title}
</Text>
<AutoColumn gap="sm">
<Row>
<Row justify="center" marginLeft="24px">
<ThemedText.SubHeader>{title}</ThemedText.SubHeader>
</Row>
<CloseIcon onClick={onDismiss} data-cy="confirmation-close-icon" />
</RowBetween>
</Row>
{topContent()}
</Section>
</AutoColumn>
{bottomContent && <BottomSection gap="12px">{bottomContent()}</BottomSection>}
</Wrapper>
)
@@ -202,7 +200,7 @@ export function TransactionErrorContent({ message, onDismiss }: { message: React
const theme = useTheme()
return (
<Wrapper>
<Section>
<AutoColumn>
<RowBetween>
<Text fontWeight={600} fontSize={16}>
<Trans>Error</Trans>
@@ -213,7 +211,7 @@ export function TransactionErrorContent({ message, onDismiss }: { message: React
<AlertTriangle color={theme.accentCritical} style={{ strokeWidth: 1 }} size={90} />
<ThemedText.MediumHeader textAlign="center">{message}</ThemedText.MediumHeader>
</AutoColumn>
</Section>
</AutoColumn>
<BottomSection gap="12px">
<ButtonPrimary onClick={onDismiss}>
<Trans>Dismiss</Trans>
@@ -252,7 +250,7 @@ function L2Content({
return (
<Wrapper>
<Section inline={inline}>
<AutoColumn>
{!inline && (
<RowBetween mb="16px">
<Badge>
@@ -277,7 +275,7 @@ function L2Content({
)}
</ConfirmedIcon>
<AutoColumn gap="md" justify="center">
<Text fontWeight={500} fontSize={20} textAlign="center">
<ThemedText.SubHeaderLarge textAlign="center">
{!hash ? (
<Trans>Confirm transaction in wallet</Trans>
) : !confirmed ? (
@@ -287,20 +285,20 @@ function L2Content({
) : (
<Trans>Error</Trans>
)}
</Text>
<Text fontWeight={400} fontSize={16} textAlign="center">
</ThemedText.SubHeaderLarge>
<ThemedText.BodySecondary textAlign="center">
{transaction ? <TransactionSummary info={transaction.info} /> : pendingText}
</Text>
</ThemedText.BodySecondary>
{chainId && hash ? (
<ExternalLink href={getExplorerLink(chainId, hash, ExplorerDataType.TRANSACTION)}>
<Text fontWeight={500} fontSize={14} color={theme.accentAction}>
<ThemedText.SubHeaderSmall color={theme.accentAction}>
<Trans>View on Explorer</Trans>
</Text>
</ThemedText.SubHeaderSmall>
</ExternalLink>
) : (
<div style={{ height: '17px' }} />
)}
<Text color={theme.textTertiary} style={{ margin: '20px 0 0 0' }} fontSize="14px">
<ThemedText.SubHeaderSmall color={theme.textTertiary} marginTop="20px">
{!secondsToConfirm ? (
<div style={{ height: '24px' }} />
) : (
@@ -311,14 +309,14 @@ function L2Content({
</span>
</div>
)}
</Text>
</ThemedText.SubHeaderSmall>
<ButtonPrimary onClick={onDismiss} style={{ margin: '4px 0 0 0' }}>
<Text fontWeight={500} fontSize={20}>
<ThemedText.SubHeaderLarge>
{inline ? <Trans>Return</Trans> : <Trans>Close</Trans>}
</Text>
</ThemedText.SubHeaderLarge>
</ButtonPrimary>
</AutoColumn>
</Section>
</AutoColumn>
</Wrapper>
)
}

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,64 +0,0 @@
import { Percent } from '@uniswap/sdk-core'
import { RouterPreference, Slippage, SwapController, SwapEventHandlers } from '@uniswap/widgets'
import { DEFAULT_DEADLINE_FROM_NOW } from 'constants/misc'
import { useCallback, useMemo, useState } from 'react'
import { useUserSlippageTolerance, useUserTransactionTTL } from 'state/user/hooks'
import { SlippageTolerance } from 'state/user/types'
/**
* Integrates the Widget's settings, keeping the widget and app settings in sync.
* NB: This acts as an integration layer, so certain values are duplicated in order to translate
* between app and widget representations.
*/
export function useSyncWidgetSettings() {
const [appTtl, setAppTtl] = useUserTransactionTTL()
const [widgetTtl, setWidgetTtl] = useState<number | undefined>(appTtl / 60)
const onTransactionDeadlineChange = useCallback(
(widgetTtl: number | undefined) => {
setWidgetTtl(widgetTtl)
const appTtl = widgetTtl === undefined ? widgetTtl : widgetTtl * 60
setAppTtl(appTtl ?? DEFAULT_DEADLINE_FROM_NOW)
},
[setAppTtl]
)
const [appSlippage, setAppSlippage] = useUserSlippageTolerance()
const [widgetSlippage, setWidgetSlippage] = useState<string | undefined>(
appSlippage === SlippageTolerance.Auto ? undefined : appSlippage.toFixed(2)
)
const onSlippageChange = useCallback(
(widgetSlippage: Slippage) => {
setWidgetSlippage(widgetSlippage.max)
if (widgetSlippage.auto || !widgetSlippage.max) {
setAppSlippage(SlippageTolerance.Auto)
} else {
setAppSlippage(new Percent(Math.floor(Number(widgetSlippage.max) * 100), 10_000))
}
},
[setAppSlippage]
)
const [routerPreference, onRouterPreferenceChange] = useState(RouterPreference.API)
const onSettingsReset = useCallback(() => {
setWidgetTtl(undefined)
setAppTtl(DEFAULT_DEADLINE_FROM_NOW)
setWidgetSlippage(undefined)
setAppSlippage(SlippageTolerance.Auto)
}, [setAppSlippage, setAppTtl])
const settings: SwapController['settings'] = useMemo(() => {
const auto = appSlippage === SlippageTolerance.Auto
return {
slippage: { auto, max: widgetSlippage },
transactionTtl: widgetTtl,
routerPreference,
}
}, [appSlippage, widgetSlippage, widgetTtl, routerPreference])
const settingsHandlers: SwapEventHandlers = useMemo(
() => ({ onSettingsReset, onSlippageChange, onTransactionDeadlineChange, onRouterPreferenceChange }),
[onSettingsReset, onSlippageChange, onTransactionDeadlineChange, onRouterPreferenceChange]
)
return { settings: { settings, ...settingsHandlers } }
}

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

@@ -1,11 +1,5 @@
import userEvent from '@testing-library/user-event'
import {
TEST_ALLOWED_SLIPPAGE,
TEST_TOKEN_1,
TEST_TRADE_EXACT_INPUT,
TEST_TRADE_EXACT_OUTPUT,
toCurrencyAmount,
} from 'test-utils/constants'
import { TEST_ALLOWED_SLIPPAGE, TEST_TRADE_EXACT_INPUT, TEST_TRADE_EXACT_OUTPUT } from 'test-utils/constants'
import { act, render, screen } from 'test-utils/render'
import { AdvancedSwapDetails } from './AdvancedSwapDetails'
@@ -27,9 +21,9 @@ describe('AdvancedSwapDetails.tsx', () => {
})
it('renders correct tooltips for test trade with exact output and gas use estimate USD', async () => {
TEST_TRADE_EXACT_OUTPUT.gasUseEstimateUSD = toCurrencyAmount(TEST_TOKEN_1, 1)
TEST_TRADE_EXACT_OUTPUT.gasUseEstimateUSD = '1.00'
render(<AdvancedSwapDetails trade={TEST_TRADE_EXACT_OUTPUT} allowedSlippage={TEST_ALLOWED_SLIPPAGE} />)
await act(() => userEvent.hover(screen.getByText(/Minimum output/i)))
await act(() => userEvent.hover(screen.getByText(/Maximum input/i)))
expect(await screen.getByText(/The minimum amount you are guaranteed to receive./i)).toBeVisible()
await act(() => userEvent.hover(screen.getByText('Network fee')))
expect(await screen.getByText(/The fee paid to miners who process your transaction./i)).toBeVisible()

View File

@@ -1,12 +1,13 @@
import { Trans } from '@lingui/macro'
import { sendAnalyticsEvent } from '@uniswap/analytics'
import { InterfaceElementName, SwapEventName } from '@uniswap/analytics-events'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { Percent, TradeType } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { LoadingRows } from 'components/Loader/styled'
import { SUPPORTED_GAS_ESTIMATE_CHAIN_IDS } from 'constants/chains'
import useNativeCurrency from 'lib/hooks/useNativeCurrency'
import { InterfaceTrade } from 'state/routing/types'
import formatPriceImpact from 'utils/formatPriceImpact'
import { Separator, ThemedText } from '../../theme'
import Column from '../Column'
@@ -16,7 +17,7 @@ import RouterLabel from './RouterLabel'
import SwapRoute from './SwapRoute'
interface AdvancedSwapDetailsProps {
trade: InterfaceTrade<Currency, Currency, TradeType>
trade: InterfaceTrade
allowedSlippage: Percent
syncing?: boolean
}
@@ -60,10 +61,20 @@ export function AdvancedSwapDetails({ trade, allowedSlippage, syncing = false }:
</ThemedText.BodySmall>
</MouseoverTooltip>
<TextWithLoadingPlaceholder syncing={syncing} width={50}>
<ThemedText.BodySmall>~${trade.gasUseEstimateUSD.toFixed(2)}</ThemedText.BodySmall>
<ThemedText.BodySmall>~${trade.gasUseEstimateUSD}</ThemedText.BodySmall>
</TextWithLoadingPlaceholder>
</RowBetween>
)}
<RowBetween>
<MouseoverTooltip text={<Trans>The impact your trade has on the market price of this pool.</Trans>}>
<ThemedText.BodySmall color="textSecondary">
<Trans>Price Impact</Trans>
</ThemedText.BodySmall>
</MouseoverTooltip>
<TextWithLoadingPlaceholder syncing={syncing} width={50}>
<ThemedText.BodySmall>{formatPriceImpact(trade.priceImpact)}</ThemedText.BodySmall>
</TextWithLoadingPlaceholder>
</RowBetween>
<RowBetween>
<RowFixed>
<MouseoverTooltip
@@ -75,7 +86,7 @@ export function AdvancedSwapDetails({ trade, allowedSlippage, syncing = false }:
}
>
<ThemedText.BodySmall color="textSecondary">
<Trans>Minimum output</Trans>
{trade.tradeType === TradeType.EXACT_INPUT ? <Trans>Minimum output</Trans> : <Trans>Maximum input</Trans>}
</ThemedText.BodySmall>
</MouseoverTooltip>
</RowFixed>

View File

@@ -1,10 +1,11 @@
import { Trans } from '@lingui/macro'
import { Trace } from '@uniswap/analytics'
import { InterfaceModalName } from '@uniswap/analytics-events'
import { Trade } from '@uniswap/router-sdk'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { ReactNode, useCallback, useMemo, useState } from 'react'
import { sendAnalyticsEvent, Trace } from '@uniswap/analytics'
import { InterfaceModalName, SwapEventName, SwapPriceUpdateUserResponse } from '@uniswap/analytics-events'
import { Percent } from '@uniswap/sdk-core'
import { getPriceUpdateBasisPoints } from 'lib/utils/analytics'
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import { InterfaceTrade } from 'state/routing/types'
import { formatSwapPriceUpdatedEventProperties } from 'utils/loggingFormatters'
import { tradeMeaningfullyDiffers } from 'utils/tradeMeaningFullyDiffer'
import TransactionConfirmationModal, {
@@ -21,21 +22,17 @@ export default function ConfirmSwapModal({
allowedSlippage,
onConfirm,
onDismiss,
recipient,
swapErrorMessage,
isOpen,
attemptingTxn,
txHash,
swapQuoteReceivedDate,
fiatValueInput,
fiatValueOutput,
}: {
isOpen: boolean
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined
originalTrade: Trade<Currency, Currency, TradeType> | undefined
trade: InterfaceTrade
originalTrade: InterfaceTrade | undefined
attemptingTxn: boolean
txHash: string | undefined
recipient: string | null
allowedSlippage: Percent
onAcceptChanges: () => void
onConfirm: () => void
@@ -45,35 +42,34 @@ export default function ConfirmSwapModal({
fiatValueInput: { data?: number; isLoading: boolean }
fiatValueOutput: { data?: number; isLoading: boolean }
}) {
// shouldLogModalCloseEvent lets the child SwapModalHeader component know when modal has been closed
// and an event triggered by modal closing should be logged.
const [shouldLogModalCloseEvent, setShouldLogModalCloseEvent] = useState(false)
const showAcceptChanges = useMemo(
() => Boolean(trade && originalTrade && tradeMeaningfullyDiffers(trade, originalTrade)),
() => Boolean(originalTrade && tradeMeaningfullyDiffers(trade, originalTrade)),
[originalTrade, trade]
)
const [lastExecutionPrice, setLastExecutionPrice] = useState(trade?.executionPrice)
const [priceUpdate, setPriceUpdate] = useState<number>()
useEffect(() => {
if (lastExecutionPrice && !trade.executionPrice.equalTo(lastExecutionPrice)) {
setPriceUpdate(getPriceUpdateBasisPoints(lastExecutionPrice, trade.executionPrice))
setLastExecutionPrice(trade.executionPrice)
}
}, [lastExecutionPrice, setLastExecutionPrice, trade])
const onModalDismiss = useCallback(() => {
if (isOpen) setShouldLogModalCloseEvent(true)
sendAnalyticsEvent(
SwapEventName.SWAP_PRICE_UPDATE_ACKNOWLEDGED,
formatSwapPriceUpdatedEventProperties(trade, priceUpdate, SwapPriceUpdateUserResponse.REJECTED)
)
onDismiss()
}, [isOpen, onDismiss])
}, [onDismiss, priceUpdate, trade])
const modalHeader = useCallback(() => {
return trade ? (
<SwapModalHeader
trade={trade}
shouldLogModalCloseEvent={shouldLogModalCloseEvent}
setShouldLogModalCloseEvent={setShouldLogModalCloseEvent}
allowedSlippage={allowedSlippage}
recipient={recipient}
showAcceptChanges={showAcceptChanges}
onAcceptChanges={onAcceptChanges}
/>
) : null
}, [allowedSlippage, onAcceptChanges, recipient, showAcceptChanges, trade, shouldLogModalCloseEvent])
return <SwapModalHeader trade={trade} allowedSlippage={allowedSlippage} />
}, [allowedSlippage, trade])
const modalBottom = useCallback(() => {
return trade ? (
return (
<SwapModalFooter
onConfirm={onConfirm}
trade={trade}
@@ -84,25 +80,28 @@ export default function ConfirmSwapModal({
swapQuoteReceivedDate={swapQuoteReceivedDate}
fiatValueInput={fiatValueInput}
fiatValueOutput={fiatValueOutput}
showAcceptChanges={showAcceptChanges}
onAcceptChanges={onAcceptChanges}
/>
) : null
)
}, [
trade,
onConfirm,
txHash,
allowedSlippage,
showAcceptChanges,
swapErrorMessage,
trade,
allowedSlippage,
txHash,
swapQuoteReceivedDate,
fiatValueInput,
fiatValueOutput,
onAcceptChanges,
])
// text to show while loading
const pendingText = (
<Trans>
Swapping {trade?.inputAmount?.toSignificant(6)} {trade?.inputAmount?.currency?.symbol} for{' '}
{trade?.outputAmount?.toSignificant(6)} {trade?.outputAmount?.currency?.symbol}
Swapping {trade.inputAmount.toSignificant(6)} {trade.inputAmount.currency?.symbol} for{' '}
{trade.outputAmount.toSignificant(6)} {trade.outputAmount.currency?.symbol}
</Trans>
)
@@ -112,7 +111,7 @@ export default function ConfirmSwapModal({
<TransactionErrorContent onDismiss={onModalDismiss} message={swapErrorMessage} />
) : (
<ConfirmationModalContent
title={<Trans>Confirm Swap</Trans>}
title={<Trans>Review Swap</Trans>}
onDismiss={onModalDismiss}
topContent={modalHeader}
bottomContent={modalBottom}
@@ -124,13 +123,13 @@ export default function ConfirmSwapModal({
return (
<Trace modal={InterfaceModalName.CONFIRM_SWAP}>
<TransactionConfirmationModal
isOpen={isOpen}
isOpen
onDismiss={onModalDismiss}
attemptingTxn={attemptingTxn}
hash={txHash}
content={confirmationContent}
pendingText={pendingText}
currencyToAdd={trade?.outputAmount.currency}
currencyToAdd={trade.outputAmount.currency}
/>
</Trace>
)

View File

@@ -1,6 +1,5 @@
import { sendAnalyticsEvent } from '@uniswap/analytics'
import { InterfaceElementName, SwapEventName } from '@uniswap/analytics-events'
import { Currency, TradeType } from '@uniswap/sdk-core'
import { LoadingOpacityContainer } from 'components/Loader/styled'
import { RowFixed } from 'components/Row'
import { MouseoverTooltip, TooltipSize } from 'components/Tooltip'
@@ -26,14 +25,14 @@ export default function GasEstimateTooltip({
loading,
disabled,
}: {
trade: InterfaceTrade<Currency, Currency, TradeType> // dollar amount in active chain's stablecoin
trade: InterfaceTrade // dollar amount in active chain's stablecoin
loading: boolean
disabled?: boolean
}) {
const formattedGasPriceString = trade?.gasUseEstimateUSD
? trade.gasUseEstimateUSD.toFixed(2) === '0.00'
? trade.gasUseEstimateUSD === '0.00'
? '<$0.01'
: '$' + trade.gasUseEstimateUSD.toFixed(2)
: '$' + trade.gasUseEstimateUSD
: undefined
return (

View File

@@ -3,6 +3,7 @@ import { Percent } from '@uniswap/sdk-core'
import { OutlineCard } from 'components/Card'
import styled, { useTheme } from 'styled-components/macro'
import { opacify } from 'theme/utils'
import formatPriceImpact from 'utils/formatPriceImpact'
import { ThemedText } from '../../theme'
import { AutoColumn } from '../Column'
@@ -18,8 +19,6 @@ interface PriceImpactWarningProps {
priceImpact: Percent
}
const formatPriceImpact = (priceImpact: Percent) => `${priceImpact.multiply(-1).toFixed(2)}%`
export default function PriceImpactWarning({ priceImpact }: PriceImpactWarningProps) {
const theme = useTheme()

View File

@@ -1,5 +1,5 @@
import userEvent from '@testing-library/user-event'
import { TEST_ALLOWED_SLIPPAGE, TEST_TOKEN_1, TEST_TRADE_EXACT_INPUT, toCurrencyAmount } from 'test-utils/constants'
import { TEST_ALLOWED_SLIPPAGE, TEST_TRADE_EXACT_INPUT } from 'test-utils/constants'
import { act, render, screen } from 'test-utils/render'
import SwapDetailsDropdown from './SwapDetailsDropdown'
@@ -25,7 +25,7 @@ describe('SwapDetailsDropdown.tsx', () => {
})
it('is interactive once loaded', async () => {
TEST_TRADE_EXACT_INPUT.gasUseEstimateUSD = toCurrencyAmount(TEST_TOKEN_1, 1)
TEST_TRADE_EXACT_INPUT.gasUseEstimateUSD = '1.00'
render(
<SwapDetailsDropdown
trade={TEST_TRADE_EXACT_INPUT}

View File

@@ -1,7 +1,7 @@
import { Trans } from '@lingui/macro'
import { TraceEvent } from '@uniswap/analytics'
import { BrowserEvent, InterfaceElementName, SwapEventName } from '@uniswap/analytics-events'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { Percent } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import AnimatedDropdown from 'components/AnimatedDropdown'
import Column from 'components/Column'
@@ -92,7 +92,7 @@ const Wrapper = styled(Column)`
`
interface SwapDetailsInlineProps {
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined
trade: InterfaceTrade | undefined
syncing: boolean
loading: boolean
allowedSlippage: Percent

View File

@@ -1,28 +1,21 @@
import { Trans } from '@lingui/macro'
import { Percent } from '@uniswap/sdk-core'
import { useFiatOnRampButtonEnabled } from 'featureFlags/flags/fiatOnRampButton'
import { subhead } from 'nft/css/common.css'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
import { RowBetween, RowFixed } from '../Row'
import SettingsTab from '../Settings'
import SwapBuyFiatButton from './SwapBuyFiatButton'
const StyledSwapHeader = styled.div`
padding: 8px 12px;
margin-bottom: 8px;
width: 100%;
const StyledSwapHeader = styled(RowBetween)`
margin-bottom: 10px;
color: ${({ theme }) => theme.textSecondary};
`
const TextHeader = styled.div`
color: ${({ theme }) => theme.textPrimary};
margin-right: 8px;
display: flex;
line-height: 20px;
flex-direction: row;
justify-content: center;
align-items: center;
const HeaderButtonContainer = styled(RowFixed)`
padding: 0 12px;
gap: 16px;
`
export default function SwapHeader({ autoSlippage }: { autoSlippage: Percent }) {
@@ -30,17 +23,15 @@ export default function SwapHeader({ autoSlippage }: { autoSlippage: Percent })
return (
<StyledSwapHeader>
<RowBetween>
<RowFixed style={{ gap: '8px' }}>
<TextHeader className={subhead}>
<Trans>Swap</Trans>
</TextHeader>
{fiatOnRampButtonEnabled && <SwapBuyFiatButton />}
</RowFixed>
<RowFixed>
<SettingsTab autoSlippage={autoSlippage} />
</RowFixed>
</RowBetween>
<HeaderButtonContainer>
<ThemedText.SubHeader>
<Trans>Swap</Trans>
</ThemedText.SubHeader>
{fiatOnRampButtonEnabled && <SwapBuyFiatButton />}
</HeaderButtonContainer>
<RowFixed>
<SettingsTab autoSlippage={autoSlippage} />
</RowFixed>
</StyledSwapHeader>
)
}

View File

@@ -1,27 +1,103 @@
import { TEST_ALLOWED_SLIPPAGE, TEST_TRADE_EXACT_INPUT } from 'test-utils/constants'
import { render, screen } from 'test-utils/render'
import { TEST_ALLOWED_SLIPPAGE, TEST_TRADE_EXACT_INPUT, TEST_TRADE_EXACT_OUTPUT } from 'test-utils/constants'
import { render, screen, within } from 'test-utils/render'
import SwapModalFooter from './SwapModalFooter'
const swapErrorMessage = 'swap error'
const fiatValue = { data: 123, isLoading: false }
describe('SwapModalFooter.tsx', () => {
it('renders with a disabled button with no account', () => {
it('matches base snapshot, test trade exact input', () => {
const { asFragment } = render(
<SwapModalFooter
trade={TEST_TRADE_EXACT_INPUT}
allowedSlippage={TEST_ALLOWED_SLIPPAGE}
hash={undefined}
onConfirm={() => null}
disabledConfirm
swapErrorMessage={swapErrorMessage}
onConfirm={jest.fn()}
swapErrorMessage={undefined}
disabledConfirm={false}
swapQuoteReceivedDate={undefined}
fiatValueInput={fiatValue}
fiatValueOutput={fiatValue}
fiatValueInput={{
data: undefined,
isLoading: false,
}}
fiatValueOutput={{
data: undefined,
isLoading: false,
}}
showAcceptChanges={false}
onAcceptChanges={jest.fn()}
/>
)
expect(asFragment()).toMatchSnapshot()
expect(screen.getByTestId('confirm-swap-button')).toBeDisabled()
expect(
screen.getByText(
'The minimum amount you are guaranteed to receive. If the price slips any further, your transaction will revert.'
)
).toBeInTheDocument()
expect(
screen.getByText('The fee paid to miners who process your transaction. This must be paid in $ETH.')
).toBeInTheDocument()
expect(screen.getByText('The impact your trade has on the market price of this pool.')).toBeInTheDocument()
})
it('shows accept changes section when available', () => {
const mockAcceptChanges = jest.fn()
render(
<SwapModalFooter
trade={TEST_TRADE_EXACT_INPUT}
allowedSlippage={TEST_ALLOWED_SLIPPAGE}
hash={undefined}
onConfirm={jest.fn()}
swapErrorMessage={undefined}
disabledConfirm={false}
swapQuoteReceivedDate={undefined}
fiatValueInput={{
data: undefined,
isLoading: false,
}}
fiatValueOutput={{
data: undefined,
isLoading: false,
}}
showAcceptChanges={true}
onAcceptChanges={mockAcceptChanges}
/>
)
const showAcceptChanges = screen.getByTestId('show-accept-changes')
expect(showAcceptChanges).toBeInTheDocument()
expect(within(showAcceptChanges).getByText('Price updated')).toBeVisible()
expect(within(showAcceptChanges).getByText('Accept')).toBeVisible()
})
it('test trade exact output, no recipient', () => {
render(
<SwapModalFooter
trade={TEST_TRADE_EXACT_OUTPUT}
allowedSlippage={TEST_ALLOWED_SLIPPAGE}
hash={undefined}
onConfirm={jest.fn()}
swapErrorMessage={undefined}
disabledConfirm={false}
swapQuoteReceivedDate={undefined}
fiatValueInput={{
data: undefined,
isLoading: false,
}}
fiatValueOutput={{
data: undefined,
isLoading: false,
}}
showAcceptChanges={true}
onAcceptChanges={jest.fn()}
/>
)
expect(
screen.getByText(
'The maximum amount you are guaranteed to spend. If the price slips any further, your transaction will revert.'
)
).toBeInTheDocument()
expect(
screen.getByText('The fee paid to miners who process your transaction. This must be paid in $ETH.')
).toBeInTheDocument()
expect(screen.getByText('The impact your trade has on the market price of this pool.')).toBeInTheDocument()
})
})

View File

@@ -1,105 +1,48 @@
import { Trans } from '@lingui/macro'
import { TraceEvent } from '@uniswap/analytics'
import { BrowserEvent, InterfaceElementName, SwapEventName } from '@uniswap/analytics-events'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { formatPriceImpact } from '@uniswap/conedison/format'
import { Percent, TradeType } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import Column from 'components/Column'
import { MouseoverTooltip } from 'components/Tooltip'
import useTransactionDeadline from 'hooks/useTransactionDeadline'
import {
formatPercentInBasisPointsNumber,
formatPercentNumber,
formatToDecimal,
getDurationFromDateMilliseconds,
getDurationUntilTimestampSeconds,
getTokenAddress,
} from 'lib/utils/analytics'
import useNativeCurrency from 'lib/hooks/useNativeCurrency'
import { ReactNode } from 'react'
import { Text } from 'rebass'
import { AlertTriangle } from 'react-feather'
import { RouterPreference } from 'state/routing/slice'
import { InterfaceTrade } from 'state/routing/types'
import { useRouterPreference, useUserSlippageTolerance } from 'state/user/hooks'
import getRoutingDiagramEntries, { RoutingDiagramEntry } from 'utils/getRoutingDiagramEntries'
import { computeRealizedPriceImpact } from 'utils/prices'
import styled, { useTheme } from 'styled-components/macro'
import { ThemedText } from 'theme'
import { formatTransactionAmount, priceToPreciseFloat } from 'utils/formatNumbers'
import getRoutingDiagramEntries from 'utils/getRoutingDiagramEntries'
import { formatSwapButtonClickEventProperties } from 'utils/loggingFormatters'
import { getPriceImpactWarning } from 'utils/prices'
import { ButtonError } from '../Button'
import { AutoRow } from '../Row'
import { SwapCallbackError } from './styleds'
import { ButtonError, SmallButtonPrimary } from '../Button'
import Row, { AutoRow, RowBetween, RowFixed } from '../Row'
import { SwapCallbackError, SwapShowAcceptChanges } from './styleds'
import { Label } from './SwapModalHeaderAmount'
interface AnalyticsEventProps {
trade: InterfaceTrade<Currency, Currency, TradeType>
hash: string | undefined
allowedSlippage: Percent
transactionDeadlineSecondsSinceEpoch: number | undefined
isAutoSlippage: boolean
isAutoRouterApi: boolean
swapQuoteReceivedDate: Date | undefined
routes: RoutingDiagramEntry[]
fiatValueInput?: number
fiatValueOutput?: number
}
const DetailsContainer = styled(Column)`
padding: 0 8px;
`
const formatRoutesEventProperties = (routes: RoutingDiagramEntry[]) => {
const routesEventProperties: Record<string, any[]> = {
routes_percentages: [],
routes_protocols: [],
}
const StyledAlertTriangle = styled(AlertTriangle)`
margin-right: 8px;
min-width: 24px;
`
routes.forEach((route, index) => {
routesEventProperties['routes_percentages'].push(formatPercentNumber(route.percent))
routesEventProperties['routes_protocols'].push(route.protocol)
routesEventProperties[`route_${index}_input_currency_symbols`] = route.path.map(
(pathStep) => pathStep[0].symbol ?? ''
)
routesEventProperties[`route_${index}_output_currency_symbols`] = route.path.map(
(pathStep) => pathStep[1].symbol ?? ''
)
routesEventProperties[`route_${index}_input_currency_addresses`] = route.path.map((pathStep) =>
getTokenAddress(pathStep[0])
)
routesEventProperties[`route_${index}_output_currency_addresses`] = route.path.map((pathStep) =>
getTokenAddress(pathStep[1])
)
routesEventProperties[`route_${index}_fee_amounts_hundredths_of_bps`] = route.path.map((pathStep) => pathStep[2])
})
const ConfirmButton = styled(ButtonError)`
height: 56px;
margin-top: 10px;
`
return routesEventProperties
}
const formatAnalyticsEventProperties = ({
trade,
hash,
allowedSlippage,
transactionDeadlineSecondsSinceEpoch,
isAutoSlippage,
isAutoRouterApi,
swapQuoteReceivedDate,
routes,
fiatValueInput,
fiatValueOutput,
}: AnalyticsEventProps) => ({
estimated_network_fee_usd: trade.gasUseEstimateUSD ? formatToDecimal(trade.gasUseEstimateUSD, 2) : undefined,
transaction_hash: hash,
transaction_deadline_seconds: getDurationUntilTimestampSeconds(transactionDeadlineSecondsSinceEpoch),
token_in_address: getTokenAddress(trade.inputAmount.currency),
token_out_address: getTokenAddress(trade.outputAmount.currency),
token_in_symbol: trade.inputAmount.currency.symbol,
token_out_symbol: trade.outputAmount.currency.symbol,
token_in_amount: formatToDecimal(trade.inputAmount, trade.inputAmount.currency.decimals),
token_out_amount: formatToDecimal(trade.outputAmount, trade.outputAmount.currency.decimals),
token_in_amount_usd: fiatValueInput,
token_out_amount_usd: fiatValueOutput,
price_impact_basis_points: formatPercentInBasisPointsNumber(computeRealizedPriceImpact(trade)),
allowed_slippage_basis_points: formatPercentInBasisPointsNumber(allowedSlippage),
is_auto_router_api: isAutoRouterApi,
is_auto_slippage: isAutoSlippage,
chain_id:
trade.inputAmount.currency.chainId === trade.outputAmount.currency.chainId
? trade.inputAmount.currency.chainId
: undefined,
duration_from_first_quote_to_swap_submission_milliseconds: swapQuoteReceivedDate
? getDurationFromDateMilliseconds(swapQuoteReceivedDate)
: undefined,
swap_quote_block_number: trade.blockNumber,
...formatRoutesEventProperties(routes),
})
const DetailRowValue = styled(ThemedText.BodySmall)`
text-align: right;
overflow-wrap: break-word;
`
export default function SwapModalFooter({
trade,
@@ -111,8 +54,10 @@ export default function SwapModalFooter({
swapQuoteReceivedDate,
fiatValueInput,
fiatValueOutput,
showAcceptChanges,
onAcceptChanges,
}: {
trade: InterfaceTrade<Currency, Currency, TradeType>
trade: InterfaceTrade
hash: string | undefined
allowedSlippage: Percent
onConfirm: () => void
@@ -121,46 +66,142 @@ export default function SwapModalFooter({
swapQuoteReceivedDate: Date | undefined
fiatValueInput: { data?: number; isLoading: boolean }
fiatValueOutput: { data?: number; isLoading: boolean }
showAcceptChanges: boolean
onAcceptChanges: () => void
}) {
const transactionDeadlineSecondsSinceEpoch = useTransactionDeadline()?.toNumber() // in seconds since epoch
const isAutoSlippage = useUserSlippageTolerance()[0] === 'auto'
const [routerPreference] = useRouterPreference()
const routes = getRoutingDiagramEntries(trade)
const theme = useTheme()
const { chainId } = useWeb3React()
const nativeCurrency = useNativeCurrency(chainId)
const label = `${trade.executionPrice.baseCurrency?.symbol} `
const labelInverted = `${trade.executionPrice.quoteCurrency?.symbol}`
const formattedPrice = formatTransactionAmount(priceToPreciseFloat(trade.executionPrice))
return (
<>
<AutoRow>
<TraceEvent
events={[BrowserEvent.onClick]}
element={InterfaceElementName.CONFIRM_SWAP_BUTTON}
name={SwapEventName.SWAP_SUBMITTED_BUTTON_CLICKED}
properties={formatAnalyticsEventProperties({
trade,
hash,
allowedSlippage,
transactionDeadlineSecondsSinceEpoch,
isAutoSlippage,
isAutoRouterApi: routerPreference === RouterPreference.AUTO || routerPreference === RouterPreference.API,
swapQuoteReceivedDate,
routes,
fiatValueInput: fiatValueInput.data,
fiatValueOutput: fiatValueOutput.data,
})}
>
<ButtonError
data-testid="confirm-swap-button"
onClick={onConfirm}
disabled={disabledConfirm}
style={{ margin: '10px 0 0 0' }}
id={InterfaceElementName.CONFIRM_SWAP_BUTTON}
<DetailsContainer gap="md">
<ThemedText.BodySmall>
<Row align="flex-start" justify="space-between" gap="sm">
<Label>
<Trans>Exchange rate</Trans>
</Label>
<DetailRowValue>{`1 ${labelInverted} = ${formattedPrice ?? '-'} ${label}`}</DetailRowValue>
</Row>
</ThemedText.BodySmall>
<ThemedText.BodySmall>
<Row align="flex-start" justify="space-between" gap="sm">
<MouseoverTooltip
text={
<Trans>
The fee paid to miners who process your transaction. This must be paid in ${nativeCurrency.symbol}.
</Trans>
}
>
<Label cursor="help">
<Trans>Network fee</Trans>
</Label>
</MouseoverTooltip>
<DetailRowValue>{trade.gasUseEstimateUSD ? `~$${trade.gasUseEstimateUSD}` : '-'}</DetailRowValue>
</Row>
</ThemedText.BodySmall>
<ThemedText.BodySmall>
<Row align="flex-start" justify="space-between" gap="sm">
<MouseoverTooltip text={<Trans>The impact your trade has on the market price of this pool.</Trans>}>
<Label cursor="help">
<Trans>Price impact</Trans>
</Label>
</MouseoverTooltip>
<DetailRowValue color={getPriceImpactWarning(trade.priceImpact)}>
{trade.priceImpact ? formatPriceImpact(trade.priceImpact) : '-'}
</DetailRowValue>
</Row>
</ThemedText.BodySmall>
<ThemedText.BodySmall>
<Row align="flex-start" justify="space-between" gap="sm">
<MouseoverTooltip
text={
trade.tradeType === TradeType.EXACT_INPUT ? (
<Trans>
The minimum amount you are guaranteed to receive. If the price slips any further, your transaction
will revert.
</Trans>
) : (
<Trans>
The maximum amount you are guaranteed to spend. If the price slips any further, your transaction
will revert.
</Trans>
)
}
>
<Label cursor="help">
{trade.tradeType === TradeType.EXACT_INPUT ? (
<Trans>Minimum received</Trans>
) : (
<Trans>Maximum sent</Trans>
)}
</Label>
</MouseoverTooltip>
<DetailRowValue>
{trade.tradeType === TradeType.EXACT_INPUT
? `${trade.minimumAmountOut(allowedSlippage).toSignificant(6)} ${trade.outputAmount.currency.symbol}`
: `${trade.maximumAmountIn(allowedSlippage).toSignificant(6)} ${trade.inputAmount.currency.symbol}`}
</DetailRowValue>
</Row>
</ThemedText.BodySmall>
</DetailsContainer>
{showAcceptChanges ? (
<SwapShowAcceptChanges data-testid="show-accept-changes">
<RowBetween>
<RowFixed>
<StyledAlertTriangle size={20} />
<ThemedText.DeprecatedMain color={theme.accentAction}>
<Trans>Price updated</Trans>
</ThemedText.DeprecatedMain>
</RowFixed>
<SmallButtonPrimary onClick={onAcceptChanges}>
<Trans>Accept</Trans>
</SmallButtonPrimary>
</RowBetween>
</SwapShowAcceptChanges>
) : (
<AutoRow>
<TraceEvent
events={[BrowserEvent.onClick]}
element={InterfaceElementName.CONFIRM_SWAP_BUTTON}
name={SwapEventName.SWAP_SUBMITTED_BUTTON_CLICKED}
properties={formatSwapButtonClickEventProperties({
trade,
hash,
allowedSlippage,
transactionDeadlineSecondsSinceEpoch,
isAutoSlippage,
isAutoRouterApi: routerPreference === RouterPreference.AUTO || routerPreference === RouterPreference.API,
swapQuoteReceivedDate,
routes,
fiatValueInput: fiatValueInput.data,
fiatValueOutput: fiatValueOutput.data,
})}
>
<Text fontSize={20} fontWeight={500}>
<Trans>Confirm Swap</Trans>
</Text>
</ButtonError>
</TraceEvent>
{swapErrorMessage ? <SwapCallbackError error={swapErrorMessage} /> : null}
</AutoRow>
<ConfirmButton
data-testid="confirm-swap-button"
onClick={onConfirm}
disabled={disabledConfirm}
$borderRadius="12px"
id={InterfaceElementName.CONFIRM_SWAP_BUTTON}
>
<ThemedText.HeadlineSmall color="accentTextLightPrimary">
<Trans>Swap</Trans>
</ThemedText.HeadlineSmall>
</ConfirmButton>
</TraceEvent>
{swapErrorMessage ? <SwapCallbackError error={swapErrorMessage} /> : null}
</AutoRow>
)}
</>
)
}

View File

@@ -1,83 +1,44 @@
import { sendAnalyticsEvent } from '@uniswap/analytics'
import {
TEST_ALLOWED_SLIPPAGE,
TEST_RECIPIENT_ADDRESS,
TEST_TRADE_EXACT_INPUT,
TEST_TRADE_EXACT_OUTPUT,
} from 'test-utils/constants'
import { render, screen, within } from 'test-utils/render'
import noop from 'utils/noop'
import { formatCurrencyAmount, NumberType } from '@uniswap/conedison/format'
import { TEST_ALLOWED_SLIPPAGE, TEST_TRADE_EXACT_INPUT, TEST_TRADE_EXACT_OUTPUT } from 'test-utils/constants'
import { render, screen } from 'test-utils/render'
import SwapModalHeader from './SwapModalHeader'
jest.mock('@uniswap/analytics')
const mockSendAnalyticsEvent = sendAnalyticsEvent as jest.MockedFunction<typeof sendAnalyticsEvent>
describe('SwapModalHeader.tsx', () => {
let sendAnalyticsEventMock: jest.Mock<any, any>
beforeAll(() => {
sendAnalyticsEventMock = jest.fn()
})
it('matches base snapshot for test trade with exact input', () => {
it('matches base snapshot, test trade exact input', () => {
const { asFragment } = render(
<SwapModalHeader
trade={TEST_TRADE_EXACT_INPUT}
allowedSlippage={TEST_ALLOWED_SLIPPAGE}
shouldLogModalCloseEvent={false}
showAcceptChanges={false}
setShouldLogModalCloseEvent={noop}
onAcceptChanges={noop}
recipient={TEST_RECIPIENT_ADDRESS}
/>
<SwapModalHeader trade={TEST_TRADE_EXACT_INPUT} allowedSlippage={TEST_ALLOWED_SLIPPAGE} />
)
expect(asFragment()).toMatchSnapshot()
expect(screen.getByText(/Output is estimated. You will receive at least /i)).toBeInTheDocument()
expect(screen.getByTestId('INPUT-amount')).toHaveTextContent(
`${formatCurrencyAmount(TEST_TRADE_EXACT_INPUT.inputAmount, NumberType.TokenTx)} ${
TEST_TRADE_EXACT_INPUT.inputAmount.currency.symbol ?? ''
}`
)
expect(screen.getByTestId('OUTPUT-amount')).toHaveTextContent(
`${formatCurrencyAmount(TEST_TRADE_EXACT_INPUT.outputAmount, NumberType.TokenTx)} ${
TEST_TRADE_EXACT_INPUT.outputAmount.currency.symbol ?? ''
}`
)
})
it('shows accept changes section and logs amplitude event', () => {
const setShouldLogModalCloseEventFn = jest.fn()
mockSendAnalyticsEvent.mockImplementation(sendAnalyticsEventMock)
render(
<SwapModalHeader
trade={TEST_TRADE_EXACT_INPUT}
allowedSlippage={TEST_ALLOWED_SLIPPAGE}
shouldLogModalCloseEvent
showAcceptChanges
setShouldLogModalCloseEvent={setShouldLogModalCloseEventFn}
onAcceptChanges={noop}
recipient={TEST_RECIPIENT_ADDRESS}
/>
it('test trade exact output, no recipient', () => {
const { asFragment } = render(
<SwapModalHeader trade={TEST_TRADE_EXACT_OUTPUT} allowedSlippage={TEST_ALLOWED_SLIPPAGE} />
)
expect(setShouldLogModalCloseEventFn).toHaveBeenCalledWith(false)
const showAcceptChanges = screen.getByTestId('show-accept-changes')
expect(showAcceptChanges).toBeInTheDocument()
expect(within(showAcceptChanges).getByText('Price Updated')).toBeVisible()
expect(within(showAcceptChanges).getByText('Accept')).toBeVisible()
expect(sendAnalyticsEventMock).toHaveBeenCalledTimes(1)
})
it('renders correctly for test trade with exact output and no recipient', () => {
const rendered = render(
<SwapModalHeader
trade={TEST_TRADE_EXACT_OUTPUT}
allowedSlippage={TEST_ALLOWED_SLIPPAGE}
shouldLogModalCloseEvent={false}
showAcceptChanges={false}
setShouldLogModalCloseEvent={noop}
onAcceptChanges={noop}
recipient={null}
/>
)
expect(rendered.queryByTestId('recipient-info')).toBeNull()
expect(asFragment()).toMatchSnapshot()
expect(screen.getByText(/Input is estimated. You will sell at most/i)).toBeInTheDocument()
expect(screen.getByTestId('input-symbol')).toHaveTextContent(
TEST_TRADE_EXACT_OUTPUT.inputAmount.currency.symbol ?? ''
expect(screen.getByTestId('INPUT-amount')).toHaveTextContent(
`${formatCurrencyAmount(TEST_TRADE_EXACT_OUTPUT.inputAmount, NumberType.TokenTx)} ${
TEST_TRADE_EXACT_OUTPUT.inputAmount.currency.symbol ?? ''
}`
)
expect(screen.getByTestId('output-symbol')).toHaveTextContent(
TEST_TRADE_EXACT_OUTPUT.outputAmount.currency.symbol ?? ''
expect(screen.getByTestId('OUTPUT-amount')).toHaveTextContent(
`${formatCurrencyAmount(TEST_TRADE_EXACT_OUTPUT.outputAmount, NumberType.TokenTx)} ${
TEST_TRADE_EXACT_OUTPUT.outputAmount.currency.symbol ?? ''
}`
)
expect(screen.getByTestId('input-amount')).toHaveTextContent(TEST_TRADE_EXACT_OUTPUT.inputAmount.toExact())
expect(screen.getByTestId('output-amount')).toHaveTextContent(TEST_TRADE_EXACT_OUTPUT.outputAmount.toExact())
})
})

View File

@@ -1,216 +1,72 @@
import { Trans } from '@lingui/macro'
import { sendAnalyticsEvent } from '@uniswap/analytics'
import { SwapEventName, SwapPriceUpdateUserResponse } from '@uniswap/analytics-events'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { Percent, TradeType } from '@uniswap/sdk-core'
import Column, { AutoColumn } from 'components/Column'
import { useUSDPrice } from 'hooks/useUSDPrice'
import { getPriceUpdateBasisPoints } from 'lib/utils/analytics'
import { useEffect, useState } from 'react'
import { AlertTriangle, ArrowDown } from 'react-feather'
import { Text } from 'rebass'
import { InterfaceTrade } from 'state/routing/types'
import styled, { useTheme } from 'styled-components/macro'
import { Field } from 'state/swap/actions'
import styled from 'styled-components/macro'
import { Divider, ThemedText } from 'theme'
import { ThemedText } from '../../theme'
import { isAddress, shortenAddress } from '../../utils'
import { computeFiatValuePriceImpact } from '../../utils/computeFiatValuePriceImpact'
import { ButtonPrimary } from '../Button'
import { LightCard } from '../Card'
import { AutoColumn } from '../Column'
import { FiatValue } from '../CurrencyInputPanel/FiatValue'
import CurrencyLogo from '../Logo/CurrencyLogo'
import { RowBetween, RowFixed } from '../Row'
import TradePrice from '../swap/TradePrice'
import { AdvancedSwapDetails } from './AdvancedSwapDetails'
import { SwapShowAcceptChanges, TruncatedText } from './styleds'
import { SwapModalHeaderAmount } from './SwapModalHeaderAmount'
const ArrowWrapper = styled.div`
padding: 4px;
border-radius: 12px;
height: 40px;
width: 40px;
position: relative;
margin-top: -18px;
margin-bottom: -18px;
left: calc(50% - 16px);
display: flex;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.backgroundSurface};
border: 4px solid;
border-color: ${({ theme }) => theme.backgroundModule};
z-index: 2;
const Rule = styled(Divider)`
margin: 16px 2px 24px 2px;
`
const formatAnalyticsEventProperties = (
trade: InterfaceTrade<Currency, Currency, TradeType>,
priceUpdate: number | undefined,
response: SwapPriceUpdateUserResponse
) => ({
chain_id:
trade.inputAmount.currency.chainId === trade.outputAmount.currency.chainId
? trade.inputAmount.currency.chainId
: undefined,
response,
token_in_symbol: trade.inputAmount.currency.symbol,
token_out_symbol: trade.outputAmount.currency.symbol,
price_update_basis_points: priceUpdate,
})
const HeaderContainer = styled(AutoColumn)`
margin-top: 16px;
`
export default function SwapModalHeader({
trade,
shouldLogModalCloseEvent,
setShouldLogModalCloseEvent,
allowedSlippage,
recipient,
showAcceptChanges,
onAcceptChanges,
}: {
trade: InterfaceTrade<Currency, Currency, TradeType>
shouldLogModalCloseEvent: boolean
setShouldLogModalCloseEvent: (shouldLog: boolean) => void
trade: InterfaceTrade
allowedSlippage: Percent
recipient: string | null
showAcceptChanges: boolean
onAcceptChanges: () => void
}) {
const theme = useTheme()
const [lastExecutionPrice, setLastExecutionPrice] = useState(trade.executionPrice)
const [priceUpdate, setPriceUpdate] = useState<number | undefined>()
const fiatValueInput = useUSDPrice(trade.inputAmount)
const fiatValueOutput = useUSDPrice(trade.outputAmount)
useEffect(() => {
if (!trade.executionPrice.equalTo(lastExecutionPrice)) {
setPriceUpdate(getPriceUpdateBasisPoints(lastExecutionPrice, trade.executionPrice))
setLastExecutionPrice(trade.executionPrice)
}
}, [lastExecutionPrice, setLastExecutionPrice, trade.executionPrice])
useEffect(() => {
if (shouldLogModalCloseEvent && showAcceptChanges) {
sendAnalyticsEvent(
SwapEventName.SWAP_PRICE_UPDATE_ACKNOWLEDGED,
formatAnalyticsEventProperties(trade, priceUpdate, SwapPriceUpdateUserResponse.REJECTED)
)
}
setShouldLogModalCloseEvent(false)
}, [shouldLogModalCloseEvent, showAcceptChanges, setShouldLogModalCloseEvent, trade, priceUpdate])
return (
<AutoColumn gap="4px" style={{ marginTop: '1rem' }}>
<LightCard padding="0.75rem 1rem">
<AutoColumn gap="sm">
<RowBetween align="center">
<RowFixed gap="0px">
<TruncatedText
fontSize={24}
fontWeight={500}
color={showAcceptChanges && trade.tradeType === TradeType.EXACT_OUTPUT ? theme.accentAction : ''}
data-testid="input-amount"
>
{trade.inputAmount.toSignificant(6)}
</TruncatedText>
</RowFixed>
<RowFixed gap="0px">
<CurrencyLogo currency={trade.inputAmount.currency} size="20px" style={{ marginRight: '12px' }} />
<Text fontSize={20} fontWeight={500} data-testid="input-symbol">
{trade.inputAmount.currency.symbol}
</Text>
</RowFixed>
</RowBetween>
<RowBetween>
<FiatValue fiatValue={fiatValueInput} />
</RowBetween>
</AutoColumn>
</LightCard>
<ArrowWrapper>
<ArrowDown size="16" color={theme.textPrimary} />
</ArrowWrapper>
<LightCard padding="0.75rem 1rem" style={{ marginBottom: '0.25rem' }}>
<AutoColumn gap="sm">
<RowBetween align="flex-end">
<RowFixed gap="0px">
<TruncatedText fontSize={24} fontWeight={500} data-testid="output-amount">
{trade.outputAmount.toSignificant(6)}
</TruncatedText>
</RowFixed>
<RowFixed gap="0px">
<CurrencyLogo currency={trade.outputAmount.currency} size="20px" style={{ marginRight: '12px' }} />
<Text fontSize={20} fontWeight={500} data-testid="output-symbol">
{trade.outputAmount.currency.symbol}
</Text>
</RowFixed>
</RowBetween>
<RowBetween>
<ThemedText.DeprecatedBody fontSize={14} color={theme.textTertiary}>
<FiatValue
fiatValue={fiatValueOutput}
priceImpact={computeFiatValuePriceImpact(fiatValueInput.data, fiatValueOutput.data)}
/>
</ThemedText.DeprecatedBody>
</RowBetween>
</AutoColumn>
</LightCard>
<RowBetween style={{ marginTop: '0.25rem', padding: '0 1rem' }}>
<TradePrice price={trade.executionPrice} />
</RowBetween>
<LightCard style={{ padding: '.75rem', marginTop: '0.5rem' }}>
<AdvancedSwapDetails trade={trade} allowedSlippage={allowedSlippage} />
</LightCard>
{showAcceptChanges ? (
<SwapShowAcceptChanges justify="flex-start" gap="0px" data-testid="show-accept-changes">
<RowBetween>
<RowFixed>
<AlertTriangle size={20} style={{ marginRight: '8px', minWidth: 24 }} />
<ThemedText.DeprecatedMain color={theme.accentAction}>
<Trans>Price Updated</Trans>
</ThemedText.DeprecatedMain>
</RowFixed>
<ButtonPrimary
style={{ padding: '.5rem', width: 'fit-content', fontSize: '0.825rem', borderRadius: '12px' }}
onClick={onAcceptChanges}
>
<Trans>Accept</Trans>
</ButtonPrimary>
</RowBetween>
</SwapShowAcceptChanges>
) : null}
<AutoColumn justify="flex-start" gap="sm" style={{ padding: '.75rem 1rem' }}>
{trade.tradeType === TradeType.EXACT_INPUT ? (
<ThemedText.DeprecatedItalic fontWeight={400} textAlign="left" style={{ width: '100%' }}>
<Trans>
Output is estimated. You will receive at least{' '}
<b>
{trade.minimumAmountOut(allowedSlippage).toSignificant(6)} {trade.outputAmount.currency.symbol}
</b>{' '}
or the transaction will revert.
</Trans>
</ThemedText.DeprecatedItalic>
) : (
<ThemedText.DeprecatedItalic fontWeight={400} textAlign="left" style={{ width: '100%' }}>
<Trans>
Input is estimated. You will sell at most{' '}
<b>
{trade.maximumAmountIn(allowedSlippage).toSignificant(6)} {trade.inputAmount.currency.symbol}
</b>{' '}
or the transaction will revert.
</Trans>
</ThemedText.DeprecatedItalic>
)}
</AutoColumn>
{recipient !== null ? (
<AutoColumn justify="flex-start" gap="sm" style={{ padding: '12px 0 0 0px' }} data-testid="recipient-info">
<ThemedText.DeprecatedMain>
<Trans>
Output will be sent to{' '}
<b title={recipient}>{isAddress(recipient) ? shortenAddress(recipient) : recipient}</b>
</Trans>
</ThemedText.DeprecatedMain>
</AutoColumn>
) : null}
</AutoColumn>
<HeaderContainer gap="sm">
<Column gap="lg">
<SwapModalHeaderAmount
field={Field.INPUT}
label={<Trans>You pay</Trans>}
amount={trade.inputAmount}
usdAmount={fiatValueInput.data}
/>
<SwapModalHeaderAmount
field={Field.OUTPUT}
label={<Trans>You receive</Trans>}
amount={trade.outputAmount}
usdAmount={fiatValueOutput.data}
tooltipText={
trade.tradeType === TradeType.EXACT_INPUT ? (
<ThemedText.Caption>
<Trans>
Output is estimated. You will receive at least{' '}
<b>
{trade.minimumAmountOut(allowedSlippage).toSignificant(6)} {trade.outputAmount.currency.symbol}
</b>{' '}
or the transaction will revert.
</Trans>
</ThemedText.Caption>
) : (
<ThemedText.Caption>
<Trans>
Input is estimated. You will sell at most{' '}
<b>
{trade.maximumAmountIn(allowedSlippage).toSignificant(6)} {trade.inputAmount.currency.symbol}
</b>{' '}
or the transaction will revert.
</Trans>
</ThemedText.Caption>
)
}
/>
</Column>
<Rule />
</HeaderContainer>
)
}

View File

@@ -0,0 +1,68 @@
import { formatCurrencyAmount, formatNumber, NumberType } from '@uniswap/conedison/format'
import { Currency, CurrencyAmount } from '@uniswap/sdk-core'
import Column from 'components/Column'
import CurrencyLogo from 'components/Logo/CurrencyLogo'
import Row from 'components/Row'
import { MouseoverTooltip } from 'components/Tooltip'
import { useWindowSize } from 'hooks/useWindowSize'
import { PropsWithChildren, ReactNode } from 'react'
import { TextProps } from 'rebass'
import { Field } from 'state/swap/actions'
import styled from 'styled-components/macro'
import { BREAKPOINTS, ThemedText } from 'theme'
const MAX_AMOUNT_STR_LENGTH = 9
export const Label = styled(ThemedText.BodySmall)<{ cursor?: string }>`
cursor: ${({ cursor }) => cursor};
color: ${({ theme }) => theme.textSecondary};
margin-right: 8px;
`
const ResponsiveHeadline = ({ children, ...textProps }: PropsWithChildren<TextProps>) => {
const { width } = useWindowSize()
if (width && width < BREAKPOINTS.xs) {
return <ThemedText.HeadlineMedium {...textProps}>{children}</ThemedText.HeadlineMedium>
}
return <ThemedText.HeadlineLarge {...textProps}>{children}</ThemedText.HeadlineLarge>
}
interface AmountProps {
field: Field
tooltipText?: ReactNode
label: ReactNode
amount: CurrencyAmount<Currency> | undefined
usdAmount?: number
}
export function SwapModalHeaderAmount({ tooltipText, label, amount, usdAmount, field }: AmountProps) {
let formattedAmount = formatCurrencyAmount(amount, NumberType.TokenTx)
if (formattedAmount.length > MAX_AMOUNT_STR_LENGTH) {
formattedAmount = formatCurrencyAmount(amount, NumberType.SwapTradeAmount)
}
return (
<Row align="center" justify="space-between" gap="md">
<Column gap="xs">
<ThemedText.BodySecondary>
<MouseoverTooltip text={tooltipText} disabled={!tooltipText}>
<Label cursor="help">{label}</Label>
</MouseoverTooltip>
</ThemedText.BodySecondary>
<Column gap="xs">
<ResponsiveHeadline data-testid={`${field}-amount`}>
{formattedAmount} {amount?.currency.symbol}
</ResponsiveHeadline>
{usdAmount && (
<ThemedText.BodySmall color="textTertiary">
{formatNumber(usdAmount, NumberType.FiatTokenQuantity)}
</ThemedText.BodySmall>
)}
</Column>
</Column>
{amount?.currency && <CurrencyLogo currency={amount.currency} size="36px" />}
</Row>
)
}

View File

@@ -1,5 +1,4 @@
import { Trans } from '@lingui/macro'
import { Currency, TradeType } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import Column from 'components/Column'
import { LoadingRows } from 'components/Loader/styled'
@@ -12,13 +11,7 @@ import getRoutingDiagramEntries from 'utils/getRoutingDiagramEntries'
import RouterLabel from './RouterLabel'
export default function SwapRoute({
trade,
syncing,
}: {
trade: InterfaceTrade<Currency, Currency, TradeType>
syncing: boolean
}) {
export default function SwapRoute({ trade, syncing }: { trade: InterfaceTrade; syncing: boolean }) {
const { chainId } = useWeb3React()
const autoRouterSupported = useAutoRouterSupported()
@@ -28,9 +21,9 @@ export default function SwapRoute({
// TODO(WEB-3303)
// Can `trade.gasUseEstimateUSD` be defined when `chainId` is not in `SUPPORTED_GAS_ESTIMATE_CHAIN_IDS`?
trade.gasUseEstimateUSD && chainId && SUPPORTED_GAS_ESTIMATE_CHAIN_IDS.includes(chainId)
? trade.gasUseEstimateUSD.toFixed(2) === '0.00'
? trade.gasUseEstimateUSD === '0.00'
? '<$0.01'
: '$' + trade.gasUseEstimateUSD.toFixed(2)
: '$' + trade.gasUseEstimateUSD
: undefined
return (

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

@@ -32,17 +32,17 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
justify-content: space-between;
}
.c5 {
.c8 {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c7 {
.c6 {
color: #7780A0;
}
.c8 {
.c7 {
color: #0D111C;
}
@@ -67,7 +67,7 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
gap: 12px;
}
.c6 {
.c5 {
display: inline-block;
height: inherit;
}
@@ -82,14 +82,54 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
class="c2 c3 c4"
>
<div
class="c2 c3 c5"
class="c5"
>
<div>
<div
class="c6 css-zhpkf8"
>
Network fee
</div>
</div>
</div>
<div
class="c7 css-zhpkf8"
>
~$1.00
</div>
</div>
<div
class="c2 c3 c4"
>
<div
class="c5"
>
<div>
<div
class="c6 css-zhpkf8"
>
Price Impact
</div>
</div>
</div>
<div
class="c7 css-zhpkf8"
>
105566.37%
</div>
</div>
<div
class="c2 c3 c4"
>
<div
class="c2 c3 c8"
>
<div
class="c6"
class="c5"
>
<div>
<div
class="c7 css-zhpkf8"
class="c6 css-zhpkf8"
>
Minimum output
</div>
@@ -97,7 +137,7 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
</div>
</div>
<div
class="c8 css-zhpkf8"
class="c7 css-zhpkf8"
>
0.00000000000000098 DEF
</div>
@@ -106,14 +146,14 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
class="c2 c3 c4"
>
<div
class="c2 c3 c5"
class="c2 c3 c8"
>
<div
class="c6"
class="c5"
>
<div>
<div
class="c7 css-zhpkf8"
class="c6 css-zhpkf8"
>
Expected output
</div>
@@ -121,7 +161,7 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
</div>
</div>
<div
class="c8 css-zhpkf8"
class="c7 css-zhpkf8"
>
0.000000000000001 DEF
</div>
@@ -133,16 +173,16 @@ exports[`AdvancedSwapDetails.tsx matches base snapshot 1`] = `
class="c2 c3 c4"
>
<div
class="c7 css-zhpkf8"
class="c6 css-zhpkf8"
>
Order routing
</div>
<div
class="c6"
class="c5"
>
<div>
<div
class="c8 css-zhpkf8"
class="c7 css-zhpkf8"
>
Uniswap API
</div>

View File

@@ -42,11 +42,11 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
color: #0D111C;
}
.c15 {
.c12 {
color: #7780A0;
}
.c13 {
.c16 {
width: 100%;
height: 1px;
background-color: #D2D9EE;
@@ -66,7 +66,7 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
justify-content: flex-start;
}
.c12 {
.c15 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
@@ -89,11 +89,20 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
transition: opacity 0.2s ease-in-out;
}
.c14 {
.c10 {
display: inline-block;
height: inherit;
}
.c11 {
margin-right: 4px;
height: 18px;
}
.c11 > * {
stroke: #98A1C0;
}
.c8 {
background-color: transparent;
border: none;
@@ -135,7 +144,7 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
cursor: pointer;
}
.c10 {
.c13 {
-webkit-transform: none;
-ms-transform: none;
transform: none;
@@ -144,7 +153,7 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
transition: transform 0.1s linear;
}
.c11 {
.c14 {
padding-top: 12px;
}
@@ -184,8 +193,32 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
<div
class="c2 c3 c6"
>
<svg
<div
class="c10"
>
<div>
<div
class="c7"
>
<div
class="c2 c3 c6"
>
<svg
class="c11"
>
gas-icon.svg
</svg>
<div
class="c12 css-zhpkf8"
>
$1.00
</div>
</div>
</div>
</div>
</div>
<svg
class="c13"
fill="none"
height="24"
stroke="#98A1C0"
@@ -207,15 +240,55 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
>
<div>
<div
class="c11"
class="c14"
data-testid="advanced-swap-details"
>
<div
class="c12"
class="c15"
>
<div
class="c13"
class="c16"
/>
<div
class="c2 c3 c4"
>
<div
class="c10"
>
<div>
<div
class="c12 css-zhpkf8"
>
Network fee
</div>
</div>
</div>
<div
class="c9 css-zhpkf8"
>
~$1.00
</div>
</div>
<div
class="c2 c3 c4"
>
<div
class="c10"
>
<div>
<div
class="c12 css-zhpkf8"
>
Price Impact
</div>
</div>
</div>
<div
class="c9 css-zhpkf8"
>
105566.37%
</div>
</div>
<div
class="c2 c3 c4"
>
@@ -223,11 +296,11 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
class="c2 c3 c6"
>
<div
class="c14"
class="c10"
>
<div>
<div
class="c15 css-zhpkf8"
class="c12 css-zhpkf8"
>
Minimum output
</div>
@@ -247,11 +320,11 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
class="c2 c3 c6"
>
<div
class="c14"
class="c10"
>
<div>
<div
class="c15 css-zhpkf8"
class="c12 css-zhpkf8"
>
Expected output
</div>
@@ -265,18 +338,18 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = `
</div>
</div>
<div
class="c13"
class="c16"
/>
<div
class="c2 c3 c4"
>
<div
class="c15 css-zhpkf8"
class="c12 css-zhpkf8"
>
Order routing
</div>
<div
class="c14"
class="c10"
>
<div>
<div

View File

@@ -1,7 +1,172 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SwapModalFooter.tsx renders with a disabled button with no account 1`] = `
exports[`SwapModalFooter.tsx matches base snapshot, test trade exact input 1`] = `
<DocumentFragment>
.c3 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c4 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 0;
-webkit-align-items: flex-start;
-webkit-box-align: flex-start;
-ms-flex-align: flex-start;
align-items: flex-start;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
gap: 8px;
}
.c2 {
color: #0D111C;
}
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
gap: 12px;
}
.c7 {
display: inline-block;
height: inherit;
}
.c5 {
color: #7780A0;
margin-right: 8px;
}
.c8 {
cursor: help;
color: #7780A0;
margin-right: 8px;
}
.c1 {
padding: 0 8px;
}
.c6 {
text-align: right;
overflow-wrap: break-word;
}
<div
class="c0 c1"
>
<div
class="c2 css-zhpkf8"
>
<div
class="c3 c4"
>
<div
class="c2 c5 css-zhpkf8"
>
Exchange rate
</div>
<div
class="c2 c6 css-zhpkf8"
>
1 DEF = 1.00 ABC
</div>
</div>
</div>
<div
class="c2 css-zhpkf8"
>
<div
class="c3 c4"
>
<div
class="c7"
>
<div>
<div
class="c2 c8 css-zhpkf8"
cursor="help"
>
Network fee
</div>
</div>
</div>
<div
class="c2 c6 css-zhpkf8"
>
~$1.00
</div>
</div>
</div>
<div
class="c2 css-zhpkf8"
>
<div
class="c3 c4"
>
<div
class="c7"
>
<div>
<div
class="c2 c8 css-zhpkf8"
cursor="help"
>
Price impact
</div>
</div>
</div>
<div
class="c6 css-zhpkf8"
>
105566.373%
</div>
</div>
</div>
<div
class="c2 css-zhpkf8"
>
<div
class="c3 c4"
>
<div
class="c7"
>
<div>
<div
class="c2 c8 css-zhpkf8"
cursor="help"
>
Minimum received
</div>
</div>
</div>
<div
class="c2 c6 css-zhpkf8"
>
0.00000000000000098 DEF
</div>
</div>
</div>
</div>
.c0 {
box-sizing: border-box;
margin: 0;
@@ -58,6 +223,10 @@ exports[`SwapModalFooter.tsx renders with a disabled button with no account 1`]
margin: !important;
}
.c7 {
color: #F5F6FC;
}
.c4 {
padding: 16px;
width: 100%;
@@ -146,106 +315,24 @@ exports[`SwapModalFooter.tsx renders with a disabled button with no account 1`]
}
.c6 {
background-color: rgba(250,43,57,0.1);
border-radius: 1rem;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
font-size: 0.825rem;
width: 100%;
padding: 3rem 1.25rem 1rem 1rem;
margin-top: -2rem;
color: #FA2B39;
z-index: -1;
}
.c6 p {
padding: 0;
margin: 0;
font-weight: 500;
}
.c7 {
background-color: rgba(250,43,57,0.1);
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
margin-right: 12px;
border-radius: 12px;
min-width: 48px;
height: 48px;
height: 56px;
margin-top: 10px;
}
<div
class="c0 c1 c2"
>
<button
class="c3 c4 c5"
class="c3 c4 c5 c6"
data-testid="confirm-swap-button"
disabled=""
id="confirm-swap-or-send"
style="margin: 10px 0px 0px 0px;"
>
<div
class="css-10ob8xa"
class="c7 css-iapcxi"
>
Confirm Swap
Swap
</div>
</button>
<div
class="c6"
>
<div
class="c7"
>
<svg
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
/>
<line
x1="12"
x2="12"
y1="9"
y2="13"
/>
<line
x1="12"
x2="12.01"
y1="17"
y2="17"
/>
</svg>
</div>
<p
style="word-break: break-word;"
>
swap error
</p>
</div>
</div>
</DocumentFragment>
`;

View File

@@ -1,21 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact input 1`] = `
exports[`SwapModalHeader.tsx matches base snapshot, test trade exact input 1`] = `
<DocumentFragment>
.c1 {
box-sizing: border-box;
margin: 0;
min-width: 0;
padding: 0.75rem 1rem;
}
.c5 {
.c3 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c6 {
.c4 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
@@ -26,99 +19,30 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
}
.c8 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 0;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
gap: 0px;
}
.c16 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 0;
-webkit-align-items: flex-end;
-webkit-box-align: flex-end;
-ms-flex-align: flex-end;
align-items: flex-end;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
}
.c7 {
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
gap: 12px;
}
.c9 {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
margin: -0px;
}
.c22 {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c18 {
color: #0D111C;
}
.c24 {
.c6 {
color: #7780A0;
}
.c21 {
.c8 {
color: #0D111C;
}
.c12 {
width: 100%;
height: 1px;
border-width: 0;
margin: 0;
background-color: #D2D9EE;
}
.c2 {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 16px;
}
.c19 {
width: 100%;
padding: 1rem;
border-radius: 16px;
}
.c3 {
border: 1px solid #E8ECFB;
background-color: #F5F6FC;
}
.c20 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
@@ -130,63 +54,39 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
gap: 12px;
gap: 24px;
}
.c5 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
gap: 4px;
}
.c0 {
display: grid;
grid-auto-rows: auto;
grid-row-gap: 4px;
}
.c4 {
display: grid;
grid-auto-rows: auto;
grid-row-gap: 8px;
}
.c25 {
display: grid;
grid-auto-rows: auto;
grid-row-gap: 8px;
justify-items: flex-start;
}
.c13 {
border-radius: 12px;
border-radius: 12px;
height: 24px;
width: 50%;
width: 50%;
-webkit-animation: fAQEyV 1.5s infinite;
animation: fAQEyV 1.5s infinite;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
background: linear-gradient( to left,#E8ECFB 25%,#fff 50%,#E8ECFB 75% );
will-change: background-position;
background-size: 400%;
}
.c23 {
display: inline-block;
height: inherit;
}
.c14 {
border-radius: 4px;
width: 4rem;
height: 1rem;
}
.c12 {
width: 20px;
height: 20px;
.c11 {
width: 36px;
height: 36px;
border-radius: 50%;
background: radial-gradient(white 60%,#ffffff00 calc(70% + 1px));
box-shadow: 0 0 1px white;
}
.c11 {
.c10 {
position: relative;
display: -webkit-box;
display: -webkit-flex;
@@ -194,342 +94,334 @@ exports[`SwapModalHeader.tsx matches base snapshot for test trade with exact inp
display: flex;
}
.c17 {
background-color: transparent;
border: none;
cursor: pointer;
.c7 {
display: inline-block;
height: inherit;
}
.c9 {
cursor: help;
color: #7780A0;
margin-right: 8px;
}
.c13 {
margin: 16px 2px 24px 2px;
}
.c1 {
margin-top: 16px;
}
<div
class="c0 c1"
>
<div
class="c2"
>
<div
class="c3 c4"
>
<div
class="c5"
>
<div
class="c6 css-1jljtub"
>
<div
class="c7"
>
<div>
<div
class="c8 c9 css-zhpkf8"
cursor="help"
>
You pay
</div>
</div>
</div>
</div>
<div
class="c5"
>
<div
class="c8 css-xdrz3"
data-testid="INPUT-amount"
>
&lt;0.00001 ABC
</div>
</div>
</div>
<div
class="c10"
>
<img
alt="ABC logo"
class="c11"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000001/logo.png"
/>
</div>
</div>
<div
class="c3 c4"
>
<div
class="c5"
>
<div
class="c6 css-1jljtub"
>
<div
class="c7"
>
<div>
<div
class="c8 c9 css-zhpkf8"
cursor="help"
>
You receive
</div>
</div>
</div>
</div>
<div
class="c5"
>
<div
class="c8 css-xdrz3"
data-testid="OUTPUT-amount"
>
&lt;0.00001 DEF
</div>
</div>
</div>
<div
class="c10"
>
<img
alt="DEF logo"
class="c11"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000002/logo.png"
/>
</div>
</div>
</div>
<div
class="c12 c13"
/>
</div>
</DocumentFragment>
`;
exports[`SwapModalHeader.tsx test trade exact output, no recipient 1`] = `
<DocumentFragment>
.c3 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c4 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 0;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
gap: 12px;
}
.c6 {
color: #7780A0;
}
.c8 {
color: #0D111C;
}
.c12 {
width: 100%;
height: 1px;
border-width: 0;
margin: 0;
background-color: #D2D9EE;
}
.c2 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
padding: 0;
grid-template-columns: 1fr auto;
grid-gap: 0.25rem;
gap: 24px;
}
.c5 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
text-align: left;
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
gap: 4px;
}
.c0 {
display: grid;
grid-auto-rows: auto;
grid-row-gap: 8px;
}
.c11 {
width: 36px;
height: 36px;
border-radius: 50%;
background: radial-gradient(white 60%,#ffffff00 calc(70% + 1px));
box-shadow: 0 0 1px white;
}
.c10 {
text-overflow: ellipsis;
max-width: 220px;
overflow: hidden;
text-align: right;
}
.c15 {
padding: 4px;
border-radius: 12px;
height: 40px;
width: 40px;
position: relative;
margin-top: -18px;
margin-bottom: -18px;
left: calc(50% - 16px);
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
background-color: #FFFFFF;
border: 4px solid;
border-color: #F5F6FC;
z-index: 2;
}
.c7 {
display: inline-block;
height: inherit;
}
.c9 {
cursor: help;
color: #7780A0;
margin-right: 8px;
}
.c13 {
margin: 16px 2px 24px 2px;
}
.c1 {
margin-top: 16px;
}
<div
class="c0"
style="margin-top: 1rem;"
class="c0 c1"
>
<div
class="c1 c2 c3"
class="c2"
>
<div
class="c4"
class="c3 c4"
>
<div
class="c5 c6 c7"
class="c5"
>
<div
class="c5 c8 c9"
class="c6 css-1jljtub"
>
<div
class="c10 css-13xjr5l"
data-testid="input-amount"
>
0.000000000000001
</div>
</div>
<div
class="c5 c8 c9"
>
<div
class="c11"
style="margin-right: 12px;"
>
<img
alt="ABC logo"
class="c12"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000001/logo.png"
/>
</div>
<div
class="css-10ob8xa"
data-testid="input-symbol"
>
ABC
</div>
</div>
</div>
<div
class="c5 c6 c7"
>
<div
class="css-zhpkf8"
>
<div
class="c13 c14"
/>
</div>
</div>
</div>
</div>
<div
class="c15"
>
<svg
fill="none"
height="16"
stroke="#0D111C"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="12"
x2="12"
y1="5"
y2="19"
/>
<polyline
points="19 12 12 19 5 12"
/>
</svg>
</div>
<div
class="c1 c2 c3"
style="margin-bottom: 0.25rem;"
>
<div
class="c4"
>
<div
class="c5 c16 c7"
>
<div
class="c5 c8 c9"
>
<div
class="c10 css-1kwqs79"
data-testid="output-amount"
>
0.000000000000001
</div>
</div>
<div
class="c5 c8 c9"
>
<div
class="c11"
style="margin-right: 12px;"
>
<img
alt="DEF logo"
class="c12"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000002/logo.png"
/>
</div>
<div
class="css-10ob8xa"
data-testid="output-symbol"
>
DEF
</div>
</div>
</div>
<div
class="c5 c6 c7"
>
<div
class="css-zhpkf8"
>
<div
class="css-zhpkf8"
>
<div
class="c13 c14"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="c5 c6 c7"
style="margin-top: 0.25rem; padding: 0px 1rem;"
>
<button
class="c17"
title="1 DEF = 1.00 ABC "
>
<div
class="c18 css-zhpkf8"
>
1 DEF = 1.00 ABC
</div>
</button>
</div>
<div
class="c5 c19 c3"
style="padding: .75rem; margin-top: 0.5rem;"
>
<div
class="c20"
>
<div
class="c21"
/>
<div
class="c5 c6 c7"
>
<div
class="c5 c6 c22"
>
<div
class="c23"
class="c7"
>
<div>
<div
class="c24 css-zhpkf8"
class="c8 c9 css-zhpkf8"
cursor="help"
>
Minimum output
You pay
</div>
</div>
</div>
</div>
<div
class="c18 css-zhpkf8"
class="c5"
>
0.00000000000000098 DEF
<div
class="c8 css-xdrz3"
data-testid="INPUT-amount"
>
&lt;0.00001 ABC
</div>
</div>
</div>
<div
class="c5 c6 c7"
class="c10"
>
<img
alt="ABC logo"
class="c11"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000001/logo.png"
/>
</div>
</div>
<div
class="c3 c4"
>
<div
class="c5"
>
<div
class="c5 c6 c22"
class="c6 css-1jljtub"
>
<div
class="c23"
class="c7"
>
<div>
<div
class="c24 css-zhpkf8"
class="c8 c9 css-zhpkf8"
cursor="help"
>
Expected output
You receive
</div>
</div>
</div>
</div>
<div
class="c18 css-zhpkf8"
class="c5"
>
0.000000000000001 DEF
</div>
</div>
<div
class="c21"
/>
<div
class="c5 c6 c7"
>
<div
class="c24 css-zhpkf8"
>
Order routing
</div>
<div
class="c23"
>
<div>
<div
class="c18 css-zhpkf8"
>
Uniswap API
</div>
<div
class="c8 css-xdrz3"
data-testid="OUTPUT-amount"
>
&lt;0.00001 GHI
</div>
</div>
</div>
</div>
</div>
<div
class="c25"
style="padding: .75rem 1rem;"
>
<div
class="c24 css-k51stg"
style="width: 100%;"
>
Output is estimated. You will receive at least
<b>
0.00000000000000098 DEF
</b>
or the transaction will revert.
</div>
</div>
<div
class="c25"
data-testid="recipient-info"
style="padding: 12px 0px 0px 0px;"
>
<div
class="c24 css-8mokm4"
>
Output will be sent to
<b
title="0x0000000000000000000000000000000000000004"
<div
class="c10"
>
0x0000...0004
</b>
<img
alt="GHI logo"
class="c11"
src="https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0x0000000000000000000000000000000000000003/logo.png"
/>
</div>
</div>
</div>
<div
class="c12 c13"
/>
</div>
</DocumentFragment>
`;

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

@@ -2,7 +2,6 @@ import { SupportedChainId } from 'constants/chains'
import { transparentize } from 'polished'
import { ReactNode } from 'react'
import { AlertTriangle } from 'react-feather'
import { Text } from 'rebass'
import styled, { css } from 'styled-components/macro'
import { Z_INDEX } from 'theme/zIndex'
@@ -29,6 +28,7 @@ export const SwapWrapper = styled.main<{ chainId: number | undefined }>`
border-radius: 16px;
border: 1px solid ${({ theme }) => theme.backgroundOutline};
padding: 8px;
padding-top: 12px;
box-shadow: ${({ chainId }) => !!chainId && chainId === SupportedChainId.BNB && '0px 40px 120px 0px #f0b90b29'};
z-index: ${Z_INDEX.default};
transition: transform 250ms ease;
@@ -63,13 +63,6 @@ export const ArrowWrapper = styled.div<{ clickable: boolean }>`
: null}
`
export const TruncatedText = styled(Text)`
text-overflow: ellipsis;
max-width: 220px;
overflow: hidden;
text-align: right;
`
// styles
export const Dots = styled.span`
&::after {
@@ -135,7 +128,7 @@ export function SwapCallbackError({ error }: { error: ReactNode }) {
export const SwapShowAcceptChanges = styled(AutoColumn)`
background-color: ${({ theme }) => transparentize(0.95, theme.deprecated_primary3)};
color: ${({ theme }) => theme.accentAction};
padding: 0.5rem;
padding: 12px;
border-radius: 12px;
margin-top: 8px;
`

View File

@@ -46,16 +46,16 @@ function useTryActivation() {
onSuccess()
} catch (error) {
// TODO(WEB-3162): re-add special treatment for already-pending injected errors & move debug to after didUserReject() check
console.debug(`Connection failed: ${connection.getName()}`)
console.error(error)
// Gracefully handles errors from the user rejecting a connection attempt
if (didUserReject(connection, error)) {
setActivationState(IDLE_ACTIVATION_STATE)
return
}
// TODO(WEB-3162): re-add special treatment for already-pending injected errors & move debug to after didUserReject() check
console.debug(`Connection failed: ${connection.getName()}`)
console.error(error)
// Failed Connection events are logged here, while successful ones are logged by Web3Provider
sendAnalyticsEvent(InterfaceEventName.WALLET_CONNECT_TXN_COMPLETED, {
result: WalletConnectionResult.FAILED,

View File

@@ -113,3 +113,7 @@ export const L2_CHAIN_IDS = [
] as const
export type SupportedL2ChainId = typeof L2_CHAIN_IDS[number]
export function isPolygonChain(chainId: number): chainId is SupportedChainId.POLYGON | SupportedChainId.POLYGON_MUMBAI {
return chainId === SupportedChainId.POLYGON || chainId === SupportedChainId.POLYGON_MUMBAI
}

View File

@@ -359,6 +359,14 @@ export const UNI: { [chainId: number]: Token } = {
[SupportedChainId.GOERLI]: new Token(SupportedChainId.GOERLI, UNI_ADDRESS[5], 18, 'UNI', 'Uniswap'),
}
export const ARB = new Token(
SupportedChainId.ARBITRUM_ONE,
'0x912CE59144191C1204E64559FE8253a0e49E6548',
18,
'ARB',
'Arbitrum'
)
export const WRAPPED_NATIVE_CURRENCY: { [chainId: number]: Token | undefined } = {
...(WETH9 as Record<SupportedChainId, Token>),
[SupportedChainId.OPTIMISM]: new Token(

View File

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

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,12 @@
import { BaseVariant, FeatureFlag, useBaseFlag } from '../index'
export function useUnifiedRoutingAPIFlag(): BaseVariant {
return useBaseFlag(FeatureFlag.uraEnabled)
}
// eslint-disable-next-line import/no-unused-modules
export function useUnifiedRoutingAPIEnabled(): boolean {
return useUnifiedRoutingAPIFlag() === BaseVariant.Enabled
}
export { BaseVariant as UnifiedRouterVariant }

View File

@@ -1,6 +1,7 @@
import { WRAPPED_NATIVE_CURRENCY } from 'constants/tokens'
import { ARB, WRAPPED_NATIVE_CURRENCY } from 'constants/tokens'
import gql from 'graphql-tag'
import { useMemo } from 'react'
import invariant from 'tiny-invariant'
import { Chain, SearchTokensQuery, useSearchTokensQuery } from './__generated__/types-and-hooks'
import { chainIdToBackendName } from './util'
@@ -41,19 +42,30 @@ gql`
}
`
const ARB_ADDRESS = ARB.address.toLowerCase()
export type SearchToken = NonNullable<NonNullable<SearchTokensQuery['searchTokens']>[number]>
function isMoreRevelantToken(current: SearchToken, existing: SearchToken | undefined, searchChain: Chain) {
if (!existing) return true
/* Returns the more relevant cross-chain token based on native status and search chain */
function dedupeCrosschainTokens(current: SearchToken, existing: SearchToken | undefined, searchChain: Chain) {
if (!existing) return current
invariant(current.project?.id === existing.project?.id, 'Cannot dedupe tokens within different tokenProjects')
// Always priotize natives, and if both tokens are native, prefer native on current chain (i.e. Matic on Polygon over Matic on Mainnet )
if (current.standard === 'NATIVE' && (existing.standard !== 'NATIVE' || current.chain === searchChain)) return true
// Special case: always prefer Arbitrum ARB over Mainnet ARB
if (current.address?.toLowerCase() === ARB_ADDRESS) return current
if (existing.address?.toLowerCase() === ARB_ADDRESS) return existing
// Always prioritize natives, and if both tokens are native, prefer native on current chain (i.e. Matic on Polygon over Matic on Mainnet )
if (current.standard === 'NATIVE' && (existing.standard !== 'NATIVE' || current.chain === searchChain)) return current
// Prefer tokens on the searched chain, otherwise prefer mainnet tokens
return current.chain === searchChain || (existing.chain !== searchChain && current.chain === Chain.Ethereum)
if (current.chain === searchChain || (existing.chain !== searchChain && current.chain === Chain.Ethereum))
return current
return existing
}
// Places natives first, wrapped native on current chain next, then sorts by volume
/* Places natives first, wrapped native on current chain next, then sorts by volume */
function searchTokenSortFunction(
searchChain: Chain,
wrappedNativeAddress: string | undefined,
@@ -87,7 +99,7 @@ export function useSearchTokens(searchQuery: string, chainId: number) {
data?.searchTokens?.forEach((token) => {
if (token.project?.id) {
const existing = selectionMap[token.project.id]
if (isMoreRevelantToken(token, existing, searchChain)) selectionMap[token.project.id] = token
selectionMap[token.project.id] = dedupeCrosschainTokens(token, existing, searchChain)
}
})
return Object.values(selectionMap).sort(

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

@@ -11,7 +11,7 @@ import { useMemo } from 'react'
import { InterfaceTrade } from 'state/routing/types'
import useGasPrice from './useGasPrice'
import useStablecoinPrice, { useStablecoinValue } from './useStablecoinPrice'
import useStablecoinPrice, { useStablecoinAmountFromFiatValue, useStablecoinValue } from './useStablecoinPrice'
const DEFAULT_AUTO_SLIPPAGE = new Percent(1, 1000) // .10%
@@ -72,15 +72,14 @@ const MAX_AUTO_SLIPPAGE_TOLERANCE = new Percent(5, 100) // 5%
/**
* Returns slippage tolerance based on values from current trade, gas estimates from api, and active network.
*/
export default function useAutoSlippageTolerance(
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined
): Percent {
export default function useAutoSlippageTolerance(trade?: InterfaceTrade): Percent {
const { chainId } = useWeb3React()
const onL2 = chainId && L2_CHAIN_IDS.includes(chainId)
const outputDollarValue = useStablecoinValue(trade?.outputAmount)
const nativeGasPrice = useGasPrice()
const gasEstimate = guesstimateGas(trade)
const gasEstimateUSD = useStablecoinAmountFromFiatValue(trade?.gasUseEstimateUSD) ?? null
const nativeCurrency = useNativeCurrency(chainId)
const nativeCurrencyPrice = useStablecoinPrice((trade && nativeCurrency) ?? undefined)
@@ -100,9 +99,7 @@ export default function useAutoSlippageTolerance(
// NOTE - dont use gas estimate for L2s yet - need to verify accuracy
// if not, use local heuristic
const dollarCostToUse =
chainId && SUPPORTED_GAS_ESTIMATE_CHAIN_IDS.includes(chainId) && trade?.gasUseEstimateUSD
? trade.gasUseEstimateUSD
: dollarGasCost
chainId && SUPPORTED_GAS_ESTIMATE_CHAIN_IDS.includes(chainId) && gasEstimateUSD ? gasEstimateUSD : dollarGasCost
if (outputDollarValue && dollarCostToUse) {
// optimize for highest possible slippage without getting MEV'd
@@ -121,5 +118,15 @@ export default function useAutoSlippageTolerance(
}
return DEFAULT_AUTO_SLIPPAGE
}, [trade, onL2, nativeGasPrice, gasEstimate, nativeCurrency, nativeCurrencyPrice, chainId, outputDollarValue])
}, [
trade,
onL2,
nativeGasPrice,
gasEstimate,
nativeCurrency,
nativeCurrencyPrice,
chainId,
gasEstimateUSD,
outputDollarValue,
])
}

View File

@@ -83,15 +83,6 @@ describe('#useBestV3Trade ExactIn', () => {
expect(useClientSideV3Trade).toHaveBeenCalledWith(TradeType.EXACT_INPUT, undefined, undefined)
expect(result.current).toEqual({ state: TradeState.VALID, trade: undefined })
})
it('does not compute client side v3 trade if routing api is SYNCING', () => {
expectRouterMock(TradeState.SYNCING)
const { result } = renderHook(() => useBestTrade(TradeType.EXACT_INPUT, USDCAmount, DAI))
expect(useClientSideV3Trade).toHaveBeenCalledWith(TradeType.EXACT_INPUT, undefined, undefined)
expect(result.current).toEqual({ state: TradeState.SYNCING, trade: undefined })
})
})
describe('when routing api is in error state', () => {
@@ -167,15 +158,6 @@ describe('#useBestV3Trade ExactOut', () => {
expect(useClientSideV3Trade).toHaveBeenCalledWith(TradeType.EXACT_OUTPUT, undefined, undefined)
expect(result.current).toEqual({ state: TradeState.VALID, trade: undefined })
})
it('does not compute client side v3 trade if routing api is SYNCING', () => {
expectRouterMock(TradeState.SYNCING)
const { result } = renderHook(() => useBestTrade(TradeType.EXACT_OUTPUT, DAIAmount, USDC_MAINNET))
expect(useClientSideV3Trade).toHaveBeenCalledWith(TradeType.EXACT_OUTPUT, undefined, undefined)
expect(result.current).toEqual({ state: TradeState.SYNCING, trade: undefined })
})
})
describe('when routing api is in error state', () => {

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