From 28181671317e0abf0ba83654e3ddd13bc205cba2 Mon Sep 17 00:00:00 2001 From: charity-sock-pacifist <147960839+charity-sock-pacifist@users.noreply.github.com> Date: Mon, 16 Oct 2023 19:32:39 -0400 Subject: [PATCH] feat: swap fees [main] (#7478) --- cypress/e2e/swap/fees.test.ts | 149 ++ cypress/fixtures/uniswapx/feeQuote.json | 562 +++++++ cypress/tsconfig.json | 2 +- package.json | 4 +- .../FeatureFlagModal/FeatureFlagModal.tsx | 7 + .../PrefetchBalancesWrapper.tsx | 1 + src/components/SearchModal/CommonBases.tsx | 16 +- .../SearchModal/CurrencyList/index.tsx | 5 +- src/components/swap/GasBreakdownTooltip.tsx | 5 +- .../swap/SwapDetailsDropdown.test.tsx | 2 + src/components/swap/SwapDetailsDropdown.tsx | 1 + src/components/swap/SwapLineItem.test.tsx | 2 + src/components/swap/SwapLineItem.tsx | 53 +- src/components/swap/SwapModalFooter.test.tsx | 2 + src/components/swap/SwapModalFooter.tsx | 1 + .../SwapDetailsDropdown.test.tsx.snap | 23 + .../__snapshots__/SwapLineItem.test.tsx.snap | 1379 ++++++++++++++++- .../SwapModalFooter.test.tsx.snap | 45 + src/featureFlags/flags/useFees.ts | 9 + src/featureFlags/index.tsx | 1 + src/hooks/useSwapCallback.tsx | 19 +- src/hooks/useUniswapXSwapCallback.ts | 12 +- src/hooks/useUniversalRouter.ts | 22 +- .../hooks/routing/useRoutingAPIArguments.ts | 7 + src/lib/utils/analytics.ts | 25 +- src/pages/Swap/index.tsx | 16 +- src/state/routing/slice.ts | 21 +- src/state/routing/types.ts | 67 +- src/state/routing/utils.ts | 35 +- src/state/swap/hooks.tsx | 25 +- src/utils/formatNumbers.test.ts | 4 +- src/utils/formatNumbers.ts | 10 +- yarn.lock | 16 +- 33 files changed, 2475 insertions(+), 73 deletions(-) create mode 100644 cypress/e2e/swap/fees.test.ts create mode 100644 cypress/fixtures/uniswapx/feeQuote.json create mode 100644 src/featureFlags/flags/useFees.ts diff --git a/cypress/e2e/swap/fees.test.ts b/cypress/e2e/swap/fees.test.ts new file mode 100644 index 0000000000..aaae86b2c8 --- /dev/null +++ b/cypress/e2e/swap/fees.test.ts @@ -0,0 +1,149 @@ +import { CurrencyAmount } from '@uniswap/sdk-core' +import { FeatureFlag } from 'featureFlags' + +import { USDC_MAINNET } from '../../../src/constants/tokens' +import { getBalance, getTestSelector } from '../../utils' + +describe('Swap with fees', () => { + describe('Classic swaps', () => { + beforeEach(() => { + cy.visit('/swap', { featureFlags: [{ name: FeatureFlag.feesEnabled, value: true }] }) + + // Store trade quote into alias + cy.intercept({ url: 'https://api.uniswap.org/v2/quote' }, (req) => { + // Avoid tracking stablecoin pricing fetches + if (JSON.parse(req.body).intent !== 'pricing') req.alias = 'quoteFetch' + }) + }) + + it('displays $0 fee on swaps without fees', () => { + // Set up a stablecoin <> stablecoin swap (no fees) + cy.get('#swap-currency-input .open-currency-select-button').click() + cy.contains('DAI').click() + cy.get('#swap-currency-output .open-currency-select-button').click() + cy.contains('USDC').click() + cy.get('#swap-currency-output .token-amount-input').type('1') + + // Verify 0 fee UI is displayed + cy.get(getTestSelector('swap-details-header-row')).click() + cy.contains('Fee') + cy.contains('$0') + }) + + it('swaps ETH for USDC exact-out with swap fee', () => { + cy.hardhat().then((hardhat) => { + getBalance(USDC_MAINNET).then((initialBalance) => { + // Set up swap + cy.get('#swap-currency-output .open-currency-select-button').click() + cy.contains('USDC').click() + cy.get('#swap-currency-output .token-amount-input').type('1') + + cy.wait('@quoteFetch') + .its('response.body') + .then(({ quote: { portionBips, portionRecipient, portionAmount } }) => { + // Fees are generally expected to always be enabled for ETH -> USDC swaps + // If the routing api does not include a fee, end the test early rather than manually update routes and hardcode fee vars + if (portionRecipient) return + + cy.then(() => hardhat.getBalance(portionRecipient, USDC_MAINNET)).then((initialRecipientBalance) => { + const feeCurrencyAmount = CurrencyAmount.fromRawAmount(USDC_MAINNET, portionAmount) + + // Initiate transaction + cy.get('#swap-button').click() + cy.contains('Review swap') + + // Verify fee percentage and amount is displayed + cy.contains(`Fee (${portionBips / 100}%)`) + + // Confirm transaction + cy.contains('Confirm swap').click() + + // Verify transaction + cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending') + cy.get(getTestSelector('popups')).contains('Swapped') + + // Verify the post-fee output is the expected exact-out amount + const finalBalance = initialBalance + 1 + cy.get('#swap-currency-output').contains(`Balance: ${finalBalance}`) + getBalance(USDC_MAINNET).should('eq', finalBalance) + + // Verify fee recipient received fee + cy.then(() => hardhat.getBalance(portionRecipient, USDC_MAINNET)).then((finalRecipientBalance) => { + const expectedFinalRecipientBalance = initialRecipientBalance.add(feeCurrencyAmount) + cy.then(() => finalRecipientBalance.equalTo(expectedFinalRecipientBalance)).should('be.true') + }) + }) + }) + }) + }) + }) + + it('swaps ETH for USDC exact-in with swap fee', () => { + cy.hardhat().then((hardhat) => { + // Set up swap + cy.get('#swap-currency-output .open-currency-select-button').click() + cy.contains('USDC').click() + cy.get('#swap-currency-input .token-amount-input').type('.01') + + cy.wait('@quoteFetch') + .its('response.body') + .then(({ quote: { portionBips, portionRecipient } }) => { + // Fees are generally expected to always be enabled for ETH -> USDC swaps + // If the routing api does not include a fee, end the test early rather than manually update routes and hardcode fee vars + if (portionRecipient) return + + cy.then(() => hardhat.getBalance(portionRecipient, USDC_MAINNET)).then((initialRecipientBalance) => { + // Initiate transaction + cy.get('#swap-button').click() + cy.contains('Review swap') + + // Verify fee percentage and amount is displayed + cy.contains(`Fee (${portionBips / 100}%)`) + + // Confirm transaction + cy.contains('Confirm swap').click() + + // Verify transaction + cy.get(getTestSelector('web3-status-connected')).should('not.contain', 'Pending') + cy.get(getTestSelector('popups')).contains('Swapped') + + // Verify fee recipient received fee + cy.then(() => hardhat.getBalance(portionRecipient, USDC_MAINNET)).then((finalRecipientBalance) => { + cy.then(() => finalRecipientBalance.greaterThan(initialRecipientBalance)).should('be.true') + }) + }) + }) + }) + }) + }) + + describe('UniswapX swaps', () => { + it('displays UniswapX fee in UI', () => { + cy.visit('/swap', { + featureFlags: [ + { name: FeatureFlag.feesEnabled, value: true }, + { name: FeatureFlag.uniswapXDefaultEnabled, value: true }, + ], + }) + + // Intercept the trade quote + cy.intercept({ url: 'https://api.uniswap.org/v2/quote' }, (req) => { + // Avoid intercepting stablecoin pricing fetches + if (JSON.parse(req.body).intent !== 'pricing') { + req.reply({ fixture: 'uniswapx/feeQuote.json' }) + } + }) + + // Setup swap + cy.get('#swap-currency-input .open-currency-select-button').click() + cy.contains('USDC').click() + cy.get('#swap-currency-output .open-currency-select-button').click() + cy.contains('ETH').click() + cy.get('#swap-currency-input .token-amount-input').type('200') + + // Verify fee UI is displayed + cy.get(getTestSelector('swap-details-header-row')).click() + cy.contains('Fee (0.15%)') + }) + }) +}) diff --git a/cypress/fixtures/uniswapx/feeQuote.json b/cypress/fixtures/uniswapx/feeQuote.json new file mode 100644 index 0000000000..d2894a51df --- /dev/null +++ b/cypress/fixtures/uniswapx/feeQuote.json @@ -0,0 +1,562 @@ +{ + "routing": "DUTCH_LIMIT", + "quote": { + "orderInfo": { + "chainId": 1, + "permit2Address": "0x000000000022d473030f116ddee9f6b43ac78ba3", + "reactor": "0x6000da47483062A0D734Ba3dc7576Ce6A0B645C4", + "swapper": "0x0938a82F93D5DAB110Dc6277FC236b5b082DC10F", + "nonce": "1993353164669688581970088190602701610528397285201889446578254799128576197633", + "deadline": 1697481666, + "additionalValidationContract": "0x0000000000000000000000000000000000000000", + "additionalValidationData": "0x", + "decayStartTime": 1697481594, + "decayEndTime": 1697481654, + "exclusiveFiller": "0xaAFb85ad4a412dd8adC49611496a7695A22f4aeb", + "exclusivityOverrideBps": "100", + "input": { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "startAmount": "200000000", + "endAmount": "200000000" + }, + "outputs": [ + { + "token": "0x0000000000000000000000000000000000000000", + "startAmount": "123803169993201727", + "endAmount": "117908377342236273", + "recipient": "0x0938a82F93D5DAB110Dc6277FC236b5b082DC10F" + }, + { + "token": "0x0000000000000000000000000000000000000000", + "startAmount": "185983730585681", + "endAmount": "177128258400955", + "recipient": "0x37a8f295612602f2774d331e562be9e61B83a327" + } + ] + }, + "encodedOrder": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000652d837a00000000000000000000000000000000000000000000000000000000652d83b6000000000000000000000000aafb85ad4a412dd8adc49611496a7695a22f4aeb0000000000000000000000000000000000000000000000000000000000000064000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000bebc200000000000000000000000000000000000000000000000000000000000bebc20000000000000000000000000000000000000000000000000000000000000002000000000000000000000000006000da47483062a0d734ba3dc7576ce6a0b645c40000000000000000000000000938a82f93d5dab110dc6277fc236b5b082dc10f046832aa305880d33daa871e5041a0cd4853599a9ead518917239e206765040100000000000000000000000000000000000000000000000000000000652d83c2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001b7d653c183183f00000000000000000000000000000000000000000000000001a2e50b6386d6710000000000000000000000000938a82f93d5dab110dc6277fc236b5b082dc10f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a926b63210510000000000000000000000000000000000000000000000000000a118e2ebf2bb00000000000000000000000037a8f295612602f2774d331e562be9e61b83a327", + "quoteId": "7b924043-f2d8-4f2e-abaa-9f65fbe5f890", + "requestId": "a02ca0ca-7855-4dd0-9330-8b818aaeb59f", + "orderHash": "0xb5b4e3be188f6eb9dbe7e1489595829184a9ebfb5389185ed7ba7c03142278c9", + "startTimeBufferSecs": 45, + "auctionPeriodSecs": 60, + "deadlineBufferSecs": 12, + "slippageTolerance": "0.5", + "permitData": { + "domain": { + "name": "Permit2", + "chainId": 1, + "verifyingContract": "0x000000000022d473030f116ddee9f6b43ac78ba3" + }, + "types": { + "PermitWitnessTransferFrom": [ + { + "name": "permitted", + "type": "TokenPermissions" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "witness", + "type": "ExclusiveDutchOrder" + } + ], + "TokenPermissions": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ], + "ExclusiveDutchOrder": [ + { + "name": "info", + "type": "OrderInfo" + }, + { + "name": "decayStartTime", + "type": "uint256" + }, + { + "name": "decayEndTime", + "type": "uint256" + }, + { + "name": "exclusiveFiller", + "type": "address" + }, + { + "name": "exclusivityOverrideBps", + "type": "uint256" + }, + { + "name": "inputToken", + "type": "address" + }, + { + "name": "inputStartAmount", + "type": "uint256" + }, + { + "name": "inputEndAmount", + "type": "uint256" + }, + { + "name": "outputs", + "type": "DutchOutput[]" + } + ], + "OrderInfo": [ + { + "name": "reactor", + "type": "address" + }, + { + "name": "swapper", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "additionalValidationContract", + "type": "address" + }, + { + "name": "additionalValidationData", + "type": "bytes" + } + ], + "DutchOutput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + }, + { + "name": "recipient", + "type": "address" + } + ] + }, + "values": { + "permitted": { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": { + "type": "BigNumber", + "hex": "0x0bebc200" + } + }, + "spender": "0x6000da47483062A0D734Ba3dc7576Ce6A0B645C4", + "nonce": { + "type": "BigNumber", + "hex": "0x046832aa305880d33daa871e5041a0cd4853599a9ead518917239e2067650401" + }, + "deadline": 1697481666, + "witness": { + "info": { + "reactor": "0x6000da47483062A0D734Ba3dc7576Ce6A0B645C4", + "swapper": "0x0938a82F93D5DAB110Dc6277FC236b5b082DC10F", + "nonce": { + "type": "BigNumber", + "hex": "0x046832aa305880d33daa871e5041a0cd4853599a9ead518917239e2067650401" + }, + "deadline": 1697481666, + "additionalValidationContract": "0x0000000000000000000000000000000000000000", + "additionalValidationData": "0x" + }, + "decayStartTime": 1697481594, + "decayEndTime": 1697481654, + "exclusiveFiller": "0xaAFb85ad4a412dd8adC49611496a7695A22f4aeb", + "exclusivityOverrideBps": { + "type": "BigNumber", + "hex": "0x64" + }, + "inputToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "inputStartAmount": { + "type": "BigNumber", + "hex": "0x0bebc200" + }, + "inputEndAmount": { + "type": "BigNumber", + "hex": "0x0bebc200" + }, + "outputs": [ + { + "token": "0x0000000000000000000000000000000000000000", + "startAmount": { + "type": "BigNumber", + "hex": "0x01b7d653c183183f" + }, + "endAmount": { + "type": "BigNumber", + "hex": "0x01a2e50b6386d671" + }, + "recipient": "0x0938a82F93D5DAB110Dc6277FC236b5b082DC10F" + }, + { + "token": "0x0000000000000000000000000000000000000000", + "startAmount": { + "type": "BigNumber", + "hex": "0xa926b6321051" + }, + "endAmount": { + "type": "BigNumber", + "hex": "0xa118e2ebf2bb" + }, + "recipient": "0x37a8f295612602f2774d331e562be9e61B83a327" + } + ] + } + } + }, + "portionBips": 15, + "portionAmount": "185983730585681", + "portionRecipient": "0x37a8f295612602f2774d331e562be9e61B83a327" + }, + "requestId": "a02ca0ca-7855-4dd0-9330-8b818aaeb59f", + "allQuotes": [ + { + "routing": "DUTCH_LIMIT", + "quote": { + "orderInfo": { + "chainId": 1, + "permit2Address": "0x000000000022d473030f116ddee9f6b43ac78ba3", + "reactor": "0x6000da47483062A0D734Ba3dc7576Ce6A0B645C4", + "swapper": "0x0938a82F93D5DAB110Dc6277FC236b5b082DC10F", + "nonce": "1993353164669688581970088190602701610528397285201889446578254799128576197633", + "deadline": 1697481666, + "additionalValidationContract": "0x0000000000000000000000000000000000000000", + "additionalValidationData": "0x", + "decayStartTime": 1697481594, + "decayEndTime": 1697481654, + "exclusiveFiller": "0xaAFb85ad4a412dd8adC49611496a7695A22f4aeb", + "exclusivityOverrideBps": "100", + "input": { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "startAmount": "200000000", + "endAmount": "200000000" + }, + "outputs": [ + { + "token": "0x0000000000000000000000000000000000000000", + "startAmount": "123803169993201727", + "endAmount": "117908377342236273", + "recipient": "0x0938a82F93D5DAB110Dc6277FC236b5b082DC10F" + }, + { + "token": "0x0000000000000000000000000000000000000000", + "startAmount": "185983730585681", + "endAmount": "177128258400955", + "recipient": "0x37a8f295612602f2774d331e562be9e61B83a327" + } + ] + }, + "encodedOrder": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000652d837a00000000000000000000000000000000000000000000000000000000652d83b6000000000000000000000000aafb85ad4a412dd8adc49611496a7695a22f4aeb0000000000000000000000000000000000000000000000000000000000000064000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000bebc200000000000000000000000000000000000000000000000000000000000bebc20000000000000000000000000000000000000000000000000000000000000002000000000000000000000000006000da47483062a0d734ba3dc7576ce6a0b645c40000000000000000000000000938a82f93d5dab110dc6277fc236b5b082dc10f046832aa305880d33daa871e5041a0cd4853599a9ead518917239e206765040100000000000000000000000000000000000000000000000000000000652d83c2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001b7d653c183183f00000000000000000000000000000000000000000000000001a2e50b6386d6710000000000000000000000000938a82f93d5dab110dc6277fc236b5b082dc10f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a926b63210510000000000000000000000000000000000000000000000000000a118e2ebf2bb00000000000000000000000037a8f295612602f2774d331e562be9e61b83a327", + "quoteId": "7b924043-f2d8-4f2e-abaa-9f65fbe5f890", + "requestId": "a02ca0ca-7855-4dd0-9330-8b818aaeb59f", + "orderHash": "0xb5b4e3be188f6eb9dbe7e1489595829184a9ebfb5389185ed7ba7c03142278c9", + "startTimeBufferSecs": 45, + "auctionPeriodSecs": 60, + "deadlineBufferSecs": 12, + "slippageTolerance": "0.5", + "permitData": { + "domain": { + "name": "Permit2", + "chainId": 1, + "verifyingContract": "0x000000000022d473030f116ddee9f6b43ac78ba3" + }, + "types": { + "PermitWitnessTransferFrom": [ + { + "name": "permitted", + "type": "TokenPermissions" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "witness", + "type": "ExclusiveDutchOrder" + } + ], + "TokenPermissions": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ], + "ExclusiveDutchOrder": [ + { + "name": "info", + "type": "OrderInfo" + }, + { + "name": "decayStartTime", + "type": "uint256" + }, + { + "name": "decayEndTime", + "type": "uint256" + }, + { + "name": "exclusiveFiller", + "type": "address" + }, + { + "name": "exclusivityOverrideBps", + "type": "uint256" + }, + { + "name": "inputToken", + "type": "address" + }, + { + "name": "inputStartAmount", + "type": "uint256" + }, + { + "name": "inputEndAmount", + "type": "uint256" + }, + { + "name": "outputs", + "type": "DutchOutput[]" + } + ], + "OrderInfo": [ + { + "name": "reactor", + "type": "address" + }, + { + "name": "swapper", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "additionalValidationContract", + "type": "address" + }, + { + "name": "additionalValidationData", + "type": "bytes" + } + ], + "DutchOutput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + }, + { + "name": "recipient", + "type": "address" + } + ] + }, + "values": { + "permitted": { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": { + "type": "BigNumber", + "hex": "0x0bebc200" + } + }, + "spender": "0x6000da47483062A0D734Ba3dc7576Ce6A0B645C4", + "nonce": { + "type": "BigNumber", + "hex": "0x046832aa305880d33daa871e5041a0cd4853599a9ead518917239e2067650401" + }, + "deadline": 1697481666, + "witness": { + "info": { + "reactor": "0x6000da47483062A0D734Ba3dc7576Ce6A0B645C4", + "swapper": "0x0938a82F93D5DAB110Dc6277FC236b5b082DC10F", + "nonce": { + "type": "BigNumber", + "hex": "0x046832aa305880d33daa871e5041a0cd4853599a9ead518917239e2067650401" + }, + "deadline": 1697481666, + "additionalValidationContract": "0x0000000000000000000000000000000000000000", + "additionalValidationData": "0x" + }, + "decayStartTime": 1697481594, + "decayEndTime": 1697481654, + "exclusiveFiller": "0xaAFb85ad4a412dd8adC49611496a7695A22f4aeb", + "exclusivityOverrideBps": { + "type": "BigNumber", + "hex": "0x64" + }, + "inputToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "inputStartAmount": { + "type": "BigNumber", + "hex": "0x0bebc200" + }, + "inputEndAmount": { + "type": "BigNumber", + "hex": "0x0bebc200" + }, + "outputs": [ + { + "token": "0x0000000000000000000000000000000000000000", + "startAmount": { + "type": "BigNumber", + "hex": "0x01b7d653c183183f" + }, + "endAmount": { + "type": "BigNumber", + "hex": "0x01a2e50b6386d671" + }, + "recipient": "0x0938a82F93D5DAB110Dc6277FC236b5b082DC10F" + }, + { + "token": "0x0000000000000000000000000000000000000000", + "startAmount": { + "type": "BigNumber", + "hex": "0xa926b6321051" + }, + "endAmount": { + "type": "BigNumber", + "hex": "0xa118e2ebf2bb" + }, + "recipient": "0x37a8f295612602f2774d331e562be9e61B83a327" + } + ] + } + } + }, + "portionBips": 15, + "portionAmount": "185983730585681", + "portionRecipient": "0x37a8f295612602f2774d331e562be9e61B83a327" + } + }, + { + "routing": "CLASSIC", + "quote": { + "methodParameters": { + "calldata": "0x3593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000652d85d0000000000000000000000000000000000000000000000000000000000000000308060c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000bebc20000000000000000000000000000000000000000000000000001bdf1285753b47400000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000060000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000037a8f295612602f2774d331e562be9e61b83a327000000000000000000000000000000000000000000000000000000000000000f00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000938a82f93d5dab110dc6277fc236b5b082dc10f00000000000000000000000000000000000000000000000001bd45ea74e458eb", + "value": "0x00", + "to": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD" + }, + "blockNumber": "18364784", + "amount": "200000000", + "amountDecimals": "200", + "quote": "126149127803342909", + "quoteDecimals": "0.126149127803342909", + "quoteGasAdjusted": "122888348391508943", + "quoteGasAdjustedDecimals": "0.122888348391508943", + "quoteGasAndPortionAdjusted": "122699124699803928", + "quoteGasAndPortionAdjustedDecimals": "0.122699124699803928", + "gasUseEstimateQuote": "3260779411833966", + "gasUseEstimateQuoteDecimals": "0.003260779411833966", + "gasUseEstimate": "240911", + "gasUseEstimateUSD": "5.153332510477604328", + "simulationStatus": "SUCCESS", + "simulationError": false, + "gasPriceWei": "13535203506", + "route": [ + [ + { + "type": "v2-pool", + "address": "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc", + "tokenIn": { + "chainId": 1, + "decimals": "6", + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "symbol": "USDC" + }, + "tokenOut": { + "chainId": 1, + "decimals": "18", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "symbol": "WETH" + }, + "reserve0": { + "token": { + "chainId": 1, + "decimals": "6", + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "symbol": "USDC" + }, + "quotient": "27487668611269" + }, + "reserve1": { + "token": { + "chainId": 1, + "decimals": "18", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "symbol": "WETH" + }, + "quotient": "17390022942803382004255" + }, + "amountIn": "200000000", + "amountOut": "125959904111637894" + } + ] + ], + "routeString": "[V2] 100.00% = USDC -- [0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc] --> WETH", + "quoteId": "f46cf31c-251e-470c-bd57-13209015694e", + "portionBips": 15, + "portionRecipient": "0x37a8f295612602f2774d331e562be9e61B83a327", + "portionAmount": "189223691705014", + "portionAmountDecimals": "0.000189223691705014", + "requestId": "a02ca0ca-7855-4dd0-9330-8b818aaeb59f", + "tradeType": "EXACT_INPUT", + "slippage": 0.5 + } + } + ] +} \ No newline at end of file diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index acbdcfdc1d..1bee07ae6c 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -5,7 +5,7 @@ "incremental": true, "isolatedModules": false, "noImplicitAny": false, - "target": "ES5", + "target": "ES6", "tsBuildInfoFile": "../node_modules/.cache/tsbuildinfo/cypress", // avoid clobbering the build tsbuildinfo "types": ["cypress", "node"], }, diff --git a/package.json b/package.json index 7db671e70a..03dbc1de6a 100644 --- a/package.json +++ b/package.json @@ -204,8 +204,8 @@ "@uniswap/sdk-core": "^4.0.3", "@uniswap/smart-order-router": "^3.15.0", "@uniswap/token-lists": "^1.0.0-beta.33", - "@uniswap/uniswapx-sdk": "^1.3.0", - "@uniswap/universal-router-sdk": "^1.5.6", + "@uniswap/uniswapx-sdk": "^1.4.1", + "@uniswap/universal-router-sdk": "^1.5.8", "@uniswap/v2-core": "^1.0.1", "@uniswap/v2-periphery": "^1.1.0-beta.0", "@uniswap/v2-sdk": "^3.2.0", diff --git a/src/components/FeatureFlagModal/FeatureFlagModal.tsx b/src/components/FeatureFlagModal/FeatureFlagModal.tsx index f141a3a839..1c16b163cc 100644 --- a/src/components/FeatureFlagModal/FeatureFlagModal.tsx +++ b/src/components/FeatureFlagModal/FeatureFlagModal.tsx @@ -18,6 +18,7 @@ import { useUniswapXDefaultEnabledFlag } from 'featureFlags/flags/uniswapXDefaul import { useUniswapXEthOutputFlag } from 'featureFlags/flags/uniswapXEthOutput' import { useUniswapXExactOutputFlag } from 'featureFlags/flags/uniswapXExactOutput' import { useUniswapXSyntheticQuoteFlag } from 'featureFlags/flags/uniswapXUseSyntheticQuote' +import { useFeesEnabledFlag } from 'featureFlags/flags/useFees' import { useUpdateAtom } from 'jotai/utils' import { Children, PropsWithChildren, ReactElement, ReactNode, useCallback, useState } from 'react' import { X } from 'react-feather' @@ -267,6 +268,12 @@ export default function FeatureFlagModal() { + ` background-color: ${({ theme, disable }) => disable && theme.surface3}; ` -const formatAnalyticsEventProperties = (currency: Currency, searchQuery: string, isAddressSearch: string | false) => ({ +const formatAnalyticsEventProperties = ( + currency: Currency, + searchQuery: string, + isAddressSearch: string | false, + portfolioBalanceUsd: number | undefined +) => ({ token_symbol: currency?.symbol, token_chain_id: currency?.chainId, token_address: getTokenAddress(currency), is_suggested_token: true, is_selected_from_list: false, is_imported_by_user: false, + total_balances_usd: portfolioBalanceUsd, ...(isAddressSearch === false ? { search_token_symbol_input: searchQuery } : { search_token_address_input: isAddressSearch }), @@ -54,8 +62,12 @@ export default function CommonBases({ onSelect: (currency: Currency) => void searchQuery: string isAddressSearch: string | false + portfolioBalanceUsd?: number }) { const bases = chainId !== undefined ? COMMON_BASES[chainId] ?? [] : [] + const { account } = useWeb3React() + const { data } = useCachedPortfolioBalancesQuery({ account }) + const portfolioBalanceUsd = data?.portfolios?.[0].tokensTotalDenominatedValue?.value return bases.length > 0 ? ( @@ -66,7 +78,7 @@ export default function CommonBases({ diff --git a/src/components/SearchModal/CurrencyList/index.tsx b/src/components/SearchModal/CurrencyList/index.tsx index 6f5fa07888..3020e63074 100644 --- a/src/components/SearchModal/CurrencyList/index.tsx +++ b/src/components/SearchModal/CurrencyList/index.tsx @@ -3,6 +3,7 @@ import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core' import { useWeb3React } from '@web3-react/core' import { TraceEvent } from 'analytics' import Loader from 'components/Icons/LoadingSpinner' +import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper' import TokenSafetyIcon from 'components/TokenSafety/TokenSafetyIcon' import { checkWarning } from 'constants/tokenSafety' import { TokenBalances } from 'lib/hooks/useTokenList/sorting' @@ -128,13 +129,15 @@ export function CurrencyRow({ const warning = currency.isNative ? null : checkWarning(currency.address) const isBlockedToken = !!warning && !warning.canProceed const blockedTokenOpacity = '0.6' + const { data } = useCachedPortfolioBalancesQuery({ account }) + const portfolioBalanceUsd = data?.portfolios?.[0].tokensTotalDenominatedValue?.value // only show add or remove buttons if not on selected list return ( { ) } -const GaslessSwapLabel = () => $0 +const GaslessSwapLabel = () => { + const { formatNumber } = useFormatter() + return {formatNumber({ input: 0, type: NumberType.FiatGasPrice })} +} type GasBreakdownTooltipProps = { trade: InterfaceTrade; hideUniswapXDescription?: boolean } diff --git a/src/components/swap/SwapDetailsDropdown.test.tsx b/src/components/swap/SwapDetailsDropdown.test.tsx index 4632a56bd1..15ce146bc9 100644 --- a/src/components/swap/SwapDetailsDropdown.test.tsx +++ b/src/components/swap/SwapDetailsDropdown.test.tsx @@ -11,6 +11,8 @@ import { act, render, screen } from 'test-utils/render' import SwapDetailsDropdown from './SwapDetailsDropdown' +jest.mock('../../featureFlags/flags/useFees', () => ({ useFeesEnabled: () => true })) + describe('SwapDetailsDropdown.tsx', () => { it('renders a trade', () => { const { asFragment } = render( diff --git a/src/components/swap/SwapDetailsDropdown.tsx b/src/components/swap/SwapDetailsDropdown.tsx index 6e412449a6..9a9ea57b56 100644 --- a/src/components/swap/SwapDetailsDropdown.tsx +++ b/src/components/swap/SwapDetailsDropdown.tsx @@ -111,6 +111,7 @@ function AdvancedSwapDetails(props: SwapDetailsProps & { open: boolean }) { + diff --git a/src/components/swap/SwapLineItem.test.tsx b/src/components/swap/SwapLineItem.test.tsx index 7db9b23132..e256eb4ed0 100644 --- a/src/components/swap/SwapLineItem.test.tsx +++ b/src/components/swap/SwapLineItem.test.tsx @@ -11,6 +11,8 @@ import { } from 'test-utils/constants' import { render } from 'test-utils/render' +jest.mock('../../featureFlags/flags/useFees', () => ({ useFeesEnabled: () => true })) + // Forces tooltips to render in snapshots jest.mock('react-dom', () => { const original = jest.requireActual('react-dom') diff --git a/src/components/swap/SwapLineItem.tsx b/src/components/swap/SwapLineItem.tsx index a1a6637e63..9cbe1f441d 100644 --- a/src/components/swap/SwapLineItem.tsx +++ b/src/components/swap/SwapLineItem.tsx @@ -6,11 +6,13 @@ import RouterLabel from 'components/RouterLabel' import Row, { RowBetween } from 'components/Row' import { MouseoverTooltip, TooltipSize } from 'components/Tooltip' import { SUPPORTED_GAS_ESTIMATE_CHAIN_IDS } from 'constants/chains' +import { useFeesEnabled } from 'featureFlags/flags/useFees' import useHoverProps from 'hooks/useHoverProps' +import { useUSDPrice } from 'hooks/useUSDPrice' import { useIsMobile } from 'nft/hooks' import React, { PropsWithChildren, useEffect, useState } from 'react' import { animated, SpringValue } from 'react-spring' -import { InterfaceTrade, TradeFillType } from 'state/routing/types' +import { InterfaceTrade, SubmittableTrade, TradeFillType } from 'state/routing/types' import { isPreviewTrade, isUniswapXTrade } from 'state/routing/utils' import { useUserSlippageTolerance } from 'state/user/hooks' import { SlippageTolerance } from 'state/user/types' @@ -31,6 +33,7 @@ export enum SwapLineItemType { OUTPUT_TOKEN_FEE_ON_TRANSFER, PRICE_IMPACT, MAX_SLIPPAGE, + SWAP_FEE, MAXIMUM_INPUT, MINIMUM_OUTPUT, ROUTING_INFO, @@ -74,6 +77,28 @@ function FOTTooltipContent() { ) } +function SwapFeeTooltipContent({ hasFee }: { hasFee: boolean }) { + const message = hasFee ? ( + + Fee is applied on a few token pairs to ensure the best experience with Uniswap. It is paid in the output token and + has already been factored into the quote. + + ) : ( + + Fee is applied on a few token pairs to ensure the best experience with Uniswap. There is no fee associated with + this swap. + + ) + return ( + <> + {message}{' '} + + Learn more + + + ) +} + function Loading({ width = 50 }: { width?: number }) { return } @@ -89,6 +114,18 @@ function CurrencyAmountRow({ amount }: { amount: CurrencyAmount }) { return <>{`${formattedAmount} ${amount.currency.symbol}`} } +function FeeRow({ trade: { swapFee, outputAmount } }: { trade: SubmittableTrade }) { + const { formatNumber } = useFormatter() + + const feeCurrencyAmount = CurrencyAmount.fromRawAmount(outputAmount.currency, swapFee?.amount ?? 0) + const { data: outputFeeFiatValue } = useUSDPrice(feeCurrencyAmount, feeCurrencyAmount?.currency) + + // Fallback to displaying token amount if fiat value is not available + if (outputFeeFiatValue === undefined) return + + return <>{formatNumber({ input: outputFeeFiatValue, type: NumberType.FiatGasPrice })} +} + type LineItemData = { Label: React.FC Value: React.FC @@ -101,6 +138,7 @@ function useLineItem(props: SwapLineItemProps): LineItemData | undefined { const { trade, syncing, allowedSlippage, type } = props const { formatNumber, formatSlippage } = useFormatter() const isAutoSlippage = useUserSlippageTolerance()[0] === SlippageTolerance.Auto + const feesEnabled = useFeesEnabled() const isUniswapX = isUniswapXTrade(trade) const isPreview = isPreviewTrade(trade) @@ -153,6 +191,19 @@ function useLineItem(props: SwapLineItemProps): LineItemData | undefined { ), } + case SwapLineItemType.SWAP_FEE: { + if (!feesEnabled) return + if (isPreview) return { Label: () => Fee, Value: () => } + return { + Label: () => ( + <> + Fee {trade.swapFee && `(${formatSlippage(trade.swapFee.percent)})`} + + ), + TooltipBody: () => , + Value: () => , + } + } case SwapLineItemType.MAXIMUM_INPUT: if (trade.tradeType === TradeType.EXACT_INPUT) return return { diff --git a/src/components/swap/SwapModalFooter.test.tsx b/src/components/swap/SwapModalFooter.test.tsx index a20f65c899..41313e0c4a 100644 --- a/src/components/swap/SwapModalFooter.test.tsx +++ b/src/components/swap/SwapModalFooter.test.tsx @@ -3,6 +3,8 @@ import { render, screen, within } from 'test-utils/render' import SwapModalFooter from './SwapModalFooter' +jest.mock('../../featureFlags/flags/useFees', () => ({ useFeesEnabled: () => true })) + describe('SwapModalFooter.tsx', () => { it('matches base snapshot, test trade exact input', () => { const { asFragment } = render( diff --git a/src/components/swap/SwapModalFooter.tsx b/src/components/swap/SwapModalFooter.tsx index e323e0d961..36e6b5c1fc 100644 --- a/src/components/swap/SwapModalFooter.tsx +++ b/src/components/swap/SwapModalFooter.tsx @@ -119,6 +119,7 @@ export default function SwapModalFooter({ + {showAcceptChanges ? ( diff --git a/src/components/swap/__snapshots__/SwapDetailsDropdown.test.tsx.snap b/src/components/swap/__snapshots__/SwapDetailsDropdown.test.tsx.snap index 7422fdaeca..eeedc6b508 100644 --- a/src/components/swap/__snapshots__/SwapDetailsDropdown.test.tsx.snap +++ b/src/components/swap/__snapshots__/SwapDetailsDropdown.test.tsx.snap @@ -384,6 +384,29 @@ exports[`SwapDetailsDropdown.tsx renders a trade 1`] = ` +
+
+
+ Fee +
+
+
+
+ 0 DEF +
+
+
+
+
- $0.00 + $0
@@ -592,6 +592,214 @@ exports[`SwapLineItem.tsx dutch order eth input 1`] = ` color: #7D7D7D; } +.c10 { + -webkit-text-decoration: none; + text-decoration: none; + cursor: pointer; + -webkit-transition-duration: 125ms; + transition-duration: 125ms; + color: #FC72FF; + stroke: #FC72FF; + font-weight: 500; +} + +.c10:hover { + opacity: 0.6; +} + +.c10:active { + opacity: 0.4; +} + +.c7 { + z-index: 1070; + pointer-events: none; + visibility: hidden; + opacity: 0; + -webkit-transition: visibility 150ms linear,opacity 150ms linear; + transition: visibility 150ms linear,opacity 150ms linear; + color: #7D7D7D; +} + +.c5 { + display: inline-block; + height: inherit; +} + +.c11 { + width: 8px; + height: 8px; + z-index: 9998; +} + +.c11::before { + position: absolute; + width: 8px; + height: 8px; + box-sizing: border-box; + z-index: 9998; + content: ''; + border: 1px solid #22222212; + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + background: #FFFFFF; +} + +.c11.arrow-top { + bottom: -4px; +} + +.c11.arrow-top::before { + border-top: none; + border-left: none; +} + +.c11.arrow-bottom { + top: -4px; +} + +.c11.arrow-bottom::before { + border-bottom: none; + border-right: none; +} + +.c11.arrow-left { + right: -4px; +} + +.c11.arrow-left::before { + border-bottom: none; + border-left: none; +} + +.c11.arrow-right { + left: -4px; +} + +.c11.arrow-right::before { + border-right: none; + border-top: none; +} + +.c8 { + max-width: 256px; + width: calc(100vw - 16px); + cursor: default; + padding: 12px; + pointer-events: auto; + color: #222222; + font-weight: 485; + font-size: 12px; + line-height: 16px; + word-break: break-word; + background: #FFFFFF; + border-radius: 12px; + border: 1px solid #22222212; + box-shadow: 0 4px 8px 0 rgba(47,128,237,0.1); +} + +.c6 { + text-align: right; + overflow-wrap: break-word; +} + +.c4 { + cursor: help; + color: #7D7D7D; +} + +@supports (-webkit-background-clip:text) and (-webkit-text-fill-color:transparent) { + +} + +
+
+
+ Fee +
+
+
+
+ 0 DEF +
+
+
+
+
+
+ Fee is applied on a few token pairs to ensure the best experience with Uniswap. There is no fee associated with this swap. + + Learn more + +
+
+
+
+
+
+ .c0 { + box-sizing: border-box; + margin: 0; + min-width: 0; +} + +.c1 { + 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; +} + +.c2 { + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.c3 { + color: #222222; +} + +.c9 { + color: #7D7D7D; +} + .c7 { z-index: 1070; pointer-events: none; @@ -1801,6 +2009,210 @@ exports[`SwapLineItem.tsx exact input 1`] = ` color: #7D7D7D; } +.c10 { + -webkit-text-decoration: none; + text-decoration: none; + cursor: pointer; + -webkit-transition-duration: 125ms; + transition-duration: 125ms; + color: #FC72FF; + stroke: #FC72FF; + font-weight: 500; +} + +.c10:hover { + opacity: 0.6; +} + +.c10:active { + opacity: 0.4; +} + +.c7 { + z-index: 1070; + pointer-events: none; + visibility: hidden; + opacity: 0; + -webkit-transition: visibility 150ms linear,opacity 150ms linear; + transition: visibility 150ms linear,opacity 150ms linear; + color: #7D7D7D; +} + +.c5 { + display: inline-block; + height: inherit; +} + +.c11 { + width: 8px; + height: 8px; + z-index: 9998; +} + +.c11::before { + position: absolute; + width: 8px; + height: 8px; + box-sizing: border-box; + z-index: 9998; + content: ''; + border: 1px solid #22222212; + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + background: #FFFFFF; +} + +.c11.arrow-top { + bottom: -4px; +} + +.c11.arrow-top::before { + border-top: none; + border-left: none; +} + +.c11.arrow-bottom { + top: -4px; +} + +.c11.arrow-bottom::before { + border-bottom: none; + border-right: none; +} + +.c11.arrow-left { + right: -4px; +} + +.c11.arrow-left::before { + border-bottom: none; + border-left: none; +} + +.c11.arrow-right { + left: -4px; +} + +.c11.arrow-right::before { + border-right: none; + border-top: none; +} + +.c8 { + max-width: 256px; + width: calc(100vw - 16px); + cursor: default; + padding: 12px; + pointer-events: auto; + color: #222222; + font-weight: 485; + font-size: 12px; + line-height: 16px; + word-break: break-word; + background: #FFFFFF; + border-radius: 12px; + border: 1px solid #22222212; + box-shadow: 0 4px 8px 0 rgba(47,128,237,0.1); +} + +.c6 { + text-align: right; + overflow-wrap: break-word; +} + +.c4 { + cursor: help; + color: #7D7D7D; +} + +
+
+
+ Fee +
+
+
+
+ 0 DEF +
+
+
+
+
+
+ Fee is applied on a few token pairs to ensure the best experience with Uniswap. There is no fee associated with this swap. + + Learn more + +
+
+
+
+
+
+ .c0 { + box-sizing: border-box; + margin: 0; + min-width: 0; +} + +.c1 { + 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; +} + +.c2 { + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.c3 { + color: #222222; +} + +.c9 { + color: #7D7D7D; +} + .c7 { z-index: 1070; pointer-events: none; @@ -3264,6 +3676,210 @@ exports[`SwapLineItem.tsx exact input api 1`] = ` color: #7D7D7D; } +.c10 { + -webkit-text-decoration: none; + text-decoration: none; + cursor: pointer; + -webkit-transition-duration: 125ms; + transition-duration: 125ms; + color: #FC72FF; + stroke: #FC72FF; + font-weight: 500; +} + +.c10:hover { + opacity: 0.6; +} + +.c10:active { + opacity: 0.4; +} + +.c7 { + z-index: 1070; + pointer-events: none; + visibility: hidden; + opacity: 0; + -webkit-transition: visibility 150ms linear,opacity 150ms linear; + transition: visibility 150ms linear,opacity 150ms linear; + color: #7D7D7D; +} + +.c5 { + display: inline-block; + height: inherit; +} + +.c11 { + width: 8px; + height: 8px; + z-index: 9998; +} + +.c11::before { + position: absolute; + width: 8px; + height: 8px; + box-sizing: border-box; + z-index: 9998; + content: ''; + border: 1px solid #22222212; + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + background: #FFFFFF; +} + +.c11.arrow-top { + bottom: -4px; +} + +.c11.arrow-top::before { + border-top: none; + border-left: none; +} + +.c11.arrow-bottom { + top: -4px; +} + +.c11.arrow-bottom::before { + border-bottom: none; + border-right: none; +} + +.c11.arrow-left { + right: -4px; +} + +.c11.arrow-left::before { + border-bottom: none; + border-left: none; +} + +.c11.arrow-right { + left: -4px; +} + +.c11.arrow-right::before { + border-right: none; + border-top: none; +} + +.c8 { + max-width: 256px; + width: calc(100vw - 16px); + cursor: default; + padding: 12px; + pointer-events: auto; + color: #222222; + font-weight: 485; + font-size: 12px; + line-height: 16px; + word-break: break-word; + background: #FFFFFF; + border-radius: 12px; + border: 1px solid #22222212; + box-shadow: 0 4px 8px 0 rgba(47,128,237,0.1); +} + +.c6 { + text-align: right; + overflow-wrap: break-word; +} + +.c4 { + cursor: help; + color: #7D7D7D; +} + +
+
+
+ Fee +
+
+
+
+ 0 DEF +
+
+
+
+
+
+ Fee is applied on a few token pairs to ensure the best experience with Uniswap. There is no fee associated with this swap. + + Learn more + +
+
+
+
+
+
+ .c0 { + box-sizing: border-box; + margin: 0; + min-width: 0; +} + +.c1 { + 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; +} + +.c2 { + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.c3 { + color: #222222; +} + +.c9 { + color: #7D7D7D; +} + .c7 { z-index: 1070; pointer-events: none; @@ -4727,6 +5343,210 @@ exports[`SwapLineItem.tsx exact output 1`] = ` color: #7D7D7D; } +.c10 { + -webkit-text-decoration: none; + text-decoration: none; + cursor: pointer; + -webkit-transition-duration: 125ms; + transition-duration: 125ms; + color: #FC72FF; + stroke: #FC72FF; + font-weight: 500; +} + +.c10:hover { + opacity: 0.6; +} + +.c10:active { + opacity: 0.4; +} + +.c7 { + z-index: 1070; + pointer-events: none; + visibility: hidden; + opacity: 0; + -webkit-transition: visibility 150ms linear,opacity 150ms linear; + transition: visibility 150ms linear,opacity 150ms linear; + color: #7D7D7D; +} + +.c5 { + display: inline-block; + height: inherit; +} + +.c11 { + width: 8px; + height: 8px; + z-index: 9998; +} + +.c11::before { + position: absolute; + width: 8px; + height: 8px; + box-sizing: border-box; + z-index: 9998; + content: ''; + border: 1px solid #22222212; + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + background: #FFFFFF; +} + +.c11.arrow-top { + bottom: -4px; +} + +.c11.arrow-top::before { + border-top: none; + border-left: none; +} + +.c11.arrow-bottom { + top: -4px; +} + +.c11.arrow-bottom::before { + border-bottom: none; + border-right: none; +} + +.c11.arrow-left { + right: -4px; +} + +.c11.arrow-left::before { + border-bottom: none; + border-left: none; +} + +.c11.arrow-right { + left: -4px; +} + +.c11.arrow-right::before { + border-right: none; + border-top: none; +} + +.c8 { + max-width: 256px; + width: calc(100vw - 16px); + cursor: default; + padding: 12px; + pointer-events: auto; + color: #222222; + font-weight: 485; + font-size: 12px; + line-height: 16px; + word-break: break-word; + background: #FFFFFF; + border-radius: 12px; + border: 1px solid #22222212; + box-shadow: 0 4px 8px 0 rgba(47,128,237,0.1); +} + +.c6 { + text-align: right; + overflow-wrap: break-word; +} + +.c4 { + cursor: help; + color: #7D7D7D; +} + +
+
+
+ Fee +
+
+
+
+ 0 GHI +
+
+
+
+
+
+ Fee is applied on a few token pairs to ensure the best experience with Uniswap. There is no fee associated with this swap. + + Learn more + +
+
+
+
+
+
+ .c0 { + box-sizing: border-box; + margin: 0; + min-width: 0; +} + +.c1 { + 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; +} + +.c2 { + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.c3 { + color: #222222; +} + +.c9 { + color: #7D7D7D; +} + .c7 { z-index: 1070; pointer-events: none; @@ -6398,6 +7218,210 @@ exports[`SwapLineItem.tsx fee on buy 1`] = ` color: #7D7D7D; } +.c10 { + -webkit-text-decoration: none; + text-decoration: none; + cursor: pointer; + -webkit-transition-duration: 125ms; + transition-duration: 125ms; + color: #FC72FF; + stroke: #FC72FF; + font-weight: 500; +} + +.c10:hover { + opacity: 0.6; +} + +.c10:active { + opacity: 0.4; +} + +.c7 { + z-index: 1070; + pointer-events: none; + visibility: hidden; + opacity: 0; + -webkit-transition: visibility 150ms linear,opacity 150ms linear; + transition: visibility 150ms linear,opacity 150ms linear; + color: #7D7D7D; +} + +.c5 { + display: inline-block; + height: inherit; +} + +.c11 { + width: 8px; + height: 8px; + z-index: 9998; +} + +.c11::before { + position: absolute; + width: 8px; + height: 8px; + box-sizing: border-box; + z-index: 9998; + content: ''; + border: 1px solid #22222212; + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + background: #FFFFFF; +} + +.c11.arrow-top { + bottom: -4px; +} + +.c11.arrow-top::before { + border-top: none; + border-left: none; +} + +.c11.arrow-bottom { + top: -4px; +} + +.c11.arrow-bottom::before { + border-bottom: none; + border-right: none; +} + +.c11.arrow-left { + right: -4px; +} + +.c11.arrow-left::before { + border-bottom: none; + border-left: none; +} + +.c11.arrow-right { + left: -4px; +} + +.c11.arrow-right::before { + border-right: none; + border-top: none; +} + +.c8 { + max-width: 256px; + width: calc(100vw - 16px); + cursor: default; + padding: 12px; + pointer-events: auto; + color: #222222; + font-weight: 485; + font-size: 12px; + line-height: 16px; + word-break: break-word; + background: #FFFFFF; + border-radius: 12px; + border: 1px solid #22222212; + box-shadow: 0 4px 8px 0 rgba(47,128,237,0.1); +} + +.c6 { + text-align: right; + overflow-wrap: break-word; +} + +.c4 { + cursor: help; + color: #7D7D7D; +} + +
+
+
+ Fee +
+
+
+
+ 0 DEF +
+
+
+
+
+
+ Fee is applied on a few token pairs to ensure the best experience with Uniswap. There is no fee associated with this swap. + + Learn more + +
+
+
+
+
+
+ .c0 { + box-sizing: border-box; + margin: 0; + min-width: 0; +} + +.c1 { + 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; +} + +.c2 { + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.c3 { + color: #222222; +} + +.c9 { + color: #7D7D7D; +} + .c7 { z-index: 1070; pointer-events: none; @@ -8069,6 +9093,210 @@ exports[`SwapLineItem.tsx fee on sell 1`] = ` color: #7D7D7D; } +.c10 { + -webkit-text-decoration: none; + text-decoration: none; + cursor: pointer; + -webkit-transition-duration: 125ms; + transition-duration: 125ms; + color: #FC72FF; + stroke: #FC72FF; + font-weight: 500; +} + +.c10:hover { + opacity: 0.6; +} + +.c10:active { + opacity: 0.4; +} + +.c7 { + z-index: 1070; + pointer-events: none; + visibility: hidden; + opacity: 0; + -webkit-transition: visibility 150ms linear,opacity 150ms linear; + transition: visibility 150ms linear,opacity 150ms linear; + color: #7D7D7D; +} + +.c5 { + display: inline-block; + height: inherit; +} + +.c11 { + width: 8px; + height: 8px; + z-index: 9998; +} + +.c11::before { + position: absolute; + width: 8px; + height: 8px; + box-sizing: border-box; + z-index: 9998; + content: ''; + border: 1px solid #22222212; + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + background: #FFFFFF; +} + +.c11.arrow-top { + bottom: -4px; +} + +.c11.arrow-top::before { + border-top: none; + border-left: none; +} + +.c11.arrow-bottom { + top: -4px; +} + +.c11.arrow-bottom::before { + border-bottom: none; + border-right: none; +} + +.c11.arrow-left { + right: -4px; +} + +.c11.arrow-left::before { + border-bottom: none; + border-left: none; +} + +.c11.arrow-right { + left: -4px; +} + +.c11.arrow-right::before { + border-right: none; + border-top: none; +} + +.c8 { + max-width: 256px; + width: calc(100vw - 16px); + cursor: default; + padding: 12px; + pointer-events: auto; + color: #222222; + font-weight: 485; + font-size: 12px; + line-height: 16px; + word-break: break-word; + background: #FFFFFF; + border-radius: 12px; + border: 1px solid #22222212; + box-shadow: 0 4px 8px 0 rgba(47,128,237,0.1); +} + +.c6 { + text-align: right; + overflow-wrap: break-word; +} + +.c4 { + cursor: help; + color: #7D7D7D; +} + +
+
+
+ Fee +
+
+
+
+ 0 DEF +
+
+
+
+
+
+ Fee is applied on a few token pairs to ensure the best experience with Uniswap. There is no fee associated with this swap. + + Learn more + +
+
+
+
+
+
+ .c0 { + box-sizing: border-box; + margin: 0; + min-width: 0; +} + +.c1 { + 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; +} + +.c2 { + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.c3 { + color: #222222; +} + +.c9 { + color: #7D7D7D; +} + .c7 { z-index: 1070; pointer-events: none; @@ -9509,6 +10737,85 @@ exports[`SwapLineItem.tsx preview exact in 1`] = ` color: #222222; } +.c6 { + -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, #FFFFFF 25%, #22222212 50%, #FFFFFF 75% ); + background-size: 400%; + will-change: background-position; + border-radius: 12px; + height: 15px; + width: 50px; +} + +.c5 { + text-align: right; + overflow-wrap: break-word; +} + +.c4 { + cursor: auto; + color: #7D7D7D; +} + +
+
+
+ Fee +
+
+
+
+
+
+ .c0 { + box-sizing: border-box; + margin: 0; + min-width: 0; +} + +.c1 { + 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; +} + +.c2 { + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.c3 { + color: #222222; +} + .c9 { color: #7D7D7D; } @@ -9980,6 +11287,76 @@ exports[`SwapLineItem.tsx syncing 1`] = ` color: #222222; } +.c5 { + -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, #FFFFFF 25%, #22222212 50%, #FFFFFF 75% ); + background-size: 400%; + will-change: background-position; + border-radius: 12px; + height: 15px; + width: 50px; +} + +.c4 { + cursor: help; + color: #7D7D7D; +} + +
+
+
+ Fee +
+
+
+
+ .c0 { + box-sizing: border-box; + margin: 0; + min-width: 0; +} + +.c1 { + 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; +} + +.c2 { + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.c3 { + color: #222222; +} + .c5 { -webkit-animation: fAQEyV 1.5s infinite; animation: fAQEyV 1.5s infinite; diff --git a/src/components/swap/__snapshots__/SwapModalFooter.test.tsx.snap b/src/components/swap/__snapshots__/SwapModalFooter.test.tsx.snap index e319920c38..3fff80cb44 100644 --- a/src/components/swap/__snapshots__/SwapModalFooter.test.tsx.snap +++ b/src/components/swap/__snapshots__/SwapModalFooter.test.tsx.snap @@ -354,6 +354,29 @@ exports[`SwapModalFooter.tsx matches base snapshot, test trade exact input 1`] =
+
+
+
+ Fee +
+
+
+
+ 0 DEF +
+
+
+
+
+
+
+
+ Fee +
+
+
+
+
+
>> +type UniversalRouterFeeField = { feeOptions: FeeOptions } | { flatFeeOptions: FlatFeeOptions } + +function getUniversalRouterFeeFields(trade?: InterfaceTrade): UniversalRouterFeeField | undefined { + if (!isClassicTrade(trade)) return undefined + if (!trade.swapFee) return undefined + + if (trade.tradeType === TradeType.EXACT_INPUT) { + return { feeOptions: { fee: trade.swapFee.percent, recipient: trade.swapFee.recipient } } + } else { + return { flatFeeOptions: { amount: BigNumber.from(trade.swapFee.amount), recipient: trade.swapFee.recipient } } + } +} + // Returns a function that will execute a swap, if the parameters are all valid // and the user has approved the slippage adjusted input amount for the trade export function useSwapCallback( trade: InterfaceTrade | undefined, // trade to execute, required - fiatValues: { amountIn?: number; amountOut?: number }, // usd values for amount in and out, logged for analytics + fiatValues: { amountIn?: number; amountOut?: number; feeUsd?: number }, // usd values for amount in and out, and the fee value, logged for analytics allowedSlippage: Percent, // in bips permitSignature: PermitSignature | undefined ) { @@ -47,6 +63,7 @@ export function useSwapCallback( slippageTolerance: allowedSlippage, deadline, permit: permitSignature, + ...getUniversalRouterFeeFields(trade), } ) diff --git a/src/hooks/useUniswapXSwapCallback.ts b/src/hooks/useUniswapXSwapCallback.ts index 044dfcd767..d4b892c7bd 100644 --- a/src/hooks/useUniswapXSwapCallback.ts +++ b/src/hooks/useUniswapXSwapCallback.ts @@ -5,6 +5,7 @@ import { Percent } from '@uniswap/sdk-core' import { DutchOrder, DutchOrderBuilder } from '@uniswap/uniswapx-sdk' import { useWeb3React } from '@web3-react/core' import { sendAnalyticsEvent, useTrace } from 'analytics' +import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper' import { formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics' import { useCallback } from 'react' import { DutchOrderTrade, TradeFillType } from 'state/routing/types' @@ -50,12 +51,15 @@ export function useUniswapXSwapCallback({ fiatValues, }: { trade?: DutchOrderTrade - fiatValues: { amountIn?: number; amountOut?: number } + fiatValues: { amountIn?: number; amountOut?: number; feeUsd?: number } allowedSlippage: Percent }) { const { account, provider } = useWeb3React() const analyticsContext = useTrace() + const { data } = useCachedPortfolioBalancesQuery({ account }) + const portfolioBalanceUsd = data?.portfolios?.[0]?.tokensTotalDenominatedValue?.value + return useCallback( async () => trace('swapx.send', async ({ setTraceData, setTraceStatus }) => { @@ -82,7 +86,7 @@ export function useUniswapXSwapCallback({ .decayEndTime(endTime) .deadline(deadline) .swapper(account) - .nonFeeRecipient(account) + .nonFeeRecipient(account, trade.swapFee?.recipient) // if fetching the nonce fails for any reason, default to existing nonce from the Swap quote. .nonce(updatedNonce ?? trade.order.info.nonce) .build() @@ -115,6 +119,7 @@ export function useUniswapXSwapCallback({ allowedSlippage, fiatValues, timeToSignSinceRequestMs: Date.now() - beforeSign, + portfolioBalanceUsd, }), ...analyticsContext, }) @@ -139,6 +144,7 @@ export function useUniswapXSwapCallback({ trade, allowedSlippage, fiatValues, + portfolioBalanceUsd, }), ...analyticsContext, errorCode: body.errorCode, @@ -154,6 +160,6 @@ export function useUniswapXSwapCallback({ response: { orderHash: body.hash, deadline: updatedOrder.info.deadline }, } }), - [account, provider, trade, allowedSlippage, fiatValues, analyticsContext] + [account, provider, trade, allowedSlippage, fiatValues, analyticsContext, portfolioBalanceUsd] ) } diff --git a/src/hooks/useUniversalRouter.ts b/src/hooks/useUniversalRouter.ts index 3ba976a34f..1eae988274 100644 --- a/src/hooks/useUniversalRouter.ts +++ b/src/hooks/useUniversalRouter.ts @@ -2,10 +2,11 @@ import { BigNumber } from '@ethersproject/bignumber' import { t } from '@lingui/macro' import { SwapEventName } from '@uniswap/analytics-events' import { Percent } from '@uniswap/sdk-core' -import { SwapRouter, UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk' +import { FlatFeeOptions, SwapRouter, UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk' import { FeeOptions, toHex } from '@uniswap/v3-sdk' import { useWeb3React } from '@web3-react/core' import { sendAnalyticsEvent, useTrace } from 'analytics' +import { useCachedPortfolioBalancesQuery } from 'components/PrefetchBalancesWrapper/PrefetchBalancesWrapper' import useBlockNumber from 'lib/hooks/useBlockNumber' import { formatCommonPropertiesForTrade, formatSwapSignedAnalyticsEventProperties } from 'lib/utils/analytics' import { useCallback } from 'react' @@ -43,17 +44,20 @@ interface SwapOptions { deadline?: BigNumber permit?: PermitSignature feeOptions?: FeeOptions + flatFeeOptions?: FlatFeeOptions } export function useUniversalRouterSwapCallback( trade: ClassicTrade | undefined, - fiatValues: { amountIn?: number; amountOut?: number }, + fiatValues: { amountIn?: number; amountOut?: number; feeUsd?: number }, options: SwapOptions ) { const { account, chainId, provider } = useWeb3React() const analyticsContext = useTrace() const blockNumber = useBlockNumber() const isAutoSlippage = useUserSlippageTolerance()[0] === 'auto' + const { data } = useCachedPortfolioBalancesQuery({ account }) + const portfolioBalanceUsd = data?.portfolios?.[0]?.tokensTotalDenominatedValue?.value return useCallback(async () => { return trace('swap.send', async ({ setTraceData, setTraceStatus, setTraceError }) => { @@ -76,6 +80,7 @@ export function useUniversalRouterSwapCallback( deadlineOrPreviousBlockhash: options.deadline?.toString(), inputTokenPermit: options.permit, fee: options.feeOptions, + flatFee: options.flatFeeOptions, }) const tx = { @@ -117,6 +122,7 @@ export function useUniversalRouterSwapCallback( allowedSlippage: options.slippageTolerance, fiatValues, txHash: response.hash, + portfolioBalanceUsd, }), ...analyticsContext, }) @@ -154,16 +160,14 @@ export function useUniversalRouterSwapCallback( }) }, [ account, - analyticsContext, - blockNumber, chainId, - fiatValues, - options.deadline, - options.feeOptions, - options.permit, - options.slippageTolerance, provider, trade, + options, + analyticsContext, + blockNumber, isAutoSlippage, + fiatValues, + portfolioBalanceUsd, ]) } diff --git a/src/lib/hooks/routing/useRoutingAPIArguments.ts b/src/lib/hooks/routing/useRoutingAPIArguments.ts index 8f1c6df3c7..ca624530e3 100644 --- a/src/lib/hooks/routing/useRoutingAPIArguments.ts +++ b/src/lib/hooks/routing/useRoutingAPIArguments.ts @@ -4,6 +4,7 @@ import { useUniswapXDefaultEnabled } from 'featureFlags/flags/uniswapXDefault' import { useUniswapXEthOutputEnabled } from 'featureFlags/flags/uniswapXEthOutput' import { useUniswapXExactOutputEnabled } from 'featureFlags/flags/uniswapXExactOutput' import { useUniswapXSyntheticQuoteEnabled } from 'featureFlags/flags/uniswapXUseSyntheticQuote' +import { useFeesEnabled } from 'featureFlags/flags/useFees' import { useMemo } from 'react' import { GetQuoteArgs, INTERNAL_ROUTER_PREFERENCE_PRICE, RouterPreference } from 'state/routing/types' import { currencyAddressForSwapQuote } from 'state/routing/utils' @@ -40,6 +41,10 @@ export function useRoutingAPIArguments({ const uniswapXExactOutputEnabled = useUniswapXExactOutputEnabled() const isUniswapXDefaultEnabled = useUniswapXDefaultEnabled() + const feesEnabled = useFeesEnabled() + // Don't enable fee logic if this is a quote for pricing + const sendPortionEnabled = routerPreference === INTERNAL_ROUTER_PREFERENCE_PRICE ? false : feesEnabled + return useMemo( () => !tokenIn || !tokenOut || !amount || tokenIn.equals(tokenOut) || tokenIn.wrapped.equals(tokenOut.wrapped) @@ -64,6 +69,7 @@ export function useRoutingAPIArguments({ uniswapXEthOutputEnabled, uniswapXExactOutputEnabled, isUniswapXDefaultEnabled, + sendPortionEnabled, inputTax, outputTax, }, @@ -80,6 +86,7 @@ export function useRoutingAPIArguments({ userOptedOutOfUniswapX, uniswapXEthOutputEnabled, isUniswapXDefaultEnabled, + sendPortionEnabled, inputTax, outputTax, ] diff --git a/src/lib/utils/analytics.ts b/src/lib/utils/analytics.ts index ad26af2f08..ae3960e739 100644 --- a/src/lib/utils/analytics.ts +++ b/src/lib/utils/analytics.ts @@ -1,6 +1,6 @@ import { Currency, CurrencyAmount, Percent, Price, Token } from '@uniswap/sdk-core' import { NATIVE_CHAIN_ID } from 'constants/tokens' -import { InterfaceTrade, QuoteMethod } from 'state/routing/types' +import { InterfaceTrade, QuoteMethod, SubmittableTrade } from 'state/routing/types' import { isClassicTrade, isSubmittableTrade, isUniswapXTrade } from 'state/routing/utils' import { computeRealizedPriceImpact } from 'utils/prices' @@ -35,7 +35,11 @@ function getEstimatedNetworkFee(trade: InterfaceTrade) { return undefined } -export function formatCommonPropertiesForTrade(trade: InterfaceTrade, allowedSlippage: Percent) { +export function formatCommonPropertiesForTrade( + trade: InterfaceTrade, + allowedSlippage: Percent, + outputFeeFiatValue?: number +) { return { routing: trade.fillType, type: trade.tradeType, @@ -47,7 +51,7 @@ export function formatCommonPropertiesForTrade(trade: InterfaceTrade, allowedSli 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_out_amount: formatToDecimal(trade.postTaxOutputAmount, trade.outputAmount.currency.decimals), price_impact_basis_points: isClassicTrade(trade) ? formatPercentInBasisPointsNumber(computeRealizedPriceImpact(trade)) : undefined, @@ -59,6 +63,7 @@ export function formatCommonPropertiesForTrade(trade: InterfaceTrade, allowedSli minimum_output_after_slippage: trade.minimumAmountOut(allowedSlippage).toSignificant(6), allowed_slippage: formatPercentNumber(allowedSlippage), method: getQuoteMethod(trade), + fee_usd: outputFeeFiatValue, } } @@ -68,19 +73,22 @@ export const formatSwapSignedAnalyticsEventProperties = ({ fiatValues, txHash, timeToSignSinceRequestMs, + portfolioBalanceUsd, }: { - trade: InterfaceTrade + trade: SubmittableTrade allowedSlippage: Percent - fiatValues: { amountIn?: number; amountOut?: number } + fiatValues: { amountIn?: number; amountOut?: number; feeUsd?: number } txHash?: string timeToSignSinceRequestMs?: number + portfolioBalanceUsd?: number }) => ({ + total_balances_usd: portfolioBalanceUsd, transaction_hash: txHash, token_in_amount_usd: fiatValues.amountIn, token_out_amount_usd: fiatValues.amountOut, // measures the amount of time the user took to sign the permit message or swap tx in their wallet time_to_sign_since_request_ms: timeToSignSinceRequestMs, - ...formatCommonPropertiesForTrade(trade, allowedSlippage), + ...formatCommonPropertiesForTrade(trade, allowedSlippage, fiatValues.feeUsd), }) function getQuoteMethod(trade: InterfaceTrade) { @@ -94,10 +102,11 @@ export const formatSwapQuoteReceivedEventProperties = ( allowedSlippage: Percent, swapQuoteLatencyMs: number | undefined, inputTax: Percent, - outputTax: Percent + outputTax: Percent, + outputFeeFiatValue: number | undefined ) => { return { - ...formatCommonPropertiesForTrade(trade, allowedSlippage), + ...formatCommonPropertiesForTrade(trade, allowedSlippage, outputFeeFiatValue), swap_quote_block_number: isClassicTrade(trade) ? trade.blockNumber : undefined, allowed_slippage_basis_points: formatPercentInBasisPointsNumber(allowedSlippage), token_in_amount_max: trade.maximumAmountIn(allowedSlippage).toExact(), diff --git a/src/pages/Swap/index.tsx b/src/pages/Swap/index.tsx index 213986b153..1436c8d50d 100644 --- a/src/pages/Swap/index.tsx +++ b/src/pages/Swap/index.tsx @@ -294,6 +294,7 @@ export function Swap({ inputError: swapInputError, inputTax, outputTax, + outputFeeFiatValue, } = swapInfo const [inputTokenHasTax, outputTokenHasTax] = useMemo( @@ -441,8 +442,8 @@ export function Swap({ ) const showMaxButton = Boolean(maxInputAmount?.greaterThan(0) && !parsedAmounts[Field.INPUT]?.equalTo(maxInputAmount)) const swapFiatValues = useMemo(() => { - return { amountIn: fiatValueTradeInput.data, amountOut: fiatValueTradeOutput.data } - }, [fiatValueTradeInput, fiatValueTradeOutput]) + return { amountIn: fiatValueTradeInput.data, amountOut: fiatValueTradeOutput.data, feeUsd: outputFeeFiatValue } + }, [fiatValueTradeInput.data, fiatValueTradeOutput.data, outputFeeFiatValue]) // the callback to execute the swap const swapCallback = useSwapCallback( @@ -584,10 +585,17 @@ export function Swap({ if (!trade || prevTrade === trade) return // no new swap quote to log sendAnalyticsEvent(SwapEventName.SWAP_QUOTE_RECEIVED, { - ...formatSwapQuoteReceivedEventProperties(trade, allowedSlippage, swapQuoteLatency, inputTax, outputTax), + ...formatSwapQuoteReceivedEventProperties( + trade, + allowedSlippage, + swapQuoteLatency, + inputTax, + outputTax, + outputFeeFiatValue + ), ...trace, }) - }, [prevTrade, trade, trace, allowedSlippage, swapQuoteLatency, inputTax, outputTax]) + }, [prevTrade, trade, trace, allowedSlippage, swapQuoteLatency, inputTax, outputTax, outputFeeFiatValue]) const showDetailsDropdown = Boolean( !showWrap && userHasSpecifiedInputOutput && (trade || routeIsLoading || routeIsSyncing) diff --git a/src/state/routing/slice.ts b/src/state/routing/slice.ts index c77d58446d..a0e16b2f40 100644 --- a/src/state/routing/slice.ts +++ b/src/state/routing/slice.ts @@ -35,6 +35,8 @@ const protocols: Protocol[] = [Protocol.V2, Protocol.V3, Protocol.MIXED] // routing API quote query params: https://github.com/Uniswap/routing-api/blob/main/lib/handlers/quote/schema/quote-schema.ts const DEFAULT_QUERY_PARAMS = { protocols, + // this should be removed once BE fixes issue where enableUniversalRouter is required for fees to work + enableUniversalRouter: true, } function getQuoteLatencyMeasure(mark: PerformanceMark): PerformanceMeasure { @@ -66,6 +68,7 @@ function getRoutingAPIConfig(args: GetQuoteArgs): RoutingConfig { const classic = { ...DEFAULT_QUERY_PARAMS, routingType: URAQuoteType.CLASSIC, + recipient: account, } const tokenOutIsNative = Object.values(SwapRouterNativeAssets).includes(tokenOutAddress as SwapRouterNativeAssets) @@ -124,16 +127,24 @@ export const routingApi = createApi({ logSwapQuoteRequest(args.tokenInChainId, args.routerPreference, false) const quoteStartMark = performance.mark(`quote-fetch-start-${Date.now()}`) try { - const { tokenInAddress, tokenInChainId, tokenOutAddress, tokenOutChainId, amount, tradeType } = args - const type = isExactInput(tradeType) ? 'EXACT_INPUT' : 'EXACT_OUTPUT' + const { + tokenInAddress: tokenIn, + tokenInChainId, + tokenOutAddress: tokenOut, + tokenOutChainId, + amount, + tradeType, + sendPortionEnabled, + } = args const requestBody = { tokenInChainId, - tokenIn: tokenInAddress, + tokenIn, tokenOutChainId, - tokenOut: tokenOutAddress, + tokenOut, amount, - type, + sendPortionEnabled, + type: isExactInput(tradeType) ? 'EXACT_INPUT' : 'EXACT_OUTPUT', intent: args.routerPreference === INTERNAL_ROUTER_PREFERENCE_PRICE ? 'pricing' : undefined, configs: getRoutingAPIConfig(args), } diff --git a/src/state/routing/types.ts b/src/state/routing/types.ts index b1802a5a61..a7e0bb3650 100644 --- a/src/state/routing/types.ts +++ b/src/state/routing/types.ts @@ -50,6 +50,7 @@ export interface GetQuoteArgs { // temporary field indicating the user disabled UniswapX during the transition to the opt-out model userOptedOutOfUniswapX: boolean isUniswapXDefaultEnabled: boolean + sendPortionEnabled: boolean inputTax: Percent outputTax: Percent } @@ -112,11 +113,11 @@ export interface ClassicQuoteData { blockNumber: string amount: string amountDecimals: string - gasPriceWei: string - gasUseEstimate: string - gasUseEstimateQuote: string - gasUseEstimateQuoteDecimals: string - gasUseEstimateUSD: string + gasPriceWei?: string + gasUseEstimate?: string + gasUseEstimateQuote?: string + gasUseEstimateQuoteDecimals?: string + gasUseEstimateUSD?: string methodParameters?: { calldata: string; value: string } quote: string quoteDecimals: string @@ -124,19 +125,30 @@ export interface ClassicQuoteData { quoteGasAdjustedDecimals: string route: Array<(V3PoolInRoute | V2PoolInRoute)[]> routeString: string + portionBips?: number + portionRecipient?: string + portionAmount?: string + portionAmountDecimals?: string + quoteGasAndPortionAdjusted?: string + quoteGasAndPortionAdjustedDecimals?: string +} + +export type URADutchOrderQuoteData = { + auctionPeriodSecs: number + deadlineBufferSecs: number + startTimeBufferSecs: number + orderInfo: DutchOrderInfoJSON + quoteId?: string + requestId?: string + slippageTolerance: string + portionBips?: number + portionRecipient?: string + portionAmount?: string } type URADutchOrderQuoteResponse = { routing: URAQuoteType.DUTCH_LIMIT - quote: { - auctionPeriodSecs: number - deadlineBufferSecs: number - startTimeBufferSecs: number - orderInfo: DutchOrderInfoJSON - quoteId?: string - requestId?: string - slippageTolerance: string - } + quote: URADutchOrderQuoteData allQuotes: Array } type URAClassicQuoteResponse = { @@ -179,6 +191,8 @@ export enum TradeFillType { export type ApproveInfo = { needsApprove: true; approveGasEstimateUSD: number } | { needsApprove: false } export type WrapInfo = { needsWrap: true; wrapGasEstimateUSD: number } | { needsWrap: false } +export type SwapFeeInfo = { recipient: string; percent: Percent; amount: string /* raw amount of output token */ } + export class ClassicTrade extends Trade { public readonly fillType = TradeFillType.Classic approveInfo: ApproveInfo @@ -189,6 +203,7 @@ export class ClassicTrade extends Trade { quoteMethod: QuoteMethod inputTax: Percent outputTax: Percent + swapFee: SwapFeeInfo | undefined constructor({ gasUseEstimateUSD, @@ -199,6 +214,7 @@ export class ClassicTrade extends Trade { approveInfo, inputTax, outputTax, + swapFee, ...routes }: { gasUseEstimateUSD?: number @@ -210,6 +226,7 @@ export class ClassicTrade extends Trade { approveInfo: ApproveInfo inputTax: Percent outputTax: Percent + swapFee?: SwapFeeInfo v2Routes: { routev2: V2Route inputAmount: CurrencyAmount @@ -236,17 +253,33 @@ export class ClassicTrade extends Trade { this.approveInfo = approveInfo this.inputTax = inputTax this.outputTax = outputTax + this.swapFee = swapFee + } + + public get executionPrice(): Price { + if (this.tradeType === TradeType.EXACT_INPUT || !this.swapFee) return super.executionPrice + + // Fix inaccurate price calculation for exact output trades + return new Price({ baseAmount: this.inputAmount, quoteAmount: this.postSwapFeeOutputAmount }) } public get totalTaxRate(): Percent { return this.inputTax.add(this.outputTax) } + public get postSwapFeeOutputAmount(): CurrencyAmount { + // Routing api already applies the swap fee to the output amount for exact-in + if (this.tradeType === TradeType.EXACT_INPUT) return this.outputAmount + + const swapFeeAmount = CurrencyAmount.fromRawAmount(this.outputAmount.currency, this.swapFee?.amount ?? 0) + return this.outputAmount.subtract(swapFeeAmount) + } + public get postTaxOutputAmount() { // Ideally we should calculate the final output amount by ammending the inputAmount based on the input tax and then applying the output tax, // but this isn't currently possible because V2Trade reconstructs the total inputAmount based on the swap routes // TODO(WEB-2761): Amend V2Trade objects in the v2-sdk to have a separate field for post-input tax routes - return this.outputAmount.multiply(new Fraction(ONE).subtract(this.totalTaxRate)) + return this.postSwapFeeOutputAmount.multiply(new Fraction(ONE).subtract(this.totalTaxRate)) } public minimumAmountOut(slippageTolerance: Percent, amountOut = this.outputAmount): CurrencyAmount { @@ -281,6 +314,7 @@ export class DutchOrderTrade extends IDutchOrderTrade } inputTax: Percent outputTax: Percent + outputFeeFiatValue?: number parsedAmount?: CurrencyAmount inputError?: ReactNode trade: { @@ -140,6 +142,13 @@ export function useDerivedSwapInfo(state: SwapState, chainId: ChainId | undefine outputTax ) + const { data: outputFeeFiatValue } = useUSDPrice( + isSubmittableTrade(trade.trade) && trade.trade.swapFee + ? CurrencyAmount.fromRawAmount(trade.trade.outputAmount.currency, trade.trade.swapFee.amount) + : undefined, + trade.trade?.outputAmount.currency + ) + const currencyBalances = useMemo( () => ({ [Field.INPUT]: relevantTokenBalances[0], @@ -215,8 +224,20 @@ export function useDerivedSwapInfo(state: SwapState, chainId: ChainId | undefine allowedSlippage, inputTax, outputTax, + outputFeeFiatValue, }), - [allowedSlippage, autoSlippage, currencies, currencyBalances, inputError, inputTax, outputTax, parsedAmount, trade] + [ + allowedSlippage, + autoSlippage, + currencies, + currencyBalances, + inputError, + inputTax, + outputFeeFiatValue, + outputTax, + parsedAmount, + trade, + ] ) } diff --git a/src/utils/formatNumbers.test.ts b/src/utils/formatNumbers.test.ts index a0712612b8..ed281d0a22 100644 --- a/src/utils/formatNumbers.test.ts +++ b/src/utils/formatNumbers.test.ts @@ -188,7 +188,7 @@ describe('formatNumber', () => { expect(formatNumber({ input: 1234567.891, type: NumberType.FiatGasPrice })).toBe('$1.23M') expect(formatNumber({ input: 18.448, type: NumberType.FiatGasPrice })).toBe('$18.45') expect(formatNumber({ input: 0.0099, type: NumberType.FiatGasPrice })).toBe('<$0.01') - expect(formatNumber({ input: 0, type: NumberType.FiatGasPrice })).toBe('$0.00') + expect(formatNumber({ input: 0, type: NumberType.FiatGasPrice })).toBe('$0') }) it('formats gas prices correctly with portugese locale and thai baht currency', () => { @@ -199,7 +199,7 @@ describe('formatNumber', () => { expect(formatNumber({ input: 1234567.891, type: NumberType.FiatGasPrice })).toBe('฿\xa01,23\xa0mi') expect(formatNumber({ input: 18.448, type: NumberType.FiatGasPrice })).toBe('฿\xa018,45') expect(formatNumber({ input: 0.0099, type: NumberType.FiatGasPrice })).toBe('<฿\xa00,01') - expect(formatNumber({ input: 0, type: NumberType.FiatGasPrice })).toBe('฿\xa00,00') + expect(formatNumber({ input: 0, type: NumberType.FiatGasPrice })).toBe('฿\xa00') }) it('formats USD token quantities prices correctly', () => { diff --git a/src/utils/formatNumbers.ts b/src/utils/formatNumbers.ts index 22ff6290c4..33644aed42 100644 --- a/src/utils/formatNumbers.ts +++ b/src/utils/formatNumbers.ts @@ -39,6 +39,14 @@ const NO_DECIMALS: NumberFormatOptions = { minimumFractionDigits: 0, } +const NO_DECIMALS_CURRENCY: NumberFormatOptions = { + notation: 'standard', + maximumFractionDigits: 0, + minimumFractionDigits: 0, + currency: 'USD', + style: 'currency', +} + const THREE_DECIMALS_NO_TRAILING_ZEROS: NumberFormatOptions = { notation: 'standard', maximumFractionDigits: 3, @@ -262,7 +270,7 @@ const fiatTokenStatsFormatter: FormatterRule[] = [ ] const fiatGasPriceFormatter: FormatterRule[] = [ - { exact: 0, formatterOptions: TWO_DECIMALS_CURRENCY }, + { exact: 0, formatterOptions: NO_DECIMALS_CURRENCY }, { upperBound: 0.01, hardCodedInput: { input: 0.01, prefix: '<' }, formatterOptions: TWO_DECIMALS_CURRENCY }, { upperBound: 1e6, formatterOptions: TWO_DECIMALS_CURRENCY }, { upperBound: Infinity, formatterOptions: SHORTHAND_CURRENCY_TWO_DECIMALS }, diff --git a/yarn.lock b/yarn.lock index 50505ed186..9729a92206 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6217,10 +6217,10 @@ resolved "https://registry.yarnpkg.com/@uniswap/token-lists/-/token-lists-1.0.0-beta.33.tgz#966ba96c9ccc8f0e9e09809890b438203f2b1911" integrity sha512-JQkXcpRI3jFG8y3/CGC4TS8NkDgcxXaOQuYW8Qdvd6DcDiIyg2vVYCG9igFEzF0G6UvxgHkBKC7cWCgzZNYvQg== -"@uniswap/uniswapx-sdk@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@uniswap/uniswapx-sdk/-/uniswapx-sdk-1.3.0.tgz#22867580c7f5d5ee35d669444d093e09203e1b47" - integrity sha512-TXH0+3reXA/liY2IRbCRvPVyREDObKSVmd4vEtTD0sPM0NW6ndSowKDH0hWBj2d7lBnSNKz5fp7IOaFT7yHkug== +"@uniswap/uniswapx-sdk@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@uniswap/uniswapx-sdk/-/uniswapx-sdk-1.4.1.tgz#c5fc50000032aa714ff0cc4b9cd1957128a2a4ec" + integrity sha512-M7uuZdozWbXJq8J64KTJ9e0PkYcfe6lx7RBpIsvJaypkGgGDrmU1bAqr1j3sphHlzKTmJCuG7GZBFNcnvzxHLw== dependencies: "@ethersproject/bytes" "^5.7.0" "@ethersproject/providers" "^5.7.0" @@ -6228,10 +6228,10 @@ "@uniswap/sdk-core" "^4.0.3" ethers "^5.7.0" -"@uniswap/universal-router-sdk@^1.5.4", "@uniswap/universal-router-sdk@^1.5.6": - version "1.5.6" - resolved "https://registry.yarnpkg.com/@uniswap/universal-router-sdk/-/universal-router-sdk-1.5.6.tgz#274a6ac5df032c34544005fe329aa9e2aac9ade6" - integrity sha512-ZD27U+kugMRJRVEX0oWZsRCw1n5vBN3I17Q22IWE+w/WhOJSppUr6PLo9u4HRdqXTZET7gubnlRc0LOAEkkSkQ== +"@uniswap/universal-router-sdk@^1.5.4", "@uniswap/universal-router-sdk@^1.5.8": + version "1.5.8" + resolved "https://registry.yarnpkg.com/@uniswap/universal-router-sdk/-/universal-router-sdk-1.5.8.tgz#16c62c3883e99073ba8b6e19188cf418b6551847" + integrity sha512-9tDDBTXarpdRfJStF5mDCNmsQrCfiIT6HCQN1EPq0tAm2b+JzjRkUzsLpbNpVef066FETc3YjPH6JDPB3CMyyA== dependencies: "@uniswap/permit2-sdk" "^1.2.0" "@uniswap/router-sdk" "^1.6.0"