Compare commits

...

52 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
194 changed files with 43835 additions and 20903 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',
},
},
{

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

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

@@ -10,6 +10,17 @@ jobs:
environment:
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

@@ -10,13 +10,24 @@ jobs:
environment:
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
@@ -70,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

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
@@ -30,10 +32,12 @@ jobs:
uses: ./.github/actions/report
with:
name: Lint
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
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
@@ -48,10 +52,12 @@ jobs:
uses: ./.github/actions/report
with:
name: Typecheck
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
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
@@ -60,10 +66,12 @@ jobs:
uses: ./.github/actions/report
with:
name: Dependency checks
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
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
@@ -84,7 +92,7 @@ jobs:
uses: ./.github/actions/report
with:
name: Unit tests
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
build-e2e:
runs-on: ubuntu-latest
@@ -115,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
@@ -127,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
@@ -140,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:
@@ -156,17 +177,17 @@ 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()
needs: [cypress-test-matrix]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- if: needs.cypress-test-matrix.result != 'success'
run: exit 1
- if: failure() && github.ref_name == 'main'
uses: ./.github/actions/report
with:
name: Cypress tests
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_TEST_REPORTER_WEBHOOK }}

View File

@@ -84,8 +84,11 @@ module.exports = {
},
webpack: {
plugins: [
// react-markdown requires path to be global, and Webpack 5 does polyfill node globals, so we polyfill it.
new ProvidePlugin({ process: 'process/browser' }),
// 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.
@@ -140,8 +143,11 @@ module.exports = {
return plugin
}),
// react-markdown requires path to be importable, and Webpack 5 does resolve node globals, so we resolve it.
fallback: { path: require.resolve('path-browserify') },
// 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):

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

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

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,7 +82,24 @@ 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
@@ -90,16 +107,12 @@ function combineActivities(localMap: ActivityMap = {}, remoteMap: ActivityMap =
const localActivity = (localMap?.[hash] ?? {}) as Activity
const remoteActivity = (remoteMap?.[hash] ?? {}) as Activity
// Check for nonce collision
const isNonceCollision =
localActivity.nonce !== undefined &&
Object.keys(remoteMap).some((remoteHash) => remoteMap[remoteHash]?.nonce === localActivity.nonce)
// TODO(WEB-2064): Display cancelled status in UI rather than completely hiding cancelled TXs
if (wasTxCancelled(localActivity, remoteMap, account)) return acc
if (!isNonceCollision) {
// TODO(cartcrom): determine best logic for which fields to prefer from which sources
// i.e.prefer remote exact swap output instead of local estimated output
acc.push({ ...localActivity, ...remoteActivity } as Activity)
}
// 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
}, [])
@@ -132,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

@@ -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,6 +1,7 @@
import { BaseVariant, FeatureFlag, featureFlagSettings, useUpdateFlag } from 'featureFlags'
import { DetailsV2Variant, useDetailsV2Flag } from 'featureFlags/flags/nftDetails'
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'
@@ -207,6 +208,12 @@ export default function FeatureFlagModal() {
featureFlag={FeatureFlag.detailsV2}
label="Use the new details page for nfts"
/>
<FeatureFlagOption
variant={UnifiedRouterVariant}
value={useUnifiedRoutingAPIFlag()}
featureFlag={FeatureFlag.uraEnabled}
label="Enable the Unified Routing API"
/>
<FeatureFlagGroup name="Debug">
<FeatureFlagOption
variant={TraceJsonRpcVariant}

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

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

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

@@ -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,4 +6,5 @@ export enum FeatureFlag {
permit2 = 'permit2',
fiatOnRampButtonOnSwap = 'fiat_on_ramp_button_on_swap_page',
detailsV2 = 'details_v2',
uraEnabled = 'ura_enabled',
}

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

@@ -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', () => {

View File

@@ -23,7 +23,7 @@ export function useBestTrade(
otherCurrency?: Currency
): {
state: TradeState
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined
trade?: InterfaceTrade
} {
const { chainId } = useWeb3React()
const autoRouterSupported = useAutoRouterSupported()

View File

@@ -5,7 +5,7 @@ import { SupportedChainId } from 'constants/chains'
import JSBI from 'jsbi'
import { useSingleContractWithCallData } from 'lib/hooks/multicall'
import { useMemo } from 'react'
import { InterfaceTrade, TradeState } from 'state/routing/types'
import { ClassicTrade, InterfaceTrade, TradeState } from 'state/routing/types'
import { isCelo } from '../constants/tokens'
import { useAllV3Routes } from './useAllV3Routes'
@@ -33,7 +33,7 @@ export function useClientSideV3Trade<TTradeType extends TradeType>(
tradeType: TTradeType,
amountSpecified?: CurrencyAmount<Currency>,
otherCurrency?: Currency
): { state: TradeState; trade: InterfaceTrade<Currency, Currency, TTradeType> | undefined } {
): { state: TradeState; trade: InterfaceTrade | undefined } {
const [currencyIn, currencyOut] =
tradeType === TradeType.EXACT_INPUT
? [amountSpecified?.currency, otherCurrency]
@@ -135,7 +135,7 @@ export function useClientSideV3Trade<TTradeType extends TradeType>(
return {
state: TradeState.VALID,
trade: new InterfaceTrade({
trade: new ClassicTrade({
v2Routes: [],
v3Routes: [
{

View File

@@ -41,8 +41,8 @@ function useETHValue(currencyAmount?: CurrencyAmount<Currency>): {
}
}
if (!trade || !currencyAmount?.currency || !isGqlSupportedChain(chainId)) {
return { data: undefined, isLoading: state === TradeState.LOADING || state === TradeState.SYNCING }
if (!trade || state === TradeState.LOADING || !currencyAmount?.currency || !isGqlSupportedChain(chainId)) {
return { data: undefined, isLoading: state === TradeState.LOADING }
}
const { numerator, denominator } = trade.routes[0].midPrice

View File

@@ -3,8 +3,10 @@ import { BigintIsh, CurrencyAmount, Token, TradeType } from '@uniswap/sdk-core'
// eslint-disable-next-line no-restricted-imports
import { AlphaRouter, AlphaRouterConfig, ChainId } from '@uniswap/smart-order-router'
import { SupportedChainId } from 'constants/chains'
import { nativeOnChain } from 'constants/tokens'
import JSBI from 'jsbi'
import { GetQuoteResult } from 'state/routing/types'
import { GetQuoteArgs } from 'state/routing/slice'
import { QuoteResult, QuoteState, SwapRouterNativeAssets } from 'state/routing/types'
import { transformSwapRouteToGetQuoteResult } from 'utils/transformSwapRouteToGetQuoteResult'
export function toSupportedChainId(chainId: ChainId): SupportedChainId | undefined {
@@ -19,50 +21,41 @@ export function isSupportedChainId(chainId: ChainId | undefined): boolean {
async function getQuote(
{
type,
tradeType,
tokenIn,
tokenOut,
amount: amountRaw,
}: {
type: 'exactIn' | 'exactOut'
tradeType: TradeType
tokenIn: { address: string; chainId: number; decimals: number; symbol?: string }
tokenOut: { address: string; chainId: number; decimals: number; symbol?: string }
amount: BigintIsh
},
router: AlphaRouter,
config: Partial<AlphaRouterConfig>
): Promise<{ data: GetQuoteResult; error?: unknown }> {
const currencyIn = new Token(tokenIn.chainId, tokenIn.address, tokenIn.decimals, tokenIn.symbol)
const currencyOut = new Token(tokenOut.chainId, tokenOut.address, tokenOut.decimals, tokenOut.symbol)
routerConfig: Partial<AlphaRouterConfig>
): Promise<QuoteResult> {
const tokenInIsNative = Object.values(SwapRouterNativeAssets).includes(tokenIn.address as SwapRouterNativeAssets)
const tokenOutIsNative = Object.values(SwapRouterNativeAssets).includes(tokenOut.address as SwapRouterNativeAssets)
const currencyIn = tokenInIsNative
? nativeOnChain(tokenIn.chainId)
: new Token(tokenIn.chainId, tokenIn.address, tokenIn.decimals, tokenIn.symbol)
const currencyOut = tokenOutIsNative
? nativeOnChain(tokenOut.chainId)
: new Token(tokenOut.chainId, tokenOut.address, tokenOut.decimals, tokenOut.symbol)
const baseCurrency = tradeType === TradeType.EXACT_INPUT ? currencyIn : currencyOut
const quoteCurrency = tradeType === TradeType.EXACT_INPUT ? currencyOut : currencyIn
const baseCurrency = type === 'exactIn' ? currencyIn : currencyOut
const quoteCurrency = type === 'exactIn' ? currencyOut : currencyIn
const amount = CurrencyAmount.fromRawAmount(baseCurrency, JSBI.BigInt(amountRaw))
// TODO (WEB-2055): explore initializing client side routing on first load (when amountRaw is null) if there are enough users using client-side router preference.
const swapRoute = await router.route(amount, quoteCurrency, tradeType, /*swapConfig=*/ undefined, routerConfig)
const swapRoute = await router.route(
amount,
quoteCurrency,
type === 'exactIn' ? TradeType.EXACT_INPUT : TradeType.EXACT_OUTPUT,
/*swapConfig=*/ undefined,
config
)
if (!swapRoute) {
return { state: QuoteState.NOT_FOUND }
}
if (!swapRoute) throw new Error('Failed to generate client side quote')
return { data: transformSwapRouteToGetQuoteResult(type, amount, swapRoute) }
}
interface QuoteArguments {
tokenInAddress: string
tokenInChainId: ChainId
tokenInDecimals: number
tokenInSymbol?: string
tokenOutAddress: string
tokenOutChainId: ChainId
tokenOutDecimals: number
tokenOutSymbol?: string
amount: string
type: 'exactIn' | 'exactOut'
return transformSwapRouteToGetQuoteResult(tradeType, amount, swapRoute)
}
export async function getClientSideQuote(
@@ -76,14 +69,14 @@ export async function getClientSideQuote(
tokenOutDecimals,
tokenOutSymbol,
amount,
type,
}: QuoteArguments,
tradeType,
}: GetQuoteArgs,
router: AlphaRouter,
config: Partial<AlphaRouterConfig>
) {
return getQuote(
{
type,
tradeType,
tokenIn: {
address: tokenInAddress,
chainId: tokenInChainId,

View File

@@ -1,6 +1,7 @@
import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { useMemo } from 'react'
import { INTERNAL_ROUTER_PREFERENCE_PRICE, RouterPreference } from 'state/routing/slice'
import { currencyAddressForSwapQuote } from 'state/routing/utils'
/**
* Returns query arguments for the Routing API query or undefined if the
@@ -26,16 +27,16 @@ export function useRoutingAPIArguments({
? undefined
: {
amount: amount.quotient.toString(),
tokenInAddress: tokenIn.wrapped.address,
tokenInAddress: currencyAddressForSwapQuote(tokenIn),
tokenInChainId: tokenIn.wrapped.chainId,
tokenInDecimals: tokenIn.wrapped.decimals,
tokenInSymbol: tokenIn.wrapped.symbol,
tokenOutAddress: tokenOut.wrapped.address,
tokenOutAddress: currencyAddressForSwapQuote(tokenOut),
tokenOutChainId: tokenOut.wrapped.chainId,
tokenOutDecimals: tokenOut.wrapped.decimals,
tokenOutSymbol: tokenOut.wrapped.symbol,
routerPreference,
type: (tradeType === TradeType.EXACT_INPUT ? 'exactIn' : 'exactOut') as 'exactIn' | 'exactOut',
tradeType,
},
[amount, routerPreference, tokenIn, tokenOut, tradeType]
)

View File

@@ -35,6 +35,7 @@ import {
} from 'make-plural/plurals'
import { PluralCategory } from 'make-plural/plurals'
import { ReactNode, useEffect } from 'react'
import { retry } from 'utils/retry'
type LocalePlural = {
[key in SupportedLocale]: (n: number | string, ord?: boolean) => PluralCategory
@@ -79,7 +80,7 @@ const plurals: LocalePlural = {
export async function dynamicActivate(locale: SupportedLocale) {
i18n.loadLocaleData(locale, { plurals: () => plurals[locale] })
try {
const catalog = await import(`locales/${locale}.js`)
const catalog = await retry(() => import(`locales/${locale}.js`))
// Bundlers will either export it as default or as a named export named default.
i18n.load(locale, catalog.messages || catalog.default.messages)
} catch (error: unknown) {

View File

@@ -39,7 +39,7 @@ export const formatSwapSignedAnalyticsEventProperties = ({
fiatValues,
txHash,
}: {
trade: InterfaceTrade<Currency, Currency, TradeType> | Trade<Currency, Currency, TradeType>
trade: InterfaceTrade | Trade<Currency, Currency, TradeType>
fiatValues: { amountIn: number | undefined; amountOut: number | undefined }
txHash: string
}) => ({
@@ -61,8 +61,7 @@ export const formatSwapSignedAnalyticsEventProperties = ({
export const formatSwapQuoteReceivedEventProperties = (
trade: Trade<Currency, Currency, TradeType>,
gasUseEstimateUSD?: CurrencyAmount<Token>,
fetchingSwapQuoteStartTime?: Date
gasUseEstimateUSD?: string
) => {
return {
token_in_symbol: trade.inputAmount.currency.symbol,
@@ -70,15 +69,12 @@ export const formatSwapQuoteReceivedEventProperties = (
token_in_address: getTokenAddress(trade.inputAmount.currency),
token_out_address: getTokenAddress(trade.outputAmount.currency),
price_impact_basis_points: trade ? formatPercentInBasisPointsNumber(computeRealizedPriceImpact(trade)) : undefined,
estimated_network_fee_usd: gasUseEstimateUSD ? formatToDecimal(gasUseEstimateUSD, 2) : undefined,
estimated_network_fee_usd: gasUseEstimateUSD,
chain_id:
trade.inputAmount.currency.chainId === trade.outputAmount.currency.chainId
? trade.inputAmount.currency.chainId
: undefined,
token_in_amount: formatToDecimal(trade.inputAmount, trade.inputAmount.currency.decimals),
token_out_amount: formatToDecimal(trade.outputAmount, trade.outputAmount.currency.decimals),
quote_latency_milliseconds: fetchingSwapQuoteStartTime
? getDurationFromDateMilliseconds(fetchingSwapQuoteStartTime)
: undefined,
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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