Compare commits

...

23 Commits

Author SHA1 Message Date
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
118 changed files with 10319 additions and 8308 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

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

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

View File

@@ -1,24 +1,11 @@
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 +30,7 @@ describe('mini-portfolio activity history', () => {
status: 'CONFIRMED',
to: '0x034a40764485f7e08ca16366e503491a6af7850d',
from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
nonce: currentNonce,
nonce,
__typename: 'Transaction',
},
assetChanges: [],
@@ -61,7 +48,7 @@ describe('mini-portfolio activity history', () => {
status: 'CONFIRMED',
to: '0x1b5154aa4b8f027b9fd19341132fc9dae10f7359',
from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
nonce: nextNonce,
nonce: nonce + 1,
__typename: 'Transaction',
},
assetChanges: [
@@ -101,33 +88,37 @@ 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', { ethereum: 'hardhat' }).hardhat({ automine: false })
// Input swap info.
cy.get('#swap-currency-input .token-amount-input').clear().type('1')
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.contains('USDC').click()
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
// Set slippage to a high value.
cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.get(getTestSelector('max-slippage-settings')).click()
cy.get(getTestSelector('slippage-input')).clear().type('5')
cy.get('body').click('topRight')
cy.get(getTestSelector('slippage-input')).should('not.exist')
// Click swap button.
cy.contains('1 USDC = ').should('exist')
cy.get('#swap-button').should('not.be', 'disabled').click()
cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
// Check activity history tab.
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('mini-portfolio-nav-activity')).click()
// Assert that the local pending transaction is replaced by a remote transaction with the same nonce.
cy.contains('Swapping').should('not.exist')
})
})

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

@@ -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,89 @@
import { BigNumber } from '@ethersproject/bignumber'
import { parseEther } from '@ethersproject/units'
import { USDC_MAINNET } from '../../../src/constants/tokens'
import { getTestSelector } from '../../utils'
describe('Swap', () => {
it('should render and dismiss the wallet rejection modal', () => {
cy.visit('/swap', { ethereum: 'hardhat' })
.hardhat()
.then((hardhat) => {
cy.stub(hardhat.wallet, 'sendTransaction').log(false).rejects(new Error('user cancelled'))
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.get(getTestSelector('token-search-input')).clear().type(USDC_MAINNET.address)
cy.contains('USDC').click()
cy.get('#swap-currency-output .token-amount-input').clear().type('1')
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.contains('Transaction rejected').should('exist')
cy.contains('Dismiss').click()
cy.contains('Transaction rejected').should('not.exist')
})
})
it.skip('should render an error for slippage failure', () => {
cy.visit('/swap', { ethereum: 'hardhat' })
.hardhat({ automine: false })
.then((hardhat) => {
cy.then(() => hardhat.provider.getBalance(hardhat.wallet.address)).then((initialBalance) => {
// Gas estimation fails for this transaction (that would normally fail), so we stub it.
const send = cy.stub(hardhat.provider, 'send').log(false)
send.withArgs('eth_estimateGas').resolves(BigNumber.from(2_000_000))
send.callThrough()
// Set slippage to a very low value.
cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.get(getTestSelector('max-slippage-settings')).click()
cy.get(getTestSelector('slippage-input')).clear().type('0.01')
cy.get('body').click('topRight')
cy.get(getTestSelector('slippage-input')).should('not.exist')
// Open the currency select modal.
cy.get('#swap-currency-output .open-currency-select-button').click()
// Select UNI as output token
cy.get(getTestSelector('token-search-input')).clear().type('Uniswap')
cy.get(getTestSelector('currency-list-wrapper'))
.contains(/^Uniswap$/)
.first()
// Our scrolling library (react-window) seems to freeze when acted on by cypress, with this element set to
// `pointer-events: none`. This can be ignored using `{force: true}`.
.click({ force: true })
// Swap 2 times.
const AMOUNT_TO_SWAP = 400
const NUMBER_OF_SWAPS = 2
const INDIVIDUAL_SWAP_INPUT = AMOUNT_TO_SWAP / NUMBER_OF_SWAPS
cy.get('#swap-currency-input .token-amount-input').clear().type(INDIVIDUAL_SWAP_INPUT.toString())
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
cy.get('#swap-currency-input .token-amount-input').clear().type(INDIVIDUAL_SWAP_INPUT.toString())
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
// The pending transaction indicator should be visible.
cy.contains('Pending').should('exist')
cy.then(() => hardhat.mine()).then(() => {
// The pending transaction indicator should not be visible.
cy.contains('Pending').should('not.exist')
// Check for a failed transaction notification.
cy.contains('Swap failed').should('exist')
// Assert that at least one of the swaps failed due to slippage.
cy.then(() => hardhat.provider.getBalance(hardhat.wallet.address)).then((finalBalance) => {
expect(finalBalance.gt(initialBalance.sub(parseEther(AMOUNT_TO_SWAP.toString())))).to.be.true
})
})
})
})
})
})

View File

@@ -0,0 +1,168 @@
import { SupportedChainId } from '@uniswap/sdk-core'
import { UNI, USDC_MAINNET } from '../../../src/constants/tokens'
import { getTestSelector } from '../../utils'
const UNI_MAINNET = UNI[SupportedChainId.MAINNET]
describe('Swap', () => {
describe('Swap on main page', () => {
before(() => {
cy.visit('/swap', { ethereum: 'hardhat' })
})
it('starts with ETH selected by default', () => {
cy.get(`#swap-currency-input .token-amount-input`).should('have.value', '')
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'ETH')
cy.get(`#swap-currency-output .token-amount-input`).should('not.have.value')
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'Select token')
})
it('can enter an amount into input', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('0.001').should('have.value', '0.001')
})
it('zero swap amount', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('0.0').should('have.value', '0.0')
})
it('invalid swap amount', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('\\').should('have.value', '')
})
it('can enter an amount into output', () => {
cy.get('#swap-currency-output .token-amount-input').clear().type('0.001').should('have.value', '0.001')
})
it('zero output amount', () => {
cy.get('#swap-currency-output .token-amount-input').clear().type('0.0').should('have.value', '0.0')
})
it('should render an error when a transaction fails due to a passed deadline', () => {
const DEADLINE_MINUTES = 1
const TEN_MINUTES_MS = 1000 * 60 * DEADLINE_MINUTES * 10
cy.visit('/swap', { ethereum: 'hardhat' })
.hardhat({ automine: false })
.then((hardhat) => {
cy.then(() => hardhat.getBalance(hardhat.wallet.address, USDC_MAINNET))
.then((balance) => Number(balance.toFixed(1)))
.then((initialBalance) => {
// Input swap info.
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.contains('USDC').click()
cy.get('#swap-currency-output .token-amount-input').clear().type('1')
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
// Set deadline to minimum. (1 minute)
cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.get(getTestSelector('transaction-deadline-settings')).click()
cy.get(getTestSelector('deadline-input')).clear().type(DEADLINE_MINUTES.toString())
cy.get('body').click('topRight')
cy.get(getTestSelector('deadline-input')).should('not.exist')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
// Dismiss the modal that appears when a transaction is broadcast to the network.
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
// The UI should show the transaction as pending.
cy.contains('1 Pending').should('exist')
// Mine a block past the deadline.
cy.then(() => hardhat.mine(1, TEN_MINUTES_MS)).then(() => {
// The UI should no longer show the transaction as pending.
cy.contains('1 Pending').should('not.exist')
// Check that the user is informed of the failure
cy.contains('Swap failed').should('exist')
// Check that the balance is unchanged in the UI
cy.get('#swap-currency-output [data-testid="balance-text"]').should(
'have.text',
`Balance: ${initialBalance}`
)
// Check that the balance is unchanged on chain
cy.then(() => hardhat.getBalance(hardhat.wallet.address, USDC_MAINNET))
.then((balance) => Number(balance.toFixed(1)))
.should('eq', initialBalance)
})
})
})
})
it('should default inputs from URL params ', () => {
cy.visit(`/swap?inputCurrency=${UNI_MAINNET.address}`, { ethereum: 'hardhat' })
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'UNI')
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'Select token')
cy.visit(`/swap?outputCurrency=${UNI_MAINNET.address}`, { ethereum: 'hardhat' })
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'Select token')
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
cy.visit(`/swap?inputCurrency=ETH&outputCurrency=${UNI_MAINNET.address}`, { ethereum: 'hardhat' })
cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'ETH')
cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'UNI')
})
it('ETH to wETH is same value (wrapped swaps have no price impact)', () => {
cy.visit('/swap')
cy.get(`#swap-currency-output .open-currency-select-button`).click()
cy.contains('WETH').click()
cy.get('#swap-currency-input .token-amount-input').clear().type('0.01')
cy.get('#swap-currency-output .token-amount-input').should('have.value', '0.01')
})
it('Opens and closes the settings menu', () => {
cy.visit('/swap')
cy.contains('Settings').should('not.exist')
cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.contains('Max slippage').should('exist')
cy.contains('Transaction deadline').should('exist')
cy.contains('Auto Router API').should('exist')
cy.get(getTestSelector('open-settings-dialog-button')).click()
cy.contains('Settings').should('not.exist')
})
it('inputs reset when navigating between pages', () => {
cy.get('#swap-currency-input .token-amount-input').clear().type('0.01')
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
cy.visit('/pool')
cy.visit('/swap')
cy.get('#swap-currency-input .token-amount-input').should('have.value', '')
cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
})
it('can swap ETH for USDC', () => {
cy.visit('/swap', { ethereum: 'hardhat' })
const TOKEN_ADDRESS = USDC_MAINNET.address
const BALANCE_INCREMENT = 1
cy.hardhat().then((hardhat) => {
cy.then(() => hardhat.getBalance(hardhat.wallet.address, USDC_MAINNET))
.then((balance) => Number(balance.toFixed(1)))
.then((initialBalance) => {
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.get(getTestSelector('token-search-input')).clear().type(TOKEN_ADDRESS)
cy.contains('USDC').click()
cy.get('#swap-currency-output .token-amount-input').clear().type(BALANCE_INCREMENT.toString())
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
cy.get('#swap-button').click()
cy.get('#confirm-swap-or-send').click()
cy.get(getTestSelector('dismiss-tx-confirmation')).click()
// ui check
cy.get('#swap-currency-output [data-testid="balance-text"]').should(
'have.text',
`Balance: ${initialBalance + BALANCE_INCREMENT}`
)
// chain state check
cy.then(() => hardhat.getBalance(hardhat.wallet.address, USDC_MAINNET))
.then((balance) => Number(balance.toFixed(1)))
.should('eq', initialBalance + BALANCE_INCREMENT)
})
})
})
})
})

View File

@@ -0,0 +1,111 @@
import { SupportedChainId, WETH9 } from '@uniswap/sdk-core'
import { getTestSelector } from '../../utils'
describe('Swap', () => {
it('should be able to wrap ETH', () => {
const BALANCE_INCREMENT = 1
cy.visit('/swap', { ethereum: 'hardhat' })
.hardhat()
.then((hardhat) => {
cy.then(() => hardhat.getBalance(hardhat.wallet.address, WETH9[SupportedChainId.MAINNET]))
.then((balance) => Number(balance.toFixed(1)))
.then((initialWethBalance) => {
// Select WETH for the token output.
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.contains('WETH').click()
// Enter the amount to wrap.
cy.get('#swap-currency-output .token-amount-input').clear().type(BALANCE_INCREMENT.toString())
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
// Click the wrap button.
cy.get(getTestSelector('wrap-button')).should('not.be.disabled')
cy.get(getTestSelector('wrap-button')).click()
// The pending transaction indicator should be visible.
cy.get(getTestSelector('web3-status-connected')).should('have.descendants', ':contains("1 Pending")')
// <automine transaction>
// The pending transaction indicator should be gone.
cy.get(getTestSelector('web3-status-connected')).should('not.have.descendants', ':contains("1 Pending")')
// The UI balance should have increased.
cy.get('#swap-currency-output [data-testid="balance-text"]').should(
'have.text',
`Balance: ${initialWethBalance + BALANCE_INCREMENT}`
)
// There should be a successful wrap notification.
cy.contains('Wrapped').should('exist')
// The user's WETH account balance should have increased
cy.then(() => hardhat.getBalance(hardhat.wallet.address, WETH9[SupportedChainId.MAINNET]))
.then((balance) => Number(balance.toFixed(1)))
.should('eq', initialWethBalance + BALANCE_INCREMENT)
})
})
})
it('should be able to unwrap WETH', () => {
const BALANCE_INCREMENT = 1
cy.visit('/swap', { ethereum: 'hardhat' })
.hardhat()
.then((hardhat) => {
cy.then(() => hardhat.getBalance(hardhat.wallet.address, WETH9[SupportedChainId.MAINNET])).then(
(initialBalance) => {
// Select WETH for the token output.
cy.get('#swap-currency-output .open-currency-select-button').click()
cy.contains('WETH').click()
// Enter the amount to wrap.
cy.get('#swap-currency-output .token-amount-input').clear().type(BALANCE_INCREMENT.toString())
cy.get('#swap-currency-input .token-amount-input').should('not.equal', '')
// Click the wrap button.
cy.get(getTestSelector('wrap-button')).should('not.be.disabled')
cy.get(getTestSelector('wrap-button')).click()
// <automine transaction>
// The pending transaction indicator should be visible.
cy.contains('1 Pending').should('exist')
// The user should see a notification telling them they successfully wrapped their ETH.
cy.contains('Wrapped').should('exist')
// Switch to unwrapping the ETH we just wrapped.
cy.get(getTestSelector('swap-currency-button')).click()
cy.get(getTestSelector('wrap-button')).should('not.be.disabled')
// Click the Unwrap button.
cy.get(getTestSelector('wrap-button')).click()
// The pending transaction indicator should be visible.
cy.contains('1 Pending').should('exist')
// <automine transaction>
// The pending transaction indicator should be gone.
cy.contains('1 Pending').should('not.exist')
// The user should see a notification telling them they successfully unwrapped their ETH.
cy.contains('Unwrapped').should('exist')
// The UI balance should have decreased.
cy.get('#swap-currency-input [data-testid="balance-text"]').should(
'have.text',
`Balance: ${initialBalance.toFixed(0)}`
)
// There should be a successful unwrap notification.
cy.contains('Unwrapped').should('exist')
// The user's WETH account balance should not have changed from the initial balance
cy.then(() => hardhat.getBalance(hardhat.wallet.address, WETH9[SupportedChainId.MAINNET]))
.then((balance) => balance.toFixed(0))
.should('eq', initialBalance.toFixed(0))
}
)
})
})
})

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

@@ -67,7 +67,7 @@
]
},
"devDependencies": {
"@craco/craco": "6.4.3",
"@craco/craco": "^7.1.0",
"@ethersproject/experimental": "^5.4.0",
"@lingui/cli": "^3.9.0",
"@testing-library/jest-dom": "^5.16.4",
@@ -104,18 +104,22 @@
"@vanilla-extract/jest-transform": "^1.1.1",
"@vanilla-extract/webpack-plugin": "^2.1.11",
"babel-plugin-istanbul": "^6.1.1",
"buffer": "^6.0.3",
"cypress": "10.3.1",
"cypress-hardhat": "^2.3.0",
"env-cmd": "^10.1.0",
"eslint": "^7.11.0",
"eslint-plugin-import": "^2.27",
"eslint-plugin-rulesdir": "^0.2.2",
"hardhat": "^2.14.0",
"jest-fail-on-console": "^3.1.1",
"jest-fetch-mock": "^3.0.3",
"jest-styled-components": "^7.0.8",
"ms.macro": "^2.0.0",
"path-browserify": "^1.0.1",
"prettier": "^2.7.1",
"react-scripts": "^4.0.3",
"process": "^0.11.10",
"react-scripts": "^5.0.1",
"resize-observer-polyfill": "^1.5.1",
"serve": "^11.3.2",
"source-map-explorer": "^2.5.3",
@@ -136,6 +140,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

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

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

View File

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

View File

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

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

@@ -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,7 +1,7 @@
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'
@@ -16,7 +16,7 @@ import RouterLabel from './RouterLabel'
import SwapRoute from './SwapRoute'
interface AdvancedSwapDetailsProps {
trade: InterfaceTrade<Currency, Currency, TradeType>
trade: InterfaceTrade
allowedSlippage: Percent
syncing?: boolean
}
@@ -60,7 +60,7 @@ 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>
)}
@@ -75,7 +75,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

@@ -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,34 @@ 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="c2 c3 c8"
>
<div
class="c6"
class="c5"
>
<div>
<div
class="c7 css-zhpkf8"
class="c6 css-zhpkf8"
>
Minimum output
</div>
@@ -97,7 +117,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 +126,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 +141,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 +153,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,35 @@ 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"
>
@@ -223,11 +276,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 +300,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 +318,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

@@ -49,7 +49,7 @@ gql`
}
description
}
listings(first: 1) {
listings(first: 25) {
edges {
node {
address

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

@@ -1,6 +1,6 @@
import { Interface } from '@ethersproject/abi'
import { BigintIsh, Currency, Token } from '@uniswap/sdk-core'
import { abi as IUniswapV3PoolStateABI } from '@uniswap/v3-core/artifacts/contracts/interfaces/pool/IUniswapV3PoolState.sol/IUniswapV3PoolState.json'
import IUniswapV3PoolStateJSON from '@uniswap/v3-core/artifacts/contracts/interfaces/pool/IUniswapV3PoolState.sol/IUniswapV3PoolState.json'
import { computePoolAddress } from '@uniswap/v3-sdk'
import { FeeAmount, Pool } from '@uniswap/v3-sdk'
import { useWeb3React } from '@web3-react/core'
@@ -11,7 +11,7 @@ import { useMemo } from 'react'
import { V3_CORE_FACTORY_ADDRESSES } from '../constants/addresses'
import { IUniswapV3PoolStateInterface } from '../types/v3/IUniswapV3PoolState'
const POOL_STATE_INTERFACE = new Interface(IUniswapV3PoolStateABI) as IUniswapV3PoolStateInterface
const POOL_STATE_INTERFACE = new Interface(IUniswapV3PoolStateJSON.abi) as IUniswapV3PoolStateInterface
// Classes are expensive to instantiate, so this caches the recently instantiated pools.
// This avoids re-instantiating pools as the other pools in the same request are loaded.

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

@@ -1,13 +1,13 @@
import { Interface } from '@ethersproject/abi'
import { Currency, CurrencyAmount } from '@uniswap/sdk-core'
import { abi as IUniswapV2PairABI } from '@uniswap/v2-core/build/IUniswapV2Pair.json'
import IUniswapV2PairJSON from '@uniswap/v2-core/build/IUniswapV2Pair.json'
import { computePairAddress, Pair } from '@uniswap/v2-sdk'
import { useMultipleContractSingleData } from 'lib/hooks/multicall'
import { useMemo } from 'react'
import { V2_FACTORY_ADDRESSES } from '../constants/addresses'
const PAIR_INTERFACE = new Interface(IUniswapV2PairABI)
const PAIR_INTERFACE = new Interface(IUniswapV2PairJSON.abi)
export enum PairState {
LOADING,

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,7 +61,7 @@ export const formatSwapSignedAnalyticsEventProperties = ({
export const formatSwapQuoteReceivedEventProperties = (
trade: Trade<Currency, Currency, TradeType>,
gasUseEstimateUSD?: CurrencyAmount<Token>,
gasUseEstimateUSD?: string,
fetchingSwapQuoteStartTime?: Date
) => {
return {
@@ -70,7 +70,7 @@ 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

View File

@@ -3,7 +3,7 @@ import { formatEther, parseEther } from '@ethersproject/units'
import { t, Trans } from '@lingui/macro'
import { sendAnalyticsEvent, TraceEvent } from '@uniswap/analytics'
import { BrowserEvent, InterfaceElementName, NFTEventName } from '@uniswap/analytics-events'
import { Currency, CurrencyAmount, Token, TradeType } from '@uniswap/sdk-core'
import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { useToggleAccountDrawer } from 'components/AccountDrawer'
import Column from 'components/Column'
@@ -208,7 +208,7 @@ const InputCurrencyValue = ({
totalEthPrice: BigNumber
activeCurrency: Currency | undefined | null
tradeState: TradeState
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined
trade: InterfaceTrade | undefined
}) => {
if (!usingPayWithAnyToken) {
return (
@@ -219,7 +219,7 @@ const InputCurrencyValue = ({
)
}
if (tradeState === TradeState.LOADING) {
if (tradeState === TradeState.LOADING && !trade) {
return (
<ThemedText.BodyPrimary color="textTertiary" lineHeight="20px" fontWeight="500">
<Trans>Fetching price...</Trans>
@@ -228,7 +228,7 @@ const InputCurrencyValue = ({
}
return (
<ValueText color={tradeState === TradeState.SYNCING ? 'textTertiary' : 'textPrimary'}>
<ValueText color={tradeState === TradeState.LOADING ? 'textTertiary' : 'textPrimary'}>
{ethNumberStandardFormatter(trade?.inputAmount.toExact())}
</ValueText>
)

View File

@@ -9,13 +9,14 @@ import { DataPageHeader } from './DataPageHeader'
import { DataPageTable } from './DataPageTable'
import { DataPageTraits } from './DataPageTraits'
const DataPageContainer = styled(Column)`
const DataPagePaddingContainer = styled.div`
padding: 24px 64px;
height: 100vh;
width: 100%;
gap: 36px;
max-width: ${({ theme }) => theme.maxWidth};
margin: 0 auto;
@media screen and (max-width: ${BREAKPOINTS.md}px) {
height: 100%;
}
@media screen and (max-width: ${BREAKPOINTS.sm}px) {
padding: 24px 48px;
@@ -26,6 +27,14 @@ const DataPageContainer = styled(Column)`
}
`
const DataPageContainer = styled(Column)`
height: 100%;
width: 100%;
gap: 36px;
max-width: ${({ theme }) => theme.maxWidth};
margin: 0 auto;
`
const ContentContainer = styled(Row)`
gap: 24px;
padding-bottom: 45px;
@@ -43,15 +52,17 @@ const LeftColumn = styled(Column)`
export const DataPage = ({ asset }: { asset: GenieAsset }) => {
return (
<DataPageContainer>
<DataPageHeader asset={asset} />
<ContentContainer>
<LeftColumn>
{!!asset.traits?.length && <DataPageTraits asset={asset} />}
<DataPageDescription />
</LeftColumn>
<DataPageTable />
</ContentContainer>
</DataPageContainer>
<DataPagePaddingContainer>
<DataPageContainer>
<DataPageHeader asset={asset} />
<ContentContainer>
<LeftColumn>
{!!asset.traits?.length && <DataPageTraits asset={asset} />}
<DataPageDescription />
</LeftColumn>
<DataPageTable asset={asset} />
</ContentContainer>
</DataPageContainer>
</DataPagePaddingContainer>
)
}

View File

@@ -0,0 +1,23 @@
import { TEST_NFT_ASSET, TEST_OFFER, TEST_SELL_ORDER } from 'test-utils/nft/fixtures'
import { render } from 'test-utils/render'
import { ListingsTableContent } from './ListingsTableContent'
import { OffersTableContent } from './OffersTableContent'
it('data page offers table content loads with a given asset', () => {
const assetWithOffer = {
...TEST_NFT_ASSET,
offers: [TEST_OFFER],
}
const { asFragment } = render(<OffersTableContent asset={assetWithOffer} />)
expect(asFragment()).toMatchSnapshot()
})
it('data page listings table content loads with a given asset', () => {
const assetWithOrder = {
...TEST_NFT_ASSET,
sellorders: [TEST_SELL_ORDER],
}
const { asFragment } = render(<ListingsTableContent asset={assetWithOrder} />)
expect(asFragment()).toMatchSnapshot()
})

View File

@@ -1,45 +1,50 @@
import { Trans } from '@lingui/macro'
import { GenieAsset } from 'nft/types'
import { useMemo } from 'react'
import { ActivityTableContent } from './ActivityTableContent'
import { ListingsTableContent } from './ListingsTableContent'
import { OffersTableContent } from './OffersTableContent'
import { Tab, TabbedComponent } from './TabbedComponent'
enum TableTabsKeys {
export enum TableTabsKeys {
Activity = 'activity',
Offers = 'offers',
Listings = 'listings',
}
const TableTabs: Map<string, Tab> = new Map([
[
TableTabsKeys.Activity,
{
title: <Trans>Activity</Trans>,
key: TableTabsKeys.Activity,
content: <ActivityTableContent />,
},
],
[
TableTabsKeys.Offers,
{
title: <Trans>Offers</Trans>,
key: TableTabsKeys.Offers,
content: <OffersTableContent />,
count: 11, // TODO Replace Placeholder with real data
},
],
[
TableTabsKeys.Listings,
{
title: <Trans>Listings</Trans>,
key: TableTabsKeys.Listings,
content: <ListingsTableContent />,
count: 11, // TODO Replace Placeholder with real data
},
],
])
export const DataPageTable = () => {
export const DataPageTable = ({ asset }: { asset: GenieAsset }) => {
const TableTabs: Map<string, Tab> = useMemo(
() =>
new Map([
[
TableTabsKeys.Activity,
{
title: <Trans>Activity</Trans>,
key: TableTabsKeys.Activity,
content: <ActivityTableContent />,
},
],
[
TableTabsKeys.Offers,
{
title: <Trans>Offers</Trans>,
key: TableTabsKeys.Offers,
content: <OffersTableContent asset={asset} />,
count: 11, // TODO Replace Placeholder with real data
},
],
[
TableTabsKeys.Listings,
{
title: <Trans>Listings</Trans>,
key: TableTabsKeys.Listings,
content: <ListingsTableContent asset={asset} />,
count: asset.sellorders?.length,
},
],
]),
[asset]
)
return <TabbedComponent tabs={TableTabs} />
}

View File

@@ -7,13 +7,13 @@ import { GenieAsset } from 'nft/types'
import { useMemo } from 'react'
import styled from 'styled-components/macro'
import { BREAKPOINTS, ThemedText } from 'theme'
import { opacify } from 'theme/utils'
import { Scrim } from './shared'
import { Tab, TabbedComponent } from './TabbedComponent'
import { TraitRow } from './TraitRow'
const TraitsHeaderContainer = styled(Row)`
padding-right: 12px;
padding: 0px 12px;
`
const TraitsHeader = styled(ThemedText.SubHeaderSmall)<{
@@ -45,26 +45,6 @@ const TraitRowScrollableContainer = styled.div`
${ScrollBarStyles}
`
// Scrim that fades out the top and bottom of the scrollable container, isBottom changes the direction and placement of the fade
const Scrim = styled.div<{ isBottom?: boolean }>`
position: absolute;
height: 88px;
left: 0px;
right: 6px;
${({ isBottom }) =>
isBottom
? 'bottom: 0px'
: `
top: 0px;
transform: matrix(1, 0, 0, -1, 0, 0);
`};
background: ${({ theme }) =>
`linear-gradient(180deg, ${opacify(0, theme.backgroundSurface)} 0%, ${theme.backgroundSurface} 100%)`};
display: flex;
`
const TraitsContent = ({ asset }: { asset: GenieAsset }) => {
const { userCanScroll, scrollRef, scrollProgress, scrollHandler } = useSubscribeScrollState()

View File

@@ -0,0 +1,201 @@
import { Trans } from '@lingui/macro'
import { useWeb3React } from '@web3-react/core'
import Column from 'components/Column'
import Row from 'components/Row'
import { Unicon } from 'components/Unicon'
import useENSAvatar from 'hooks/useENSAvatar'
import useENSName from 'hooks/useENSName'
import { useIsMobile } from 'nft/hooks'
import { GenieAsset } from 'nft/types'
import { getLinkForTrait } from 'nft/utils'
import { ReactNode, useReducer } from 'react'
import { ChevronDown, DollarSign } from 'react-feather'
import { Link } from 'react-router-dom'
import styled from 'styled-components/macro'
import { BREAKPOINTS, ClickableStyle, EllipsisStyle, ExternalLink, LinkStyle, ThemedText } from 'theme'
import { isAddress, shortenAddress } from 'utils'
import { ExplorerDataType } from 'utils/getExplorerLink'
import { getExplorerLink } from 'utils/getExplorerLink'
const StyledBubble = styled(Row)`
background-color: ${({ theme }) => theme.backgroundSurface};
padding: 10px 12px 10px 8px;
border-radius: 20px;
max-width: 144px;
@media screen and (min-width: ${BREAKPOINTS.sm}px) {
max-width: 169px;
}
`
const StyledLabelMedium = styled.div`
font-weight: 600;
font-size: 16px;
line-height: 20px;
color: ${({ theme }) => theme.textPrimary};
${EllipsisStyle}
`
const StyledIcon = styled(Row)`
width: 24px;
height: 24px;
flex-shrink: 0;
color: ${({ theme }) => theme.accentAction};
border-radius: 100%;
overflow: hidden;
justify-content: center;
align-items: center;
`
const StyledLink = styled(Link)`
${ClickableStyle}
${LinkStyle}
`
const ConditionalLinkWrapper = ({
isExternal,
href,
children,
}: {
isExternal?: boolean
href: string
children: ReactNode
}) => {
return isExternal ? (
<ExternalLink href={href}>{children}</ExternalLink>
) : (
<StyledLink to={href}>{children}</StyledLink>
)
}
const InfoBubble = ({
title,
info,
icon,
href,
isExternal,
}: {
title: ReactNode
info: string
icon: ReactNode
href: string
isExternal?: boolean
}) => {
return (
<Column gap="sm">
<ThemedText.Caption color="textSecondary">{title}</ThemedText.Caption>
<ConditionalLinkWrapper isExternal={isExternal} href={href}>
<StyledBubble gap="sm">
<StyledIcon>{icon}</StyledIcon>
<StyledLabelMedium>{info}</StyledLabelMedium>
</StyledBubble>
</ConditionalLinkWrapper>
</Column>
)
}
const InfoChipDropdown = styled.button`
padding: 10px;
background-color: ${({ theme }) => theme.backgroundSurface};
color: ${({ theme }) => theme.textSecondary};
border-radius: 100%;
border: none;
cursor: pointer;
`
const InfoChipDropdownContainer = styled(Column)`
height: 100%;
margin-top: auto;
@media screen and (min-width: ${BREAKPOINTS.sm}px) {
display: none;
}
`
const Break = styled(Column)`
flex-basis: 100%;
@media screen and (min-width: ${BREAKPOINTS.sm}px) {
display: none;
}
`
const InfoChipsContainer = styled(Row)`
gap: 4px;
width: 100%;
flex-wrap: wrap;
@media screen and (min-width: ${BREAKPOINTS.sm}px) {
gap: 12px;
flex-wrap: nowrap;
}
`
const StyledChevron = styled(ChevronDown)<{ isOpen: boolean }>`
transform: ${({ isOpen }) => (isOpen ? 'rotate(180deg)' : 'rotate(0deg)')};
will-change: transform;
transition: transform ${({ theme }) => theme.transition.duration.medium};
`
export const InfoChips = ({ asset }: { asset: GenieAsset }) => {
const { chainId } = useWeb3React()
const isMobile = useIsMobile()
const [showExtraInfoChips, toggleShowExtraInfoChips] = useReducer((s) => !s, false)
const shouldShowExtraInfoChips = !isMobile || showExtraInfoChips
const topTrait = asset?.traits?.[0]
const traitCollectionAddress = topTrait && getLinkForTrait(topTrait, asset.address)
const isChecksummedAddress = isAddress(asset.ownerAddress)
const checksummedAddress = isChecksummedAddress ? isChecksummedAddress : undefined
const { ENSName } = useENSName(checksummedAddress)
const { avatar } = useENSAvatar(checksummedAddress)
const shortenedAddress = asset.ownerAddress ? shortenAddress(asset.ownerAddress) : ''
const addressToDisplay = ENSName ?? shortenedAddress
const avatarToDisplay = avatar ? (
<img src={avatar} width={24} height={24} />
) : (
<Unicon size={24} address={asset.ownerAddress ?? ''} />
)
return (
<Column gap="sm">
<InfoChipsContainer justify="center">
<InfoBubble
title={<Trans>Owner</Trans>}
info={addressToDisplay}
icon={avatarToDisplay}
href={getExplorerLink(chainId ?? 1, asset.ownerAddress ?? '', ExplorerDataType.ADDRESS)}
isExternal={true}
/>
{traitCollectionAddress && (
<>
<InfoBubble
title={<Trans>Trait Floor</Trans>}
info="5.3 ETH"
icon={<DollarSign size={20} />}
href={traitCollectionAddress}
/>
<InfoChipDropdownContainer>
<InfoChipDropdown onClick={toggleShowExtraInfoChips}>
<StyledChevron isOpen={showExtraInfoChips} size={20} display="block" />
</InfoChipDropdown>
</InfoChipDropdownContainer>
{shouldShowExtraInfoChips && (
<>
<Break />
<InfoBubble
title={<Trans>Top Trait</Trans>}
info={topTrait.trait_value}
icon=""
href={traitCollectionAddress}
/>
</>
)}
</>
)}
</InfoChipsContainer>
</Column>
)
}

View File

@@ -5,6 +5,7 @@ import { CollectionInfoForAsset, GenieAsset } from 'nft/types'
import styled from 'styled-components/macro'
import { BREAKPOINTS } from 'theme'
import { InfoChips } from './InfoChips'
import { MediaRenderer } from './MediaRenderer'
const MAX_WIDTH = 560
@@ -117,6 +118,7 @@ export const LandingPage = ({ asset, collection }: LandingPageProps) => {
</Row>
<StyledHeadlineText>{asset.name ?? `${asset.collectionName} #${asset.tokenId}`}</StyledHeadlineText>
</InfoDetailsContainer>
<InfoChips asset={asset} />
</InfoContainer>
</LandingPageContainer>
)

View File

@@ -1,5 +1,25 @@
import { TableContentContainer } from './shared'
import { Trans } from '@lingui/macro'
import { NftStandard } from 'graphql/data/__generated__/types-and-hooks'
import { AddToBagIcon } from 'nft/components/icons'
import { useIsMobile } from 'nft/hooks'
import { GenieAsset } from 'nft/types'
import { useTheme } from 'styled-components/macro'
export const ListingsTableContent = () => {
return <TableContentContainer>Listings Content</TableContentContainer>
import { TableTabsKeys } from './DataPageTable'
import { TableContentComponent } from './TableContentComponent'
import { ContentRow, HeaderRow } from './TableRowComponent'
export const ListingsTableContent = ({ asset }: { asset: GenieAsset }) => {
const isMobile = useIsMobile()
const theme = useTheme()
const headers = <HeaderRow type={TableTabsKeys.Listings} is1155={asset.tokenType === NftStandard.Erc1155} />
const contentRows = (asset.sellorders || []).map((offer, index) => (
<ContentRow
key={'offer_' + index}
content={offer}
buttonCTA={isMobile ? <AddToBagIcon color={theme.textSecondary} /> : <Trans>Add to Bag</Trans>}
is1155={asset.tokenType === NftStandard.Erc1155}
/>
))
return <TableContentComponent headerRow={headers} contentRows={contentRows} type={TableTabsKeys.Offers} />
}

View File

@@ -23,6 +23,7 @@ const DetailsBackground = styled.div<{ backgroundImage: string }>`
const DetailsContentContainer = styled.div`
z-index: ${Z_INDEX.hover};
width: 100%;
`
export const NftDetails = ({ asset, collection }: NftDetailsProps) => {

View File

@@ -1,5 +1,28 @@
import { TableContentContainer } from './shared'
import { Trans } from '@lingui/macro'
import { NftStandard } from 'graphql/data/__generated__/types-and-hooks'
import { useIsMobile } from 'nft/hooks'
import { GenieAsset } from 'nft/types'
import { Check } from 'react-feather'
import { useTheme } from 'styled-components/macro'
import { TEST_OFFER } from 'test-utils/nft/fixtures'
export const OffersTableContent = () => {
return <TableContentContainer>Offers Content</TableContentContainer>
import { TableTabsKeys } from './DataPageTable'
import { TableContentComponent } from './TableContentComponent'
import { ContentRow, HeaderRow } from './TableRowComponent'
export const OffersTableContent = ({ asset }: { asset: GenieAsset }) => {
// TODO(NFT-1189) Replace with real offer data when BE supports
const mockOffers = new Array(11).fill(TEST_OFFER)
const isMobile = useIsMobile()
const theme = useTheme()
const headers = <HeaderRow type={TableTabsKeys.Offers} is1155={asset.tokenType === NftStandard.Erc1155} />
const contentRows = mockOffers.map((offer, index) => (
<ContentRow
key={'offer_' + index}
content={offer}
buttonCTA={isMobile ? <Check color={theme.textSecondary} height="20px" width="20px" /> : <Trans>Accept</Trans>}
is1155={asset.tokenType === NftStandard.Erc1155}
/>
))
return <TableContentComponent headerRow={headers} contentRows={contentRows} type={TableTabsKeys.Offers} />
}

View File

@@ -0,0 +1,55 @@
import { ScrollBarStyles } from 'components/Common'
import { useSubscribeScrollState } from 'nft/hooks'
import styled from 'styled-components/macro'
import { TableTabsKeys } from './DataPageTable'
import { Scrim } from './shared'
const TableRowsContainer = styled.div`
position: relative;
`
const TableRowScrollableContainer = styled.div`
overflow-y: auto;
overflow-x: hidden;
max-height: 264px;
${ScrollBarStyles}
`
const TableHeaderRowContainer = styled.div<{ userCanScroll: boolean }>`
margin-right: ${({ userCanScroll }) => (userCanScroll ? '11px' : '0')};
`
const TableRowContainer = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.backgroundOutline};
&:last-child {
border-bottom: none;
}
`
interface TableContentComponentProps {
headerRow: React.ReactNode
contentRows: React.ReactNode[]
type: TableTabsKeys
}
export const TableContentComponent = ({ headerRow, contentRows, type }: TableContentComponentProps) => {
const { userCanScroll, scrollRef, scrollProgress, scrollHandler } = useSubscribeScrollState()
return (
<>
<TableHeaderRowContainer userCanScroll={userCanScroll}>{headerRow}</TableHeaderRowContainer>
<TableRowsContainer>
{scrollProgress > 0 && <Scrim />}
<TableRowScrollableContainer ref={scrollRef} onScroll={scrollHandler}>
{contentRows.map((row, index) => (
<TableRowContainer key={type + '_row_' + index}>{row}</TableRowContainer>
))}
</TableRowScrollableContainer>
{userCanScroll && scrollProgress !== 100 && <Scrim isBottom={true} />}
</TableRowsContainer>
</>
)
}

View File

@@ -0,0 +1,156 @@
import { Trans } from '@lingui/macro'
import { formatCurrencyAmount, NumberType } from '@uniswap/conedison/format'
import { useWeb3React } from '@web3-react/core'
import { OpacityHoverState } from 'components/Common'
import Row from 'components/Row'
import { OrderType } from 'graphql/data/__generated__/types-and-hooks'
import { useScreenSize } from 'hooks/useScreenSize'
import { useStablecoinValue } from 'hooks/useStablecoinPrice'
import useNativeCurrency from 'lib/hooks/useNativeCurrency'
import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount'
import { HomeSearchIcon } from 'nft/components/icons'
import { Offer, SellOrder } from 'nft/types'
import { formatEth, getMarketplaceIcon, timeUntil } from 'nft/utils'
import styled from 'styled-components/macro'
import { BREAKPOINTS, ExternalLink, ThemedText } from 'theme'
import { shortenAddress } from 'utils'
import { TableTabsKeys } from './DataPageTable'
const TableCell = styled.div<{ $flex?: number; $justifyContent?: string; $color?: string; hideOnSmall?: boolean }>`
display: flex;
flex: ${({ $flex }) => $flex ?? 1};
justify-content: ${({ $justifyContent }) => $justifyContent};
color: ${({ $color }) => $color};
flex-shrink: 0;
@media screen and (max-width: ${BREAKPOINTS.sm}px) {
display: ${({ hideOnSmall }) => (hideOnSmall ? 'none' : 'flex')};
}
`
const ActionButton = styled.div`
cursor: pointer;
white-space: nowrap;
${OpacityHoverState}
`
const USDPrice = styled(ThemedText.BodySmall)`
color: ${({ theme }) => theme.textSecondary};
line-height: 20px;
@media screen and (max-width: ${BREAKPOINTS.sm}px) {
display: none;
}
@media screen and (min-width: ${BREAKPOINTS.lg}px) and (max-width: ${BREAKPOINTS.xl - 1}px) {
display: none;
}
`
const Link = styled(ExternalLink)`
height: 20px;
`
const PriceCell = ({ price }: { price: number }) => {
const { chainId } = useWeb3React()
const nativeCurrency = useNativeCurrency(chainId)
const parsedAmount = tryParseCurrencyAmount(price.toString(), nativeCurrency)
const usdValue = useStablecoinValue(parsedAmount)
return (
<Row gap="8px">
<ThemedText.LabelSmall color="textPrimary" lineHeight="16px">
{formatEth(price)}
</ThemedText.LabelSmall>
<USDPrice>{formatCurrencyAmount(usdValue, NumberType.FiatTokenPrice)}</USDPrice>
</Row>
)
}
export const HeaderRow = ({ type, is1155 }: { type: TableTabsKeys; is1155?: boolean }) => {
const screenSize = useScreenSize()
const isMobile = !screenSize['sm']
const isLargeScreen = screenSize['lg'] && !screenSize['xl']
const reducedPriceWidth = isMobile || isLargeScreen
return (
<Row gap="12px" padding="6px 6px 6px 0px">
<HomeSearchIcon />
<TableCell $flex={reducedPriceWidth ? 1 : 1.75}>
<ThemedText.SubHeaderSmall color="textSecondary">
<Trans>Price</Trans>
</ThemedText.SubHeaderSmall>
</TableCell>
{is1155 && (
<TableCell $flex={0.5}>
<ThemedText.SubHeaderSmall color="textSecondary">
<Trans>Quantity</Trans>
</ThemedText.SubHeaderSmall>
</TableCell>
)}
{(type === TableTabsKeys.Offers || is1155) && (
<TableCell hideOnSmall={true}>
<ThemedText.SubHeaderSmall color="textSecondary">
{type === TableTabsKeys.Offers ? <Trans>From</Trans> : <Trans>Seller</Trans>}
</ThemedText.SubHeaderSmall>
</TableCell>
)}
<TableCell $justifyContent="flex-end">
<ThemedText.SubHeaderSmall color="textSecondary">
<Trans>Expires in</Trans>
</ThemedText.SubHeaderSmall>
</TableCell>
{/* An empty cell is needed in the headers for proper vertical alignment with the action buttons */}
<TableCell $flex={isMobile ? 0.25 : 1}>&nbsp;</TableCell>
</Row>
)
}
export const ContentRow = ({
content,
buttonCTA,
is1155,
}: {
content: Offer | SellOrder
buttonCTA: React.ReactNode
is1155?: boolean
}) => {
const screenSize = useScreenSize()
const isMobile = !screenSize['sm']
const date = content.endAt && new Date(content.endAt)
const isSellOrder = 'type' in content && content.type === OrderType.Listing
const reducedPriceWidth = isMobile || (screenSize['lg'] && !screenSize['xl'])
return (
<Row gap="12px" padding="16px 6px 16px 0px">
<Link href={content.marketplaceUrl}>{getMarketplaceIcon(content.marketplace, '20')}</Link>
{content.price && (
<TableCell $flex={reducedPriceWidth ? 1 : 1.75}>
<PriceCell price={content.price.value} />
</TableCell>
)}
{is1155 && (
<TableCell $flex={0.5} $justifyContent="center">
<ThemedText.SubHeaderSmall color="textPrimary">{content.quantity}</ThemedText.SubHeaderSmall>
</TableCell>
)}
{(!isSellOrder || is1155) && (
<TableCell hideOnSmall={true}>
<Link href={`https://etherscan.io/address/${content.maker}`}>
<ThemedText.LabelSmall color="textPrimary">{shortenAddress(content.maker)}</ThemedText.LabelSmall>
</Link>
</TableCell>
)}
<TableCell $justifyContent="flex-end">
<ThemedText.LabelSmall color="textPrimary">
{date ? timeUntil(date) : <Trans>Never</Trans>}
</ThemedText.LabelSmall>
</TableCell>
<TableCell $flex={isMobile ? 0.25 : 1} $justifyContent="center">
<ActionButton>
<ThemedText.LabelSmall color="textSecondary">{buttonCTA}</ThemedText.LabelSmall>
</ActionButton>
</TableCell>
</Row>
)
}

View File

@@ -1,8 +1,7 @@
import Column from 'components/Column'
import Row from 'components/Row'
import { Trait } from 'nft/types'
import { formatEth } from 'nft/utils'
import qs from 'qs'
import { formatEth, getLinkForTrait } from 'nft/utils'
import { Link } from 'react-router-dom'
import styled from 'styled-components/macro'
import { BREAKPOINTS, ThemedText } from 'theme'
@@ -25,7 +24,7 @@ const SubheaderTinyHidden = styled(SubheaderTiny)`
`
const TraitRowContainer = styled(Row)`
padding: 12px 18px 12px 0px;
padding: 12px 18px 12px 12px;
border-radius: 12px;
cursor: pointer;
text-decoration: none;
@@ -60,14 +59,9 @@ export const TraitRow = ({ trait, collectionAddress }: { trait: Trait; collectio
// rarity eventually should be number of items with this trait / total number of items, smaller rarity means more rare
const randomRarity = Math.random()
const rarityLevel = getRarityLevel(randomRarity)
const params = qs.stringify(
{ traits: [`("${trait.trait_type}","${trait.trait_value}")`] },
{
arrayFormat: 'comma',
}
)
return (
<TraitRowLink to={`/nfts/collection/${collectionAddress}?${params}`}>
<TraitRowLink to={getLinkForTrait(trait, collectionAddress)}>
<TraitRowContainer>
<TraitColumnValue>
<SubheaderTiny>{trait.trait_type}</SubheaderTiny>

View File

@@ -2,13 +2,13 @@
exports[`placeholder containers load 1`] = `
<DocumentFragment>
.c2 {
.c3 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c10 {
.c11 {
box-sizing: border-box;
margin: 0;
min-width: 0;
@@ -18,7 +18,7 @@ exports[`placeholder containers load 1`] = `
width: min-content;
}
.c12 {
.c13 {
box-sizing: border-box;
margin: 0;
min-width: 0;
@@ -41,7 +41,7 @@ exports[`placeholder containers load 1`] = `
border-radius: 4px;
}
.c3 {
.c4 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
@@ -58,7 +58,7 @@ exports[`placeholder containers load 1`] = `
justify-content: flex-start;
}
.c7 {
.c8 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
@@ -76,7 +76,7 @@ exports[`placeholder containers load 1`] = `
gap: 4px;
}
.c11 {
.c12 {
width: -webkit-min-content;
width: -moz-min-content;
width: min-content;
@@ -96,7 +96,7 @@ exports[`placeholder containers load 1`] = `
gap: 12px;
}
.c21 {
.c22 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
@@ -114,15 +114,15 @@ exports[`placeholder containers load 1`] = `
gap: 8px;
}
.c8 {
.c9 {
color: #7780A0;
}
.c9 {
.c10 {
color: #0D111C;
}
.c0 {
.c1 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
@@ -136,12 +136,13 @@ exports[`placeholder containers load 1`] = `
justify-content: flex-start;
}
.c25 {
.c26 {
height: 568px;
}
.c18 {
.c19 {
background: #FFFFFF;
border: 1px solid #D2D9EE;
border-radius: 16px;
padding: 16px 20px;
width: 100%;
@@ -150,33 +151,33 @@ exports[`placeholder containers load 1`] = `
align-self: flex-start;
}
.c19 {
.c20 {
gap: 32px;
margin-bottom: 12px;
width: 100;
}
.c20 {
.c21 {
color: #0D111C;
line-height: 24px;
cursor: pointer;
}
.c20:hover {
.c21:hover {
opacity: 0.6;
}
.c22 {
.c23 {
color: #98A1C0;
line-height: 24px;
cursor: pointer;
}
.c22:hover {
.c23:hover {
opacity: 0.6;
}
.c24 {
.c25 {
background: #D2D9EE;
border-radius: 4px;
padding: 2px 4px;
@@ -184,11 +185,11 @@ exports[`placeholder containers load 1`] = `
line-height: 12px;
}
.c23 {
.c24 {
height: 252px;
}
.c13 {
.c14 {
padding: 16px;
width: 100%;
font-weight: 500;
@@ -226,56 +227,56 @@ exports[`placeholder containers load 1`] = `
transform: perspective(1px) translateZ(0);
}
.c13:disabled {
.c14:disabled {
opacity: 50%;
cursor: auto;
pointer-events: none;
}
.c13 > * {
.c14 > * {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.c13 > a {
.c14 > a {
-webkit-text-decoration: none;
text-decoration: none;
}
.c14 {
.c15 {
background-color: #F5F6FC;
color: #7780A0;
font-size: 16px;
font-weight: 500;
}
.c14:hover {
.c15:hover {
background-color: #d2daf7;
}
.c14:active {
.c15:active {
background-color: #bdc8f3;
}
.c4 {
.c5 {
gap: 24px;
}
.c5 {
.c6 {
width: 96px;
height: 96px;
border-radius: 20px;
object-fit: cover;
}
.c6 {
.c7 {
gap: 4px;
margin-right: auto;
}
.c15 {
.c16 {
white-space: nowrap;
width: -webkit-min-content;
width: -moz-min-content;
@@ -286,21 +287,26 @@ exports[`placeholder containers load 1`] = `
border-radius: 16px;
}
.c1 {
.c0 {
padding: 24px 64px;
height: 100vh;
width: 100%;
}
.c2 {
height: 100%;
width: 100%;
gap: 36px;
max-width: 1200px;
margin: 0 auto;
}
.c16 {
.c17 {
gap: 24px;
padding-bottom: 45px;
}
.c17 {
.c18 {
gap: 24px;
width: 100%;
-webkit-align-self: flex-start;
@@ -309,31 +315,37 @@ exports[`placeholder containers load 1`] = `
}
@media screen and (max-width:768px) {
.c4 {
display: none;
}
}
@media screen and (max-width:1024px) {
.c5 {
display: none;
}
}
@media screen and (max-width:1024px) {
.c6 {
display: none;
}
}
@media screen and (max-width:768px) {
.c0 {
height: 100%;
}
}
@media screen and (max-width:640px) {
.c1 {
.c0 {
padding: 24px 48px;
}
}
@media screen and (max-width:396px) {
.c1 {
.c0 {
padding: 24px 20px;
}
}
@media screen and (max-width:1024px) {
.c16 {
.c17 {
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
@@ -341,146 +353,145 @@ exports[`placeholder containers load 1`] = `
}
<div
class="c0 c1"
class="c0"
>
<div
class="c2 c3 c4"
>
<img
class="c5"
src="https://cdn.center.app/1/0xED5AF388653567Af2F388E6224dC7C4b3241C544/3318/50ed67ad647d0aa0cad0b830d136a677efc2fb72a44587bc35f2a5fb334a7fdf.png"
/>
<div
class="c0 c6"
>
<div
class="c2 c7"
>
<div
class="c8 css-1aekuku"
>
Azuki
</div>
<svg
fill="none"
height="16px"
viewBox="0 0 20 20"
width="16px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.52795 13.8056C4.52719 14.4043 4.6712 14.8474 4.95997 15.135C5.24798 15.4233 5.68496 15.5651 6.27091 15.5605H7.57497C7.62945 15.5585 7.68379 15.5676 7.73463 15.5873C7.78547 15.607 7.83176 15.6369 7.87062 15.6752L8.79884 16.5928C9.22054 17.0142 9.63382 17.2237 10.0387 17.2214C10.4436 17.2191 10.8569 17.0096 11.2786 16.5928L12.1954 15.6752C12.2356 15.6365 12.2832 15.6063 12.3354 15.5866C12.3876 15.5669 12.4433 15.558 12.499 15.5605H13.7951C14.3871 15.5613 14.8283 15.4171 15.1186 15.1281C15.4089 14.839 15.5541 14.3959 15.5541 13.7987V12.5014C15.5511 12.389 15.5923 12.2799 15.6687 12.1974L16.5854 11.2798C17.0125 10.86 17.2245 10.4467 17.2214 10.0399C17.2184 9.63305 17.0064 9.21935 16.5854 8.79878L15.6687 7.88115C15.592 7.79886 15.5509 7.68965 15.5541 7.57719V6.2799C15.5533 5.68191 15.4093 5.23878 15.1221 4.95049C14.8348 4.66221 14.3925 4.51806 13.7951 4.51806H12.499C12.4433 4.52036 12.3877 4.51138 12.3355 4.49168C12.2834 4.47197 12.2357 4.44193 12.1954 4.40336L11.2786 3.48574C10.8569 3.06439 10.4436 2.85487 10.0387 2.85717C9.63382 2.85946 9.22054 3.06898 8.79884 3.48574L7.87062 4.40336C7.83164 4.44148 7.78536 4.4713 7.73454 4.49101C7.68373 4.51072 7.62943 4.51993 7.57497 4.51806H6.27091C5.67961 4.51883 5.23995 4.66182 4.95194 4.94705C4.66393 5.23228 4.51992 5.67656 4.51992 6.2799V7.58063C4.52314 7.69309 4.48197 7.80229 4.40533 7.88459L3.48859 8.80222C3.06765 9.22203 2.85718 9.63572 2.85718 10.0433C2.85718 10.4509 3.07033 10.8653 3.49662 11.2867L4.41336 12.2043C4.48979 12.2867 4.53092 12.3958 4.52795 12.5083V13.8056Z"
fill="#FB118E"
/>
<path
d="M9.99737 12.4943C9.86205 12.7005 9.6623 12.8164 9.43032 12.8164C9.19191 12.8164 9.00504 12.7198 8.83106 12.4943L7.31036 10.6385C7.20082 10.5032 7.14282 10.3614 7.14282 10.2068C7.14282 9.88458 7.38768 9.63327 7.70342 9.63327C7.89673 9.63327 8.05138 9.70415 8.20603 9.90391L9.40455 11.4311L11.9498 7.34577C12.0851 7.12669 12.2591 7.02359 12.4524 7.02359C12.7553 7.02359 13.0388 7.23623 13.0388 7.55197C13.0388 7.70017 12.9615 7.85482 12.8777 7.99014L9.99737 12.4943Z"
fill="white"
/>
</svg>
</div>
<div
class="c9 css-1tiu9da"
>
Azuki #3318
</div>
</div>
<div
class="c10 c11"
width="min-content"
>
<button
class="c12 c13 c14 c15"
>
Make an offer
</button>
</div>
</div>
<div
class="c2 c3 c16"
class="c1 c2"
>
<div
class="c0 c17"
class="c3 c4 c5"
>
<img
class="c6"
src="https://cdn.center.app/1/0xED5AF388653567Af2F388E6224dC7C4b3241C544/3318/50ed67ad647d0aa0cad0b830d136a677efc2fb72a44587bc35f2a5fb334a7fdf.png"
/>
<div
class="c18"
class="c1 c7"
>
<div
class="c2 c3 c19"
class="c3 c8"
>
<div
class="c9 c20 css-rjqmed"
class="c9 css-1aekuku"
>
<div
class="c2 c21"
>
Description
</div>
Azuki
</div>
<div
class="c9 c22 css-rjqmed"
<svg
fill="none"
height="16px"
viewBox="0 0 20 20"
width="16px"
xmlns="http://www.w3.org/2000/svg"
>
<div
class="c2 c21"
>
Details
</div>
</div>
<path
d="M4.52795 13.8056C4.52719 14.4043 4.6712 14.8474 4.95997 15.135C5.24798 15.4233 5.68496 15.5651 6.27091 15.5605H7.57497C7.62945 15.5585 7.68379 15.5676 7.73463 15.5873C7.78547 15.607 7.83176 15.6369 7.87062 15.6752L8.79884 16.5928C9.22054 17.0142 9.63382 17.2237 10.0387 17.2214C10.4436 17.2191 10.8569 17.0096 11.2786 16.5928L12.1954 15.6752C12.2356 15.6365 12.2832 15.6063 12.3354 15.5866C12.3876 15.5669 12.4433 15.558 12.499 15.5605H13.7951C14.3871 15.5613 14.8283 15.4171 15.1186 15.1281C15.4089 14.839 15.5541 14.3959 15.5541 13.7987V12.5014C15.5511 12.389 15.5923 12.2799 15.6687 12.1974L16.5854 11.2798C17.0125 10.86 17.2245 10.4467 17.2214 10.0399C17.2184 9.63305 17.0064 9.21935 16.5854 8.79878L15.6687 7.88115C15.592 7.79886 15.5509 7.68965 15.5541 7.57719V6.2799C15.5533 5.68191 15.4093 5.23878 15.1221 4.95049C14.8348 4.66221 14.3925 4.51806 13.7951 4.51806H12.499C12.4433 4.52036 12.3877 4.51138 12.3355 4.49168C12.2834 4.47197 12.2357 4.44193 12.1954 4.40336L11.2786 3.48574C10.8569 3.06439 10.4436 2.85487 10.0387 2.85717C9.63382 2.85946 9.22054 3.06898 8.79884 3.48574L7.87062 4.40336C7.83164 4.44148 7.78536 4.4713 7.73454 4.49101C7.68373 4.51072 7.62943 4.51993 7.57497 4.51806H6.27091C5.67961 4.51883 5.23995 4.66182 4.95194 4.94705C4.66393 5.23228 4.51992 5.67656 4.51992 6.2799V7.58063C4.52314 7.69309 4.48197 7.80229 4.40533 7.88459L3.48859 8.80222C3.06765 9.22203 2.85718 9.63572 2.85718 10.0433C2.85718 10.4509 3.07033 10.8653 3.49662 11.2867L4.41336 12.2043C4.48979 12.2867 4.53092 12.3958 4.52795 12.5083V13.8056Z"
fill="#FB118E"
/>
<path
d="M9.99737 12.4943C9.86205 12.7005 9.6623 12.8164 9.43032 12.8164C9.19191 12.8164 9.00504 12.7198 8.83106 12.4943L7.31036 10.6385C7.20082 10.5032 7.14282 10.3614 7.14282 10.2068C7.14282 9.88458 7.38768 9.63327 7.70342 9.63327C7.89673 9.63327 8.05138 9.70415 8.20603 9.90391L9.40455 11.4311L11.9498 7.34577C12.0851 7.12669 12.2591 7.02359 12.4524 7.02359C12.7553 7.02359 13.0388 7.23623 13.0388 7.55197C13.0388 7.70017 12.9615 7.85482 12.8777 7.99014L9.99737 12.4943Z"
fill="white"
/>
</svg>
</div>
<div
class="c23"
class="c10 css-1tiu9da"
>
Description Content
Azuki #3318
</div>
</div>
<div
class="c11 c12"
width="min-content"
>
<button
class="c13 c14 c15 c16"
>
Make an offer
</button>
</div>
</div>
<div
class="c18"
class="c3 c4 c17"
>
<div
class="c2 c3 c19"
class="c1 c18"
>
<div
class="c9 c20 css-rjqmed"
class="c19"
>
<div
class="c2 c21"
class="c3 c4 c20"
>
Activity
</div>
</div>
<div
class="c9 c22 css-rjqmed"
>
<div
class="c2 c21"
>
Offers
<div
class="c24 css-f8aq60"
class="c10 c21 css-rjqmed"
>
10+
<div
class="c3 c22"
>
Description
</div>
</div>
<div
class="c10 c23 css-rjqmed"
>
<div
class="c3 c22"
>
Details
</div>
</div>
</div>
</div>
<div
class="c9 c22 css-rjqmed"
>
<div
class="c2 c21"
class="c24"
>
Listings
<div
class="c24 css-f8aq60"
>
10+
</div>
Description Content
</div>
</div>
</div>
<div
class="c25"
class="c19"
>
Activity Content
<div
class="c3 c4 c20"
>
<div
class="c10 c21 css-rjqmed"
>
<div
class="c3 c22"
>
Activity
</div>
</div>
<div
class="c10 c23 css-rjqmed"
>
<div
class="c3 c22"
>
Offers
<div
class="c25 css-f8aq60"
>
10+
</div>
</div>
</div>
<div
class="c10 c23 css-rjqmed"
>
<div
class="c3 c22"
>
Listings
</div>
</div>
</div>
<div
class="c26"
>
Activity Content
</div>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -67,6 +67,7 @@ exports[`data page trait component does not load with asset with no traits 1`] =
.c0 {
background: #FFFFFF;
border: 1px solid #D2D9EE;
border-radius: 16px;
padding: 16px 20px;
width: 100%;
@@ -88,7 +89,7 @@ exports[`data page trait component does not load with asset with no traits 1`] =
}
.c8 {
padding-right: 12px;
padding: 0px 12px;
}
.c10 {

View File

@@ -26,6 +26,81 @@ exports[`LandingPage renders it correctly 1`] = `
gap: 4px;
}
.c16 {
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: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
}
.c20 {
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: 8px;
}
.c22 {
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;
}
.c18 {
color: #7780A0;
}
.c19 {
-webkit-text-decoration: none;
text-decoration: none;
cursor: pointer;
-webkit-transition-duration: 125ms;
transition-duration: 125ms;
color: #FB118E;
stroke: #FB118E;
font-weight: 500;
}
.c19:hover {
opacity: 0.6;
}
.c19:active {
opacity: 0.4;
}
.c5 {
display: -webkit-box;
display: -webkit-flex;
@@ -40,6 +115,21 @@ exports[`LandingPage renders it correctly 1`] = `
justify-content: flex-start;
}
.c15 {
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: 8px;
}
.c6 {
width: 100%;
-webkit-align-items: center;
@@ -48,6 +138,50 @@ exports[`LandingPage renders it correctly 1`] = `
align-items: center;
}
.c21 {
background-color: #FFFFFF;
padding: 10px 12px 10px 8px;
border-radius: 20px;
max-width: 144px;
}
.c24 {
font-weight: 600;
font-size: 16px;
line-height: 20px;
color: #0D111C;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.c23 {
width: 24px;
height: 24px;
-webkit-flex-shrink: 0;
-ms-flex-negative: 0;
flex-shrink: 0;
color: #FB118E;
border-radius: 100%;
overflow: hidden;
-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;
}
.c17 {
gap: 4px;
width: 100%;
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
}
.c2 {
position: relative;
object-fit: contain;
@@ -128,6 +262,21 @@ exports[`LandingPage renders it correctly 1`] = `
filter: drop-shadow(0px 12px 20px rgba(0,0,0,0.1));
}
@media screen and (min-width:640px) {
.c21 {
max-width: 169px;
}
}
@media screen and (min-width:640px) {
.c17 {
gap: 12px;
-webkit-flex-wrap: nowrap;
-ms-flex-wrap: nowrap;
flex-wrap: nowrap;
}
}
@media screen and (min-width:1280px) {
.c4 {
-webkit-filter: blur(50px);
@@ -259,6 +408,40 @@ exports[`LandingPage renders it correctly 1`] = `
Azuki #3318
</div>
</div>
<div
class="c15"
>
<div
class="c9 c16 c17"
>
<div
class="c15"
>
<div
class="c18 css-4u0e4f"
>
Owner
</div>
<a
class="c19"
href="https://etherscan.io/address/"
rel="noopener noreferrer"
target="_blank"
>
<div
class="c9 c20 c21"
>
<div
class="c9 c22 c23"
/>
<div
class="c24"
/>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</DocumentFragment>

View File

@@ -1,7 +1,9 @@
import styled, { css } from 'styled-components/macro'
import { opacify } from 'theme/utils'
export const containerStyles = css`
background: ${({ theme }) => theme.backgroundSurface};
border: 1px solid ${({ theme }) => theme.backgroundOutline};
border-radius: 16px;
padding: 16px 20px;
width: 100%;
@@ -11,3 +13,24 @@ export const containerStyles = css`
export const TableContentContainer = styled.div`
height: 568px;
`
// Scrim that fades out the top and bottom of the scrollable container, isBottom changes the direction and placement of the fade
export const Scrim = styled.div<{ isBottom?: boolean }>`
position: absolute;
pointer-events: none;
height: 88px;
left: 0px;
right: 6px;
${({ isBottom }) =>
isBottom
? 'bottom: 0px'
: `
top: 0px;
transform: matrix(1, 0, 0, -1, 0, 0);
`};
background: ${({ theme }) =>
`linear-gradient(180deg, ${opacify(0, theme.backgroundSurface)} 0%, ${theme.backgroundSurface} 100%)`};
display: flex;
`

View File

@@ -1351,6 +1351,35 @@ export const UniswapMagentaIcon = (props: SVGProps) => (
</svg>
)
export const HomeSearchIcon = (props: SVGProps) => (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M17.898 7.57097L11.7212 2.49102C10.7237 1.67268 9.27795 1.67185 8.28045 2.49102L2.10379 7.57016C1.83796 7.78932 1.79877 8.18268 2.01794 8.45018C2.2371 8.71768 2.63213 8.75437 2.89796 8.53604L3.54209 8.00605V15.0002C3.54209 17.0152 4.65209 18.1252 6.66709 18.1252H13.3338C15.3488 18.1252 16.4588 17.0152 16.4588 15.0002V8.00605L17.1029 8.53604C17.2195 8.63187 17.3604 8.67845 17.5004 8.67845C17.6804 8.67845 17.8596 8.601 17.9829 8.451C18.2029 8.1835 18.1638 7.79014 17.898 7.57097ZM15.2088 15.0002C15.2088 16.3143 14.6479 16.8752 13.3338 16.8752H6.66709C5.35292 16.8752 4.79209 16.3143 4.79209 15.0002V6.97852L9.07462 3.45771C9.61045 3.01688 10.3913 3.01688 10.9271 3.45771L15.2096 6.97934V15.0002H15.2088ZM6.45875 10.7643C6.45875 12.4493 7.82958 13.8202 9.51458 13.8202C10.1312 13.8202 10.7038 13.6335 11.1838 13.3176L12.4746 14.6085C12.5962 14.7302 12.7563 14.7918 12.9163 14.7918C13.0763 14.7918 13.2363 14.731 13.358 14.6085C13.6021 14.3644 13.6021 13.9685 13.358 13.7243L12.0663 12.4326C12.3813 11.9518 12.568 11.3794 12.568 10.7627C12.568 9.07854 11.1971 7.70688 9.51295 7.70688C7.82962 7.70854 6.45875 9.07933 6.45875 10.7643ZM11.3196 10.7643C11.3196 11.7602 10.5096 12.5702 9.51458 12.5702C8.51875 12.5702 7.70875 11.7602 7.70875 10.7643C7.70875 9.7685 8.51875 8.9585 9.51458 8.9585C10.5096 8.9585 11.3196 9.7685 11.3196 10.7643Z"
fill="#7780A0"
/>
</svg>
)
export const AddToBagIcon = (props: SVGProps) => (
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M8.51389 18.25H5.44444C4.6467 18.25 4 17.653 4 16.9167V7.58333C4 6.84695 4.6467 6.25 5.44444 6.25H14.5556C15.3533 6.25 16 6.84695 16 7.58333V10.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 6.25L7 5.45C7 4.60131 7.31607 3.78737 7.87868 3.18726C8.44129 2.58714 9.20435 2.25 10 2.25C10.7956 2.25 11.5587 2.58714 12.1213 3.18726C12.6839 3.78737 13 4.60131 13 5.45L13 6.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M11 15.25H17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14 12.25L14 18.25" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
export const HandHoldingDollarIcon = (props: SVGProps) => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path

View File

@@ -9,7 +9,7 @@ export default function useDerivedPayWithAnyTokenSwapInfo(
parsedOutputAmount?: CurrencyAmount<NativeCurrency | Token>
): {
state: TradeState
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined
trade: InterfaceTrade | undefined
maximumAmountIn: CurrencyAmount<Token> | undefined
allowedSlippage: Percent
} {

View File

@@ -1,4 +1,4 @@
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { Percent } from '@uniswap/sdk-core'
import { PermitInput, TokenTradeRoutesInput, TokenTradeType } from 'graphql/data/__generated__/types-and-hooks'
import { Allowance } from 'hooks/usePermit2Allowance'
import { buildAllTradeRouteInputs } from 'nft/utils/tokenRoutes'
@@ -8,7 +8,7 @@ import { InterfaceTrade } from 'state/routing/types'
import { useTokenInput } from './useTokenInput'
export default function usePayWithAnyTokenSwap(
trade?: InterfaceTrade<Currency, Currency, TradeType> | undefined,
trade?: InterfaceTrade | undefined,
allowance?: Allowance,
allowedSlippage?: Percent
) {

View File

@@ -1,4 +1,4 @@
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { Percent } from '@uniswap/sdk-core'
import { useMemo } from 'react'
import { InterfaceTrade } from 'state/routing/types'
import { useTheme } from 'styled-components/macro'
@@ -14,7 +14,7 @@ interface PriceImpactSeverity {
color: string
}
export function usePriceImpact(trade?: InterfaceTrade<Currency, Currency, TradeType>): PriceImpact | undefined {
export function usePriceImpact(trade?: InterfaceTrade): PriceImpact | undefined {
const theme = useTheme()
return useMemo(() => {

View File

@@ -1,4 +1,4 @@
import { MediaType, NftStandard } from 'graphql/data/__generated__/types-and-hooks'
import { MediaType, NftMarketplace, NftStandard } from 'graphql/data/__generated__/types-and-hooks'
import { SortBy } from 'nft/hooks'
import { SellOrder } from '../sell'
@@ -79,6 +79,21 @@ export interface Trait {
trait_count?: number
order?: any
}
export interface Offer {
createdAt: number
endAt?: number
id: string
maker: string
marketplace: NftMarketplace
marketplaceUrl: string
price: {
currency?: string
value: number
}
quantity?: number
}
export interface GenieAsset {
id?: string // This would be a random id created and assigned by front end
address: string

View File

@@ -13,7 +13,8 @@ import {
SquareSudoSwapMarketplaceIcon,
SquareZoraMarketplaceIcon,
} from 'nft/components/icons'
import { DetailsOrigin, GenieAsset, Listing, Markets, UpdatedGenieAsset, WalletAsset } from 'nft/types'
import { DetailsOrigin, GenieAsset, Listing, Markets, Trait, UpdatedGenieAsset, WalletAsset } from 'nft/types'
import qs from 'qs'
import { v4 as uuidv4 } from 'uuid'
export function getRarityStatus(
@@ -125,3 +126,14 @@ export const generateTweetForList = (assets: WalletAsset[]): string => {
.join(', ')} \n\nMarketplaces: ${assets[0].marketplaces?.map((market) => market.name).join(', ')}`
return `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}`
}
export function getLinkForTrait(trait: Trait, collectionAddress: string): string {
const params = qs.stringify(
{ traits: [`("${trait.trait_type}","${trait.trait_value}")`] },
{
arrayFormat: 'comma',
}
)
return `/nfts/collection/${collectionAddress}?${params}`
}

View File

@@ -13,6 +13,6 @@ export * from './numbers'
export * from './pooledAssets'
export * from './putCommas'
export * from './roundAndPluralize'
export * from './timeSince'
export * from './time'
export * from './transactionResponse'
export * from './updatedAssets'

View File

@@ -0,0 +1,58 @@
import { i18n } from '@lingui/core'
import { DEFAULT_LOCALE } from 'constants/locales'
import catalog from 'locales/en-US'
import { en } from 'make-plural'
import { timeUntil } from './time'
describe('timeUntil', () => {
const originalDate = new Date('2023-06-01T00:00:00.000Z')
i18n.load({
[DEFAULT_LOCALE]: catalog.messages,
})
i18n.loadLocaleData({
[DEFAULT_LOCALE]: { plurals: en },
})
i18n.activate(DEFAULT_LOCALE)
test('returns undefined when date is in the past', () => {
const pastDate = new Date('2022-01-01T00:00:00.000Z')
expect(timeUntil(pastDate, originalDate)).toBeUndefined()
})
test('returns the correct time until in months', () => {
const futureDate = new Date('2023-09-01T00:00:00.000Z')
expect(timeUntil(futureDate, originalDate)).toEqual('3 months')
})
test('returns the correct time until in weeks', () => {
const futureDate = new Date('2023-06-20T00:00:00.000Z')
expect(timeUntil(futureDate, originalDate)).toEqual('2 weeks')
})
test('returns the correct time until in days', () => {
const futureDate = new Date('2023-06-03T12:00:00.000Z')
expect(timeUntil(futureDate, originalDate)).toEqual('2 days')
})
test('returns the correct time untwil in hours', () => {
const futureDate = new Date('2023-06-01T05:00:00.000Z')
expect(timeUntil(futureDate, originalDate)).toEqual('5 hours')
})
test('returns the correct time until in minutes', () => {
const futureDate = new Date('2023-06-01T00:05:00.000Z')
expect(timeUntil(futureDate, originalDate)).toEqual('5 minutes')
})
test('returns the correct time until in seconds', () => {
const futureDate = new Date('2023-06-01T00:00:05.000Z')
expect(timeUntil(futureDate, originalDate)).toEqual('5 seconds')
})
test('returns 99+ months for large intervals', () => {
const futureDate = new Date('2123-01-01T00:00:00.000Z')
expect(timeUntil(futureDate, originalDate)).toEqual('99+ months')
})
})

98
src/nft/utils/time.ts Normal file
View File

@@ -0,0 +1,98 @@
import { plural, t } from '@lingui/macro'
import ms from 'ms.macro'
import { roundAndPluralize } from './roundAndPluralize'
const SECOND = ms`1s`
const MINUTE = ms`1m`
const HOUR = ms`1h`
const DAY = ms`1d`
const WEEK = ms`7d`
const MONTH = ms`30d`
interface TimePeriod {
milliseconds: number
pluralLabel: (i: number) => string
}
const timePeriods: TimePeriod[] = [
{
milliseconds: MONTH,
pluralLabel: (i: number) =>
plural(i, {
one: 'month',
other: 'months',
}),
},
{
milliseconds: WEEK,
pluralLabel: (i: number) =>
plural(i, {
one: 'week',
other: 'weeks',
}),
},
{
milliseconds: DAY,
pluralLabel: (i: number) =>
plural(i, {
one: 'day',
other: 'days',
}),
},
{
milliseconds: HOUR,
pluralLabel: (i: number) =>
plural(i, {
one: 'hour',
other: 'hours',
}),
},
{
milliseconds: MINUTE,
pluralLabel: (i: number) =>
plural(i, {
one: 'minute',
other: 'minutes',
}),
},
{
milliseconds: SECOND,
pluralLabel: (i: number) =>
plural(i, {
one: 'second',
other: 'seconds',
}),
},
]
export function timeUntil(date: Date, originalDate?: Date): string | undefined {
const referenceDate = originalDate ?? new Date()
const milliseconds = date.getTime() - referenceDate.getTime()
if (milliseconds < 0) return undefined
const monthInterval = milliseconds / MONTH
if (monthInterval >= 100) return `99+ ${t`months`}`
for (const period of timePeriods) {
const interval = milliseconds / period.milliseconds
if (interval >= 1) {
return `${Math.floor(interval)} ${period.pluralLabel(interval)}`
}
}
return undefined
}
export const timeLeft = (targetDate: Date): string => {
const countDown = new Date(targetDate).getTime() - new Date().getTime()
const days = Math.floor(countDown / DAY)
const hours = Math.floor((countDown % DAY) / HOUR)
const minutes = Math.floor((countDown % HOUR) / MINUTE)
return `${days !== 0 ? roundAndPluralize(days, 'day') : ''} ${
hours !== 0 ? roundAndPluralize(hours, 'hour') : ''
} ${roundAndPluralize(minutes, 'minute')}`
}

View File

@@ -1,39 +0,0 @@
import { roundAndPluralize } from './roundAndPluralize'
export function timeSince(date: Date, min?: boolean) {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000)
let interval = seconds / 31536000
if (interval > 1) return roundAndPluralize(interval, min ? 'yr' : 'year')
interval = seconds / 2592000
if (interval > 1) return roundAndPluralize(interval, min ? 'mth' : 'month')
interval = seconds / 86400
if (interval > 1) return roundAndPluralize(interval, 'day')
interval = seconds / 3600
if (interval > 1) return roundAndPluralize(interval, min ? 'hr' : 'hour')
interval = seconds / 60
if (interval > 1) return roundAndPluralize(interval, 'min')
return roundAndPluralize(interval, 'sec')
}
const MINUTE = 1000 * 60
const HOUR = MINUTE * 60
const DAY = 24 * HOUR
export const timeLeft = (targetDate: Date): string => {
const countDown = new Date(targetDate).getTime() - new Date().getTime()
const days = Math.floor(countDown / DAY)
const hours = Math.floor((countDown % DAY) / HOUR)
const minutes = Math.floor((countDown % HOUR) / MINUTE)
return `${days !== 0 ? roundAndPluralize(days, 'day') : ''} ${
hours !== 0 ? roundAndPluralize(hours, 'hour') : ''
} ${roundAndPluralize(minutes, 'minute')}`
}

View File

@@ -1,5 +1,5 @@
import { IRoute, Protocol } from '@uniswap/router-sdk'
import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { Currency, CurrencyAmount } from '@uniswap/sdk-core'
import { Pair } from '@uniswap/v2-sdk'
import { Pool } from '@uniswap/v3-sdk'
import { TokenAmountInput, TokenTradeRouteInput, TradePoolInput } from 'graphql/data/__generated__/types-and-hooks'
@@ -108,7 +108,7 @@ function buildTradeRouteInput(swap: Swap): TokenTradeRouteInput {
}
}
export function buildAllTradeRouteInputs(trade: InterfaceTrade<Currency, Currency, TradeType>): {
export function buildAllTradeRouteInputs(trade: InterfaceTrade): {
mixedTokenTradeRouteInputs: TokenTradeRouteInput[] | undefined
v2TokenTradeRouteInputs: TokenTradeRouteInput[] | undefined
v3TokenTradeRouteInputs: TokenTradeRouteInput[] | undefined

View File

@@ -18,6 +18,7 @@ import { flexRowNoWrap } from 'theme/styles'
import { Z_INDEX } from 'theme/zIndex'
import { STATSIG_DUMMY_KEY } from 'tracing'
import { getEnvName } from 'utils/env'
import { retry } from 'utils/retry'
import { getCLS, getFCP, getFID, getLCP, Metric } from 'web-vitals'
import { useAnalyticsReporter } from '../components/analytics'
@@ -45,12 +46,12 @@ import Swap from './Swap'
import { RedirectPathToSwapOnly } from './Swap/redirects'
import Tokens from './Tokens'
const TokenDetails = lazy(() => import('./TokenDetails'))
const Vote = lazy(() => import('./Vote'))
const NftExplore = lazy(() => import('nft/pages/explore'))
const Collection = lazy(() => import('nft/pages/collection'))
const Profile = lazy(() => import('nft/pages/profile/profile'))
const Asset = lazy(() => import('nft/pages/asset/Asset'))
const TokenDetails = lazy(() => retry(() => import('./TokenDetails')))
const Vote = lazy(() => retry(() => import('./Vote')))
const NftExplore = lazy(() => retry(() => import('nft/pages/explore')))
const Collection = lazy(() => retry(() => import('nft/pages/collection')))
const Profile = lazy(() => retry(() => import('nft/pages/profile/profile')))
const Asset = lazy(() => retry(() => import('nft/pages/asset/Asset')))
const BodyWrapper = styled.div`
display: flex;

View File

@@ -46,7 +46,7 @@ import useIsArgentWallet from '../../hooks/useIsArgentWallet'
import { useTotalSupply } from '../../hooks/useTotalSupply'
import { useTokenBalance } from '../../state/connection/hooks'
import { TransactionType } from '../../state/transactions/types'
import { BackArrow, ExternalLink, ThemedText } from '../../theme'
import { BackArrowLink, ExternalLink, ThemedText } from '../../theme'
import { isAddress } from '../../utils'
import { calculateGasMargin } from '../../utils/calculateGasMargin'
import { currencyId } from '../../utils/currencyId'
@@ -725,7 +725,7 @@ export default function MigrateV2Pair() {
<BodyWrapper style={{ padding: 24 }}>
<AutoColumn gap="16px">
<AutoRow style={{ alignItems: 'center', justifyContent: 'space-between' }} gap="8px">
<BackArrow to="/migrate/v2" />
<BackArrowLink to="/migrate/v2" />
<ThemedText.DeprecatedMediumHeader>
<Trans>Migrate V2 Liquidity</Trans>
</ThemedText.DeprecatedMediumHeader>

View File

@@ -20,7 +20,7 @@ import { Dots } from '../../components/swap/styleds'
import { V2_FACTORY_ADDRESSES } from '../../constants/addresses'
import { useTokenBalancesWithLoadingIndicator } from '../../state/connection/hooks'
import { toV2LiquidityToken, useTrackedTokenPairs } from '../../state/user/hooks'
import { BackArrow, StyledInternalLink, ThemedText } from '../../theme'
import { BackArrowLink, StyledInternalLink, ThemedText } from '../../theme'
import { BodyWrapper } from '../AppBody'
function EmptyState({ message }: { message: ReactNode }) {
@@ -116,7 +116,7 @@ export default function MigrateV2() {
<BodyWrapper style={{ padding: 24 }}>
<AutoColumn gap="16px">
<AutoRow style={{ alignItems: 'center', justifyContent: 'space-between' }} gap="8px">
<BackArrow to="/pools" />
<BackArrowLink to="/pools" />
<ThemedText.DeprecatedMediumHeader>
<Trans>Migrate V2 Liquidity</Trans>
</ThemedText.DeprecatedMediumHeader>

View File

@@ -8,8 +8,7 @@ import {
InterfaceSectionName,
SwapEventName,
} from '@uniswap/analytics-events'
import { Trade } from '@uniswap/router-sdk'
import { Currency, CurrencyAmount, Percent, Token, TradeType } from '@uniswap/sdk-core'
import { Currency, CurrencyAmount, Percent, Token } from '@uniswap/sdk-core'
import { UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk'
import { useWeb3React } from '@web3-react/core'
import { useToggleAccountDrawer } from 'components/AccountDrawer'
@@ -39,6 +38,7 @@ import { TradeState } from 'state/routing/types'
import styled, { useTheme } from 'styled-components/macro'
import invariant from 'tiny-invariant'
import { currencyAmountToPreciseFloat, formatTransactionAmount } from 'utils/formatNumbers'
import { didUserReject } from 'utils/swapErrorToUserReadableMessage'
import { switchChain } from 'utils/switchChain'
import AddressInputPanel from '../../components/AddressInputPanel'
@@ -115,11 +115,11 @@ const OutputSwapSection = styled(SwapSection)`
`
function getIsValidSwapQuote(
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined,
trade: InterfaceTrade | undefined,
tradeState: TradeState,
swapInputError?: ReactNode
): boolean {
return !!swapInputError && !!trade && (tradeState === TradeState.VALID || tradeState === TradeState.SYNCING)
return Boolean(swapInputError && trade && tradeState === TradeState.VALID)
}
function largerPercentValue(a?: Percent, b?: Percent) {
@@ -293,7 +293,7 @@ export function Swap({
const fiatValueOutput = useUSDPrice(parsedAmounts[Field.OUTPUT])
const [routeNotFound, routeIsLoading, routeIsSyncing] = useMemo(
() => [!trade?.swaps, TradeState.LOADING === tradeState, TradeState.SYNCING === tradeState],
() => [!trade?.swaps, TradeState.LOADING === tradeState, TradeState.LOADING === tradeState && Boolean(trade)],
[trade, tradeState]
)
@@ -336,7 +336,7 @@ export function Swap({
// modal and loading
const [{ showConfirm, tradeToConfirm, swapErrorMessage, attemptingTxn, txHash }, setSwapState] = useState<{
showConfirm: boolean
tradeToConfirm: Trade<Currency, Currency, TradeType> | undefined
tradeToConfirm: InterfaceTrade | undefined
attemptingTxn: boolean
swapErrorMessage: string | undefined
txHash: string | undefined
@@ -571,22 +571,22 @@ export function Swap({
showCancel={true}
/>
<SwapHeader autoSlippage={autoSlippage} />
<ConfirmSwapModal
isOpen={showConfirm}
trade={trade}
originalTrade={tradeToConfirm}
onAcceptChanges={handleAcceptChanges}
attemptingTxn={attemptingTxn}
txHash={txHash}
recipient={recipient}
allowedSlippage={allowedSlippage}
onConfirm={handleSwap}
swapErrorMessage={swapErrorMessage}
onDismiss={handleConfirmDismiss}
swapQuoteReceivedDate={swapQuoteReceivedDate}
fiatValueInput={fiatValueTradeInput}
fiatValueOutput={fiatValueTradeOutput}
/>
{trade && showConfirm && (
<ConfirmSwapModal
trade={trade}
originalTrade={tradeToConfirm}
onAcceptChanges={handleAcceptChanges}
attemptingTxn={attemptingTxn}
txHash={txHash}
allowedSlippage={allowedSlippage}
onConfirm={handleSwap}
swapErrorMessage={swapErrorMessage}
onDismiss={handleConfirmDismiss}
swapQuoteReceivedDate={swapQuoteReceivedDate}
fiatValueInput={fiatValueTradeInput}
fiatValueOutput={fiatValueTradeOutput}
/>
)}
<div style={{ display: 'relative' }}>
<SwapSection>
@@ -696,8 +696,17 @@ export function Swap({
</TraceEvent>
) : chainId && chainId !== connectedChainId ? (
<ButtonPrimary
onClick={() => {
switchChain(connector, chainId)
onClick={async () => {
try {
await switchChain(connector, chainId)
} catch (error) {
if (didUserReject(error)) {
// Ignore error, which keeps the user on the previous chain.
} else {
// TODO(WEB-3306): This UX could be improved to show an error state.
throw error
}
}
}}
>
Connect to {getChainInfo(chainId)?.label}

View File

@@ -1,7 +1,23 @@
import 'polyfill-object.fromentries'
import { ResizeObserver } from '@juggle/resize-observer'
import flat from 'array.prototype.flat'
import flatMap from 'array.prototype.flatmap'
import { Buffer } from 'buffer'
flat.shim()
flatMap.shim()
declare global {
interface Window {
Buffer: typeof Buffer
}
}
if (!window.Buffer) {
window.Buffer = Buffer
}
if (!window.ResizeObserver) {
window.ResizeObserver = ResizeObserver
}

View File

@@ -1,10 +1,11 @@
import '@testing-library/jest-dom' // jest custom assertions
import 'polyfills'
import 'jest-styled-components' // adds style diffs to snapshot tests
import 'polyfills'
import type { createPopper } from '@popperjs/core'
import { useWeb3React } from '@web3-react/core'
import failOnConsole from 'jest-fail-on-console'
import ResizeObserver from 'resize-observer-polyfill'
import { Readable } from 'stream'
import { mocked } from 'test-utils/mocked'
import { TextDecoder, TextEncoder } from 'util'
@@ -18,21 +19,19 @@ if (typeof global.TextEncoder === 'undefined') {
global.TextDecoder = TextDecoder as typeof global.TextDecoder
}
global.ResizeObserver = ResizeObserver
// Sets origin to the production origin, because some tests depend on this.
// This prevents each test file from needing to set this manually.
global.origin = 'https://app.uniswap.org'
global.matchMedia =
global.matchMedia ||
function () {
(() => {
return {
matches: false,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}
}
})
jest.mock('@popperjs/core', () => {
const core = jest.requireActual('@popperjs/core')

View File

@@ -1,5 +1,5 @@
import type { TransactionResponse } from '@ethersproject/providers'
import { abi as MERKLE_DISTRIBUTOR_ABI } from '@uniswap/merkle-distributor/build/MerkleDistributor.json'
import MerkleDistributorJSON from '@uniswap/merkle-distributor/build/MerkleDistributor.json'
import { CurrencyAmount, Token } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { MERKLE_DISTRIBUTOR_ADDRESS } from 'constants/addresses'
@@ -15,7 +15,7 @@ import { useTransactionAdder } from '../transactions/hooks'
import { TransactionType } from '../transactions/types'
function useMerkleDistributorContract() {
return useContract(MERKLE_DISTRIBUTOR_ADDRESS, MERKLE_DISTRIBUTOR_ABI, true)
return useContract(MERKLE_DISTRIBUTOR_ADDRESS, MerkleDistributorJSON.abi, true)
}
interface UserClaimData {

View File

@@ -6,8 +6,8 @@ import type { TransactionResponse } from '@ethersproject/providers'
import { toUtf8String, Utf8ErrorFuncs, Utf8ErrorReason } from '@ethersproject/strings'
// eslint-disable-next-line no-restricted-imports
import { t } from '@lingui/macro'
import { abi as GOVERNANCE_ABI } from '@uniswap/governance/build/GovernorAlpha.json'
import { abi as UNI_ABI } from '@uniswap/governance/build/Uni.json'
import GovernorAlphaJSON from '@uniswap/governance/build/GovernorAlpha.json'
import UniJSON from '@uniswap/governance/build/Uni.json'
import { CurrencyAmount, Token } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import GOVERNOR_BRAVO_ABI from 'abis/governor-bravo.json'
@@ -39,11 +39,11 @@ import { TransactionType } from '../transactions/types'
import { VoteOption } from './types'
function useGovernanceV0Contract(): Contract | null {
return useContract(GOVERNANCE_ALPHA_V0_ADDRESSES, GOVERNANCE_ABI, false)
return useContract(GOVERNANCE_ALPHA_V0_ADDRESSES, GovernorAlphaJSON.abi, false)
}
function useGovernanceV1Contract(): Contract | null {
return useContract(GOVERNANCE_ALPHA_V1_ADDRESSES, GOVERNANCE_ABI, false)
return useContract(GOVERNANCE_ALPHA_V1_ADDRESSES, GovernorAlphaJSON.abi, false)
}
function useGovernanceBravoContract(): Contract | null {
@@ -55,7 +55,7 @@ const useLatestGovernanceContract = useGovernanceBravoContract
function useUniContract() {
const { chainId } = useWeb3React()
const uniAddress = useMemo(() => (chainId ? UNI[chainId]?.address : undefined), [chainId])
return useContract(uniAddress, UNI_ABI, true)
return useContract(uniAddress, UniJSON.abi, true)
}
interface ProposalDetail {
@@ -99,7 +99,7 @@ export enum ProposalState {
EXECUTED,
}
const GovernanceInterface = new Interface(GOVERNANCE_ABI)
const GovernanceInterface = new Interface(GovernorAlphaJSON.abi)
// get count of all proposals made in the latest governor contract
function useProposalCount(contract: Contract | null): number | undefined {

View File

@@ -14,7 +14,15 @@ const store = configureStore({
reducer,
enhancers: (defaultEnhancers) => defaultEnhancers.concat(sentryEnhancer),
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ thunk: true })
getDefaultMiddleware({
thunk: true,
serializableCheck: {
// meta.arg and meta.baseQueryMeta are defaults. payload.trade is a nonserializable return value, but that's ok
// because we are not adding it into any persisted store that requires serialization (e.g. localStorage)
ignoredActionPaths: ['meta.arg', 'meta.baseQueryMeta', 'payload.trade'],
ignoredPaths: [routingApi.reducerPath],
},
})
.concat(routingApi.middleware)
.concat(save({ states: PERSISTED_KEYS, debounce: 1000 })),
preloadedState: load({ states: PERSISTED_KEYS, disableWarnings: isTestEnv() }),

View File

@@ -1,5 +1,6 @@
import { createApi, fetchBaseQuery, FetchBaseQueryError } from '@reduxjs/toolkit/query/react'
import { Protocol } from '@uniswap/router-sdk'
import { TradeType } from '@uniswap/sdk-core'
import { AlphaRouter, ChainId } from '@uniswap/smart-order-router'
import { RPC_PROVIDERS } from 'constants/providers'
import { getClientSideQuote, toSupportedChainId } from 'lib/hooks/routing/clientSideSmartOrderRouter'
@@ -7,7 +8,8 @@ import ms from 'ms.macro'
import qs from 'qs'
import { trace } from 'tracing/trace'
import { GetQuoteResult } from './types'
import { QuoteData, TradeResult } from './types'
import { isExactInput, transformRoutesToTrade } from './utils'
export enum RouterPreference {
AUTO = 'auto',
@@ -69,7 +71,7 @@ const PRICE_PARAMS = {
distributionPercent: 100,
}
interface GetQuoteArgs {
export interface GetQuoteArgs {
tokenInAddress: string
tokenInChainId: ChainId
tokenInDecimals: number
@@ -80,7 +82,12 @@ interface GetQuoteArgs {
tokenOutSymbol?: string
amount: string
routerPreference: RouterPreference | typeof INTERNAL_ROUTER_PREFERENCE_PRICE
type: 'exactIn' | 'exactOut'
tradeType: TradeType
}
enum QuoteState {
SUCCESS = 'Success',
NOT_FOUND = 'Not found',
}
export const routingApi = createApi({
@@ -89,7 +96,7 @@ export const routingApi = createApi({
baseUrl: 'https://api.uniswap.org/v1/',
}),
endpoints: (build) => ({
getQuote: build.query<GetQuoteResult, GetQuoteArgs>({
getQuote: build.query<TradeResult, GetQuoteArgs>({
async onQueryStarted(args: GetQuoteArgs, { queryFulfilled }) {
trace(
'quote',
@@ -119,11 +126,14 @@ export const routingApi = createApi({
)
},
async queryFn(args, _api, _extraOptions, fetch) {
const { tokenInAddress, tokenInChainId, tokenOutAddress, tokenOutChainId, amount, routerPreference, type } =
args
try {
if (routerPreference === RouterPreference.API) {
if (
args.routerPreference === RouterPreference.API ||
args.routerPreference === RouterPreference.AUTO ||
args.routerPreference === INTERNAL_ROUTER_PREFERENCE_PRICE
) {
try {
const { tokenInAddress, tokenInChainId, tokenOutAddress, tokenOutChainId, amount, tradeType } = args
const type = isExactInput(tradeType) ? 'exactIn' : 'exactOut'
const query = qs.stringify({
...API_QUERY_PARAMS,
tokenInAddress,
@@ -133,21 +143,40 @@ export const routingApi = createApi({
amount,
type,
})
return (await fetch(`quote?${query}`)) as { data: GetQuoteResult } | { error: FetchBaseQueryError }
} else {
const router = getRouter(args.tokenInChainId)
return await getClientSideQuote(
args,
router,
// TODO(zzmp): Use PRICE_PARAMS for RouterPreference.PRICE.
// This change is intentionally being deferred to first see what effect router caching has.
CLIENT_PARAMS
const response = await fetch(`quote?${query}`)
if (response.error) {
try {
// cast as any here because we do a runtime check on it being an object before indexing into .errorCode
const errorData = response.error.data as any
// NO_ROUTE should be treated as a valid response to prevent retries.
if (typeof errorData === 'object' && errorData?.errorCode === 'NO_ROUTE') {
return { data: { state: QuoteState.NOT_FOUND } }
}
} catch {
throw response.error
}
}
const quoteData = response.data as QuoteData
const tradeResult = transformRoutesToTrade(args, quoteData)
return { data: tradeResult }
} catch (error: any) {
console.warn(
`GetQuote failed on routing API, falling back to client: ${error?.message ?? error?.detail ?? error}`
)
}
} catch (error) {
// TODO: fall back to client-side quoter when auto router fails.
// deprecate 'legacy' v2/v3 routers first.
return { error: { status: 'CUSTOM_ERROR', error: error.toString() } }
}
try {
const router = getRouter(args.tokenInChainId)
const quoteResult = await getClientSideQuote(args, router, CLIENT_PARAMS)
if (quoteResult.state === QuoteState.SUCCESS) {
return { data: transformRoutesToTrade(args, quoteResult.data) }
} else {
return { data: quoteResult }
}
} catch (error: any) {
console.warn(`GetQuote failed on client: ${error}`)
return { error: { status: 'CUSTOM_ERROR', error: error?.detail ?? error?.message ?? error } }
}
},
keepUnusedDataFor: ms`10s`,

View File

@@ -8,7 +8,6 @@ export enum TradeState {
INVALID,
NO_ROUTE_FOUND,
VALID,
SYNCING,
}
// from https://github.com/Uniswap/routing-api/blob/main/lib/handlers/schema.ts
@@ -49,7 +48,7 @@ export type V2PoolInRoute = {
address?: string
}
export interface GetQuoteResult {
export interface QuoteData {
quoteId?: string
blockNumber: string
amount: string
@@ -68,12 +67,12 @@ export interface GetQuoteResult {
routeString: string
}
export class InterfaceTrade<
export class ClassicTrade<
TInput extends Currency,
TOutput extends Currency,
TTradeType extends TradeType
> extends Trade<TInput, TOutput, TTradeType> {
gasUseEstimateUSD: CurrencyAmount<Token> | null | undefined
gasUseEstimateUSD: string | null | undefined
blockNumber: string | null | undefined
constructor({
@@ -81,8 +80,8 @@ export class InterfaceTrade<
blockNumber,
...routes
}: {
gasUseEstimateUSD?: CurrencyAmount<Token> | undefined | null
blockNumber?: string | null | undefined
gasUseEstimateUSD?: string | null
blockNumber?: string | null
v2Routes: {
routev2: V2Route<TInput, TOutput>
inputAmount: CurrencyAmount<TInput>
@@ -105,3 +104,42 @@ export class InterfaceTrade<
this.gasUseEstimateUSD = gasUseEstimateUSD
}
}
export type InterfaceTrade = ClassicTrade<Currency, Currency, TradeType>
export enum QuoteState {
SUCCESS = 'Success',
NOT_FOUND = 'Not found',
}
export type QuoteResult =
| {
state: QuoteState.NOT_FOUND
data?: undefined
}
| {
state: QuoteState.SUCCESS
data: QuoteData
}
export type TradeResult =
| {
state: QuoteState.NOT_FOUND
trade?: undefined
}
| {
state: QuoteState.SUCCESS
trade: InterfaceTrade
}
export enum PoolType {
V2Pool = 'v2-pool',
V3Pool = 'v3-pool',
}
// swap router API special cases these strings to represent native currencies
// all chains have "ETH" as native currency symbol except for polygon
export enum SwapRouterNativeAssets {
MATIC = 'MATIC',
ETH = 'ETH',
}

View File

@@ -3,14 +3,16 @@ import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { IMetric, MetricLoggerUnit, setGlobalMetric } from '@uniswap/smart-order-router'
import { sendTiming } from 'components/analytics'
import { AVERAGE_L1_BLOCK_TIME } from 'constants/chainInfo'
import { useStablecoinAmountFromFiatValue } from 'hooks/useStablecoinPrice'
import { useRoutingAPIArguments } from 'lib/hooks/routing/useRoutingAPIArguments'
import ms from 'ms.macro'
import { useMemo } from 'react'
import { INTERNAL_ROUTER_PREFERENCE_PRICE, RouterPreference, useGetQuoteQuery } from 'state/routing/slice'
import { InterfaceTrade, TradeState } from './types'
import { computeRoutes, transformRoutesToTrade } from './utils'
import { InterfaceTrade, QuoteState, TradeState } from './types'
const TRADE_INVALID = { state: TradeState.INVALID, trade: undefined } as const
const TRADE_NOT_FOUND = { state: TradeState.NO_ROUTE_FOUND, trade: undefined } as const
const TRADE_LOADING = { state: TradeState.LOADING, trade: undefined } as const
/**
* Returns the best trade by invoking the routing api or the smart order router on the client
@@ -25,7 +27,7 @@ export function useRoutingAPITrade<TTradeType extends TradeType>(
routerPreference: RouterPreference | typeof INTERNAL_ROUTER_PREFERENCE_PRICE
): {
state: TradeState
trade: InterfaceTrade<Currency, Currency, TTradeType> | undefined
trade?: InterfaceTrade
} {
const [currencyIn, currencyOut]: [Currency | undefined, Currency | undefined] = useMemo(
() =>
@@ -44,10 +46,9 @@ export function useRoutingAPITrade<TTradeType extends TradeType>(
})
const {
isLoading,
isError,
data: quoteResult,
currentData,
data: tradeResult,
currentData: currentTradeResult,
} = useGetQuoteQuery(queryArgs ?? skipToken, {
// Price-fetching is informational and costly, so it's done less frequently.
pollingInterval: routerPreference === INTERNAL_ROUTER_PREFERENCE_PRICE ? ms`1m` : AVERAGE_L1_BLOCK_TIME,
@@ -55,72 +56,23 @@ export function useRoutingAPITrade<TTradeType extends TradeType>(
refetchOnMountOrArgChange: 2 * 60,
})
const route = useMemo(
() => computeRoutes(currencyIn, currencyOut, tradeType, quoteResult),
[currencyIn, currencyOut, quoteResult, tradeType]
)
// get USD gas cost of trade in active chains stablecoin amount
const gasUseEstimateUSD = useStablecoinAmountFromFiatValue(quoteResult?.gasUseEstimateUSD) ?? null
const isSyncing = currentData !== quoteResult
const isCurrent = currentTradeResult === tradeResult
return useMemo(() => {
if (!currencyIn || !currencyOut || currencyIn.equals(currencyOut)) {
if (!amountSpecified || isError || !queryArgs) {
return TRADE_INVALID
} else if (tradeResult?.state === QuoteState.NOT_FOUND && isCurrent) {
return TRADE_NOT_FOUND
} else if (!tradeResult?.trade) {
// TODO(WEB-3307): use `isLoading` returned by rtk-query hook instead of checking for `trade` status
return TRADE_LOADING
} else {
return {
state: TradeState.INVALID,
trade: undefined,
state: isCurrent ? TradeState.VALID : TradeState.LOADING,
trade: tradeResult.trade,
}
}
if (isLoading && !quoteResult) {
// only on first hook render
return {
state: TradeState.LOADING,
trade: undefined,
}
}
let otherAmount = undefined
if (quoteResult) {
if (tradeType === TradeType.EXACT_INPUT && currencyOut) {
otherAmount = CurrencyAmount.fromRawAmount(currencyOut, quoteResult.quote)
}
if (tradeType === TradeType.EXACT_OUTPUT && currencyIn) {
otherAmount = CurrencyAmount.fromRawAmount(currencyIn, quoteResult.quote)
}
}
if (isError || !otherAmount || !route || route.length === 0 || !queryArgs) {
return {
state: TradeState.NO_ROUTE_FOUND,
trade: undefined,
}
}
try {
const trade = transformRoutesToTrade(route, tradeType, quoteResult?.blockNumber, gasUseEstimateUSD)
return {
// always return VALID regardless of isFetching status
state: isSyncing ? TradeState.SYNCING : TradeState.VALID,
trade,
}
} catch (e) {
return { state: TradeState.INVALID, trade: undefined }
}
}, [
currencyIn,
currencyOut,
quoteResult,
isLoading,
tradeType,
isError,
route,
queryArgs,
gasUseEstimateUSD,
isSyncing,
])
}, [amountSpecified, isCurrent, isError, queryArgs, tradeResult])
}
// only want to enable this when app hook called

View File

@@ -1,51 +1,42 @@
import { Token, TradeType } from '@uniswap/sdk-core'
import { Token } from '@uniswap/sdk-core'
import { SupportedChainId } from 'constants/chains'
import { nativeOnChain } from 'constants/tokens'
import { nativeOnChain } from '../../constants/tokens'
import { PoolType } from './types'
import { computeRoutes } from './utils'
const USDC = new Token(1, '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', 6, 'USDC')
const DAI = new Token(1, '0x6B175474E89094C44Da98b954EedeAC495271d0F', 6, 'DAI')
const MKR = new Token(1, '0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2', 6, 'MKR')
const ETH = nativeOnChain(1)
const ETH = nativeOnChain(SupportedChainId.MAINNET)
// helper function to make amounts more readable
const amount = (raw: TemplateStringsArray) => (parseInt(raw[0]) * 1e6).toString()
describe('#useRoute', () => {
it('handles an undefined payload', () => {
const result = computeRoutes(undefined, undefined, TradeType.EXACT_INPUT, undefined)
expect(result).toBeUndefined()
})
it('handles empty edges and nodes', () => {
const result = computeRoutes(USDC, DAI, TradeType.EXACT_INPUT, {
route: [],
})
const result = computeRoutes(false, false, [])
expect(result).toEqual([])
})
it('handles a single route trade from DAI to USDC from v3', () => {
const result = computeRoutes(DAI, USDC, TradeType.EXACT_INPUT, {
route: [
[
{
type: 'v3-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`1`,
amountOut: amount`5`,
fee: '500',
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
tokenIn: DAI,
tokenOut: USDC,
},
],
const result = computeRoutes(false, false, [
[
{
type: 'v3-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`1`,
amountOut: amount`5`,
fee: '500',
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
tokenIn: DAI,
tokenOut: USDC,
},
],
})
])
const r = result?.[0]
@@ -60,28 +51,26 @@ describe('#useRoute', () => {
})
it('handles a single route trade from DAI to USDC from v2', () => {
const result = computeRoutes(DAI, USDC, TradeType.EXACT_INPUT, {
route: [
[
{
type: 'v2-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`1`,
amountOut: amount`5`,
tokenIn: DAI,
tokenOut: USDC,
reserve0: {
token: DAI,
quotient: amount`100`,
},
reserve1: {
token: USDC,
quotient: amount`200`,
},
const result = computeRoutes(false, false, [
[
{
type: 'v2-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`1`,
amountOut: amount`5`,
tokenIn: DAI,
tokenOut: USDC,
reserve0: {
token: DAI,
quotient: amount`100`,
},
],
reserve1: {
token: USDC,
quotient: amount`200`,
},
},
],
})
])
const r = result?.[0]
@@ -96,54 +85,52 @@ describe('#useRoute', () => {
})
it('handles a multi-route trade from DAI to USDC', () => {
const result = computeRoutes(DAI, USDC, TradeType.EXACT_OUTPUT, {
route: [
[
{
type: 'v2-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`5`,
amountOut: amount`6`,
tokenIn: DAI,
tokenOut: USDC,
reserve0: {
token: DAI,
quotient: amount`1000`,
},
reserve1: {
token: USDC,
quotient: amount`500`,
},
const result = computeRoutes(false, false, [
[
{
type: 'v2-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`5`,
amountOut: amount`6`,
tokenIn: DAI,
tokenOut: USDC,
reserve0: {
token: DAI,
quotient: amount`1000`,
},
],
[
{
type: 'v3-pool',
address: '0x2f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`10`,
amountOut: amount`1`,
fee: '3000',
tokenIn: DAI,
tokenOut: MKR,
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
reserve1: {
token: USDC,
quotient: amount`500`,
},
{
type: 'v3-pool',
address: '0x3f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`1`,
amountOut: amount`200`,
fee: '10000',
tokenIn: MKR,
tokenOut: USDC,
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
},
],
},
],
})
[
{
type: 'v3-pool',
address: '0x2f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`10`,
amountOut: amount`1`,
fee: '3000',
tokenIn: DAI,
tokenOut: MKR,
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
},
{
type: 'v3-pool',
address: '0x3f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`1`,
amountOut: amount`200`,
fee: '10000',
tokenIn: MKR,
tokenOut: USDC,
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
},
],
])
expect(result).toBeDefined()
expect(result?.length).toBe(2)
@@ -165,38 +152,36 @@ describe('#useRoute', () => {
})
it('handles a single route trade with same token pair, different fee tiers', () => {
const result = computeRoutes(DAI, USDC, TradeType.EXACT_INPUT, {
route: [
[
{
type: 'v3-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`1`,
amountOut: amount`5`,
fee: '500',
tokenIn: DAI,
tokenOut: USDC,
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
},
],
[
{
type: 'v3-pool',
address: '0x2f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`10`,
amountOut: amount`50`,
fee: '3000',
tokenIn: DAI,
tokenOut: USDC,
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
},
],
const result = computeRoutes(false, false, [
[
{
type: 'v3-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`1`,
amountOut: amount`5`,
fee: '500',
tokenIn: DAI,
tokenOut: USDC,
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
},
],
})
[
{
type: 'v3-pool',
address: '0x2f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`10`,
amountOut: amount`50`,
fee: '3000',
tokenIn: DAI,
tokenOut: USDC,
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
},
],
])
expect(result).toBeDefined()
expect(result?.length).toBe(2)
@@ -206,28 +191,68 @@ describe('#useRoute', () => {
expect(result?.[0].inputAmount.toSignificant()).toBe('1')
})
it('computes mixed routes correctly', () => {
const result = computeRoutes(false, false, [
[
{
type: PoolType.V3Pool,
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`1`,
amountOut: amount`5`,
fee: '500',
tokenIn: DAI,
tokenOut: USDC,
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
},
{
type: PoolType.V2Pool,
address: 'x2f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`10`,
amountOut: amount`50`,
tokenIn: USDC,
tokenOut: MKR,
reserve0: {
token: USDC,
quotient: amount`100`,
},
reserve1: {
token: MKR,
quotient: amount`200`,
},
},
],
])
expect(result).toBeDefined()
expect(result?.length).toBe(1)
expect(result?.[0].routev3).toBeNull()
expect(result?.[0].routev2).toBeNull()
expect(result?.[0].mixedRoute?.output).toStrictEqual(MKR)
expect(result?.[0].inputAmount.toSignificant()).toBe('1')
})
describe('with ETH', () => {
it('outputs native ETH as input currency', () => {
const WETH = ETH.wrapped
const result = computeRoutes(ETH, USDC, TradeType.EXACT_OUTPUT, {
route: [
[
{
type: 'v3-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: (1e18).toString(),
amountOut: amount`5`,
fee: '500',
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
tokenIn: WETH,
tokenOut: USDC,
},
],
const result = computeRoutes(true, false, [
[
{
type: 'v3-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: (1e18).toString(),
amountOut: amount`5`,
fee: '500',
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
tokenIn: WETH,
tokenOut: USDC,
},
],
})
])
expect(result).toBeDefined()
expect(result?.length).toBe(1)
@@ -239,24 +264,22 @@ describe('#useRoute', () => {
it('outputs native ETH as output currency', () => {
const WETH = new Token(1, ETH.wrapped.address, 18, 'WETH')
const result = computeRoutes(USDC, ETH, TradeType.EXACT_OUTPUT, {
route: [
[
{
type: 'v3-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`5`,
amountOut: (1e18).toString(),
fee: '500',
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
tokenIn: USDC,
tokenOut: WETH,
},
],
const result = computeRoutes(false, true, [
[
{
type: 'v3-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`5`,
amountOut: (1e18).toString(),
fee: '500',
sqrtRatioX96: '2437312313659959819381354528',
liquidity: '10272714736694327408',
tickCurrent: '-69633',
tokenIn: USDC,
tokenOut: WETH,
},
],
})
])
expect(result?.length).toBe(1)
expect(result?.[0].routev3?.input).toStrictEqual(USDC)
@@ -268,28 +291,26 @@ describe('#useRoute', () => {
it('outputs native ETH as input currency for v2 routes', () => {
const WETH = ETH.wrapped
const result = computeRoutes(ETH, USDC, TradeType.EXACT_OUTPUT, {
route: [
[
{
type: 'v2-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: (1e18).toString(),
amountOut: amount`5`,
tokenIn: WETH,
tokenOut: USDC,
reserve0: {
token: WETH,
quotient: amount`100`,
},
reserve1: {
token: USDC,
quotient: amount`200`,
},
const result = computeRoutes(true, false, [
[
{
type: 'v2-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: (1e18).toString(),
amountOut: amount`5`,
tokenIn: WETH,
tokenOut: USDC,
reserve0: {
token: WETH,
quotient: amount`100`,
},
],
reserve1: {
token: USDC,
quotient: amount`200`,
},
},
],
})
])
expect(result).toBeDefined()
expect(result?.length).toBe(1)
@@ -301,28 +322,26 @@ describe('#useRoute', () => {
it('outputs native ETH as output currency for v2 routes', () => {
const WETH = new Token(1, ETH.wrapped.address, 18, 'WETH')
const result = computeRoutes(USDC, ETH, TradeType.EXACT_OUTPUT, {
route: [
[
{
type: 'v2-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`5`,
amountOut: (1e18).toString(),
tokenIn: USDC,
tokenOut: WETH,
reserve0: {
token: WETH,
quotient: amount`100`,
},
reserve1: {
token: USDC,
quotient: amount`200`,
},
const result = computeRoutes(false, true, [
[
{
type: 'v2-pool',
address: '0x1f8F72aA9304c8B593d555F12eF6589cC3A579A2',
amountIn: amount`5`,
amountOut: (1e18).toString(),
tokenIn: USDC,
tokenOut: WETH,
reserve0: {
token: WETH,
quotient: amount`100`,
},
],
reserve1: {
token: USDC,
quotient: amount`200`,
},
},
],
})
])
expect(result?.length).toBe(1)
expect(result?.[0].routev2?.input).toStrictEqual(USDC)

View File

@@ -1,32 +1,50 @@
import { MixedRouteSDK, Protocol } from '@uniswap/router-sdk'
import { MixedRouteSDK } from '@uniswap/router-sdk'
import { Currency, CurrencyAmount, Token, TradeType } from '@uniswap/sdk-core'
import { Pair, Route as V2Route } from '@uniswap/v2-sdk'
import { FeeAmount, Pool, Route as V3Route } from '@uniswap/v3-sdk'
import { isPolygonChain } from 'constants/chains'
import { nativeOnChain } from 'constants/tokens'
import { GetQuoteResult, InterfaceTrade, V2PoolInRoute, V3PoolInRoute } from './types'
import { GetQuoteArgs } from './slice'
import {
ClassicTrade,
PoolType,
QuoteData,
QuoteState,
SwapRouterNativeAssets,
TradeResult,
V2PoolInRoute,
V3PoolInRoute,
} from './types'
/**
* Transforms a Routing API quote into an array of routes that can be used to create
* a `Trade`.
* Transforms a Routing API quote into an array of routes that can be used to
* create a `Trade`.
*/
export function computeRoutes(
currencyIn: Currency | undefined,
currencyOut: Currency | undefined,
tradeType: TradeType,
quoteResult: Pick<GetQuoteResult, 'route'> | undefined
) {
if (!quoteResult || !quoteResult.route || !currencyIn || !currencyOut) return undefined
tokenInIsNative: boolean,
tokenOutIsNative: boolean,
routes: QuoteData['route']
):
| {
routev3: V3Route<Currency, Currency> | null
routev2: V2Route<Currency, Currency> | null
mixedRoute: MixedRouteSDK<Currency, Currency> | null
inputAmount: CurrencyAmount<Currency>
outputAmount: CurrencyAmount<Currency>
}[]
| undefined {
if (routes.length === 0) return []
if (quoteResult.route.length === 0) return []
const tokenIn = routes[0]?.[0]?.tokenIn
const tokenOut = routes[0]?.[routes[0]?.length - 1]?.tokenOut
if (!tokenIn || !tokenOut) throw new Error('Expected both tokenIn and tokenOut to be present')
const parsedTokenIn = parseToken(quoteResult.route[0][0].tokenIn)
const parsedTokenOut = parseToken(quoteResult.route[0][quoteResult.route[0].length - 1].tokenOut)
if (parsedTokenIn.address !== currencyIn.wrapped.address) return undefined
if (parsedTokenOut.address !== currencyOut.wrapped.address) return undefined
if (parsedTokenIn.wrapped.equals(parsedTokenOut.wrapped)) return undefined
const parsedCurrencyIn = tokenInIsNative ? nativeOnChain(tokenIn.chainId) : parseToken(tokenIn)
const parsedCurrencyOut = tokenOutIsNative ? nativeOnChain(tokenOut.chainId) : parseToken(tokenOut)
try {
return quoteResult.route.map((route) => {
return routes.map((route) => {
if (route.length === 0) {
throw new Error('Expected route to have at least one pair or pool')
}
@@ -37,68 +55,90 @@ export function computeRoutes(
throw new Error('Expected both amountIn and amountOut to be present')
}
const routeProtocol = getRouteProtocol(route)
const isOnlyV2 = isVersionedRoute<V2PoolInRoute>(PoolType.V2Pool, route)
const isOnlyV3 = isVersionedRoute<V3PoolInRoute>(PoolType.V3Pool, route)
return {
routev3:
routeProtocol === Protocol.V3
? new V3Route(route.map(genericPoolPairParser) as Pool[], currencyIn, currencyOut)
: null,
routev2:
routeProtocol === Protocol.V2
? new V2Route(route.map(genericPoolPairParser) as Pair[], currencyIn, currencyOut)
: null,
routev3: isOnlyV3 ? new V3Route(route.map(parsePool), parsedCurrencyIn, parsedCurrencyOut) : null,
routev2: isOnlyV2 ? new V2Route(route.map(parsePair), parsedCurrencyIn, parsedCurrencyOut) : null,
mixedRoute:
routeProtocol === Protocol.MIXED
? new MixedRouteSDK(route.map(genericPoolPairParser), currencyIn, currencyOut)
!isOnlyV3 && !isOnlyV2
? new MixedRouteSDK(route.map(parsePoolOrPair), parsedCurrencyIn, parsedCurrencyOut)
: null,
inputAmount: CurrencyAmount.fromRawAmount(currencyIn, rawAmountIn),
outputAmount: CurrencyAmount.fromRawAmount(currencyOut, rawAmountOut),
inputAmount: CurrencyAmount.fromRawAmount(parsedCurrencyIn, rawAmountIn),
outputAmount: CurrencyAmount.fromRawAmount(parsedCurrencyOut, rawAmountOut),
}
})
} catch (e) {
// `Route` constructor may throw if inputs/outputs are temporarily out of sync
// (RTK-Query always returns the latest data which may not be the right inputs/outputs)
// This is not fatal and will fix itself in future render cycles
console.error(e)
return undefined
console.error('Error computing routes', e)
return
}
}
export function transformRoutesToTrade<TTradeType extends TradeType>(
route: ReturnType<typeof computeRoutes>,
tradeType: TTradeType,
blockNumber?: string | null,
gasUseEstimateUSD?: CurrencyAmount<Token> | null
): InterfaceTrade<Currency, Currency, TTradeType> {
return new InterfaceTrade({
v2Routes:
route
?.filter((r): r is typeof route[0] & { routev2: NonNullable<typeof route[0]['routev2']> } => r.routev2 !== null)
.map(({ routev2, inputAmount, outputAmount }) => ({ routev2, inputAmount, outputAmount })) ?? [],
v3Routes:
route
?.filter((r): r is typeof route[0] & { routev3: NonNullable<typeof route[0]['routev3']> } => r.routev3 !== null)
.map(({ routev3, inputAmount, outputAmount }) => ({ routev3, inputAmount, outputAmount })) ?? [],
mixedRoutes:
route
?.filter(
(r): r is typeof route[0] & { mixedRoute: NonNullable<typeof route[0]['mixedRoute']> } =>
r.mixedRoute !== null
)
.map(({ mixedRoute, inputAmount, outputAmount }) => ({ mixedRoute, inputAmount, outputAmount })) ?? [],
tradeType,
gasUseEstimateUSD,
blockNumber,
})
const parsePoolOrPair = (pool: V3PoolInRoute | V2PoolInRoute): Pool | Pair => {
return pool.type === PoolType.V3Pool ? parsePool(pool) : parsePair(pool)
}
const parseToken = ({ address, chainId, decimals, symbol }: GetQuoteResult['route'][0][0]['tokenIn']): Token => {
function isVersionedRoute<T extends V2PoolInRoute | V3PoolInRoute>(
type: T['type'],
route: (V3PoolInRoute | V2PoolInRoute)[]
): route is T[] {
return route.every((pool) => pool.type === type)
}
export function transformRoutesToTrade(args: GetQuoteArgs, data: QuoteData): TradeResult {
const { tokenInAddress, tokenOutAddress, tradeType } = args
const tokenInIsNative = Object.values(SwapRouterNativeAssets).includes(tokenInAddress as SwapRouterNativeAssets)
const tokenOutIsNative = Object.values(SwapRouterNativeAssets).includes(tokenOutAddress as SwapRouterNativeAssets)
const { gasUseEstimateUSD, blockNumber } = data
const routes = computeRoutes(tokenInIsNative, tokenOutIsNative, data.route)
const trade = new ClassicTrade({
v2Routes:
routes
?.filter(
(r): r is typeof routes[0] & { routev2: NonNullable<typeof routes[0]['routev2']> } => r.routev2 !== null
)
.map(({ routev2, inputAmount, outputAmount }) => ({
routev2,
inputAmount,
outputAmount,
})) ?? [],
v3Routes:
routes
?.filter(
(r): r is typeof routes[0] & { routev3: NonNullable<typeof routes[0]['routev3']> } => r.routev3 !== null
)
.map(({ routev3, inputAmount, outputAmount }) => ({
routev3,
inputAmount,
outputAmount,
})) ?? [],
mixedRoutes:
routes
?.filter(
(r): r is typeof routes[0] & { mixedRoute: NonNullable<typeof routes[0]['mixedRoute']> } =>
r.mixedRoute !== null
)
.map(({ mixedRoute, inputAmount, outputAmount }) => ({
mixedRoute,
inputAmount,
outputAmount,
})) ?? [],
tradeType,
gasUseEstimateUSD: parseFloat(gasUseEstimateUSD).toFixed(2).toString(),
blockNumber,
})
return { state: QuoteState.SUCCESS, trade }
}
function parseToken({ address, chainId, decimals, symbol }: QuoteData['route'][0][0]['tokenIn']): Token {
return new Token(chainId, address, parseInt(decimals.toString()), symbol)
}
const parsePool = ({ fee, sqrtRatioX96, liquidity, tickCurrent, tokenIn, tokenOut }: V3PoolInRoute): Pool =>
new Pool(
function parsePool({ fee, sqrtRatioX96, liquidity, tickCurrent, tokenIn, tokenOut }: V3PoolInRoute): Pool {
return new Pool(
parseToken(tokenIn),
parseToken(tokenOut),
parseInt(fee) as FeeAmount,
@@ -106,6 +146,7 @@ const parsePool = ({ fee, sqrtRatioX96, liquidity, tickCurrent, tokenIn, tokenOu
liquidity,
parseInt(tickCurrent)
)
}
const parsePair = ({ reserve0, reserve1 }: V2PoolInRoute): Pair =>
new Pair(
@@ -113,12 +154,15 @@ const parsePair = ({ reserve0, reserve1 }: V2PoolInRoute): Pair =>
CurrencyAmount.fromRawAmount(parseToken(reserve1.token), reserve1.quotient)
)
const genericPoolPairParser = (pool: V3PoolInRoute | V2PoolInRoute): Pool | Pair => {
return pool.type === 'v3-pool' ? parsePool(pool) : parsePair(pool)
// TODO(WEB-2050): Convert other instances of tradeType comparison to use this utility function
export function isExactInput(tradeType: TradeType): boolean {
return tradeType === TradeType.EXACT_INPUT
}
function getRouteProtocol(route: (V3PoolInRoute | V2PoolInRoute)[]): Protocol {
if (route.every((pool) => pool.type === 'v2-pool')) return Protocol.V2
if (route.every((pool) => pool.type === 'v3-pool')) return Protocol.V3
return Protocol.MIXED
export function currencyAddressForSwapQuote(currency: Currency): string {
if (currency.isNative) {
return isPolygonChain(currency.chainId) ? SwapRouterNativeAssets.MATIC : SwapRouterNativeAssets.ETH
}
return currency.address
}

View File

@@ -1,5 +1,5 @@
import { Interface } from '@ethersproject/abi'
import { abi as STAKING_REWARDS_ABI } from '@uniswap/liquidity-staker/build/StakingRewards.json'
import StakingRewardsJSON from '@uniswap/liquidity-staker/build/StakingRewards.json'
import { CurrencyAmount, Token } from '@uniswap/sdk-core'
import { Pair } from '@uniswap/v2-sdk'
import { useWeb3React } from '@web3-react/core'
@@ -11,7 +11,7 @@ import { useMemo } from 'react'
import { DAI, UNI, USDC_MAINNET, USDT, WBTC, WRAPPED_NATIVE_CURRENCY } from '../../constants/tokens'
const STAKING_REWARDS_INTERFACE = new Interface(STAKING_REWARDS_ABI)
const STAKING_REWARDS_INTERFACE = new Interface(StakingRewardsJSON.abi)
export const STAKING_GENESIS = 1600387200

View File

@@ -81,7 +81,7 @@ export function useDerivedSwapInfo(
parsedAmount: CurrencyAmount<Currency> | undefined
inputError?: ReactNode
trade: {
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined
trade?: InterfaceTrade
state: TradeState
}
allowedSlippage: Percent

View File

@@ -149,10 +149,16 @@ export function useUserSlippageTolerance(): [
[dispatch]
)
return useMemo(
() => [userSlippageTolerance, setUserSlippageTolerance],
[setUserSlippageTolerance, userSlippageTolerance]
)
return [userSlippageTolerance, setUserSlippageTolerance]
}
/**
*Returns user slippage tolerance, replacing the auto with a default value
* @param defaultSlippageTolerance the value to replace auto with
*/
export function useUserSlippageToleranceWithDefault(defaultSlippageTolerance: Percent): Percent {
const [allowedSlippage] = useUserSlippageTolerance()
return allowedSlippage === SlippageTolerance.Auto ? defaultSlippageTolerance : allowedSlippage
}
export function useUserHideClosedPositions(): [boolean, (newHideClosedPositions: boolean) => void] {
@@ -170,18 +176,6 @@ export function useUserHideClosedPositions(): [boolean, (newHideClosedPositions:
return [hideClosedPositions, setHideClosedPositions]
}
/**
* Same as above but replaces the auto with a default value
* @param defaultSlippageTolerance the default value to replace auto with
*/
export function useUserSlippageToleranceWithDefault(defaultSlippageTolerance: Percent): Percent {
const allowedSlippage = useUserSlippageTolerance()[0]
return useMemo(
() => (allowedSlippage === SlippageTolerance.Auto ? defaultSlippageTolerance : allowedSlippage),
[allowedSlippage, defaultSlippageTolerance]
)
}
export function useUserTransactionTTL(): [number, (slippage: number) => void] {
const { chainId } = useWeb3React()
const dispatch = useAppDispatch()

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