From 3effe36b624acc6b873843950ca407894cc69b26 Mon Sep 17 00:00:00 2001 From: Enes Date: Fri, 24 May 2024 16:04:54 +0300 Subject: [PATCH] refactor(swap): price impact calculation, beta tag (#2273) Co-authored-by: tomiir Co-authored-by: Sven <38101365+svenvoskamp@users.noreply.github.com> Co-authored-by: Sven --- packages/common/index.ts | 1 + packages/common/src/utils/InputUtil.ts | 73 +++++++++ .../core/src/controllers/AccountController.ts | 6 +- .../controllers/BlockchainApiController.ts | 45 +++++- .../core/src/controllers/SwapController.ts | 74 +++++++--- packages/core/src/utils/SwapApiUtil.ts | 8 +- .../core/src/utils/SwapCalculationUtil.ts | 15 +- packages/core/src/utils/TypeUtil.ts | 22 ++- .../tests/controllers/SwapController.test.ts | 29 ++-- packages/core/tests/mocks/SwapController.ts | 139 ++++++++++-------- .../tests/utils/SwapCalculationUtil.test.ts | 26 ++-- .../index.ts | 2 +- .../scaffold/src/partials/w3m-header/index.ts | 15 +- .../src/partials/w3m-swap-details/index.ts | 90 ++++++++---- .../src/partials/w3m-swap-input/index.ts | 37 ++--- .../src/partials/w3m-swap-input/styles.ts | 1 + .../src/utils/w3m-email-otp-widget/index.ts | 4 +- .../views/w3m-buy-in-progress-view/index.ts | 4 +- .../views/w3m-onramp-activity-view/index.ts | 2 +- .../src/views/w3m-swap-preview-view/index.ts | 62 ++++---- .../scaffold/src/views/w3m-swap-view/index.ts | 43 +----- 21 files changed, 433 insertions(+), 265 deletions(-) create mode 100644 packages/common/src/utils/InputUtil.ts diff --git a/packages/common/index.ts b/packages/common/index.ts index cbdd2f8f34..66d688114f 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -2,6 +2,7 @@ export { DateUtil } from './src/utils/DateUtil.js' export { NetworkUtil } from './src/utils/NetworkUtil.js' export { NumberUtil } from './src/utils/NumberUtil.js' +export { InputUtil } from './src/utils/InputUtil.js' export { erc20ABI } from './src/contracts/erc20.js' export { NavigationUtil } from './src/utils/NavigationUtil.js' export { ConstantsUtil } from './src/utils/ConstantsUtil.js' diff --git a/packages/common/src/utils/InputUtil.ts b/packages/common/src/utils/InputUtil.ts new file mode 100644 index 0000000000..215ba8c2d8 --- /dev/null +++ b/packages/common/src/utils/InputUtil.ts @@ -0,0 +1,73 @@ +export const InputUtil = { + /** + * Custom key down event optimized for numeric inputs which is used on the swap + * @param event + * @param value + * @param onChange + */ + numericInputKeyDown( + event: KeyboardEvent, + currentValue: string | undefined, + onChange: (value: string) => void + ) { + const allowedKeys = [ + 'Backspace', + 'Meta', + 'Ctrl', + 'a', + 'A', + 'c', + 'C', + 'x', + 'X', + 'v', + 'V', + 'ArrowLeft', + 'ArrowRight', + 'Tab' + ] + const controlPressed = event.metaKey || event.ctrlKey + const selectAll = event.key === 'a' || event.key === 'A' + const copyKey = event.key === 'c' || event.key === 'C' + const pasteKey = event.key === 'v' || event.key === 'V' + const cutKey = event.key === 'x' || event.key === 'X' + + const isComma = event.key === ',' + const isDot = event.key === '.' + const isNumericKey = event.key >= '0' && event.key <= '9' + + // If command/ctrl key is not pressed, doesn't allow for a, c, v + if (!controlPressed && (selectAll || copyKey || pasteKey || cutKey)) { + event.preventDefault() + } + + // If current value is zero, and zero is pressed, prevent the zero from being added again + if (currentValue === '0' && !isComma && !isDot && event.key === '0') { + event.preventDefault() + } + + // If current value is zero and any numeric key is pressed, replace the zero with the number + if (currentValue === '0' && isNumericKey) { + onChange(event.key) + event.preventDefault() + } + + if (isComma || isDot) { + // If the first character is a dot or comma, add a zero before it + if (!currentValue) { + onChange('0.') + event.preventDefault() + } + + // If the current value already has a dot or comma, prevent the new one from being added + if (currentValue?.includes('.') || currentValue?.includes(',')) { + event.preventDefault() + } + } + + // If the character is not allowed and it's not a dot or comma, prevent it + if (!isNumericKey && !allowedKeys.includes(event.key) && !isDot && !isComma) { + event.preventDefault() + } + } +} diff --git a/packages/core/src/controllers/AccountController.ts b/packages/core/src/controllers/AccountController.ts index ce2952ee17..b14a34e9ce 100644 --- a/packages/core/src/controllers/AccountController.ts +++ b/packages/core/src/controllers/AccountController.ts @@ -114,7 +114,11 @@ export const AccountController = { if (state.address && chainId) { const response = await BlockchainApiController.getBalance(state.address, chainId) - this.setTokenBalance(response.balances) + const filteredBalances = response.balances.filter( + balance => balance.quantity.decimals !== '0' + ) + + this.setTokenBalance(filteredBalances) SwapController.setBalances(SwapApiUtil.mapBalancesToSwapTokens(response.balances)) } } catch (error) { diff --git a/packages/core/src/controllers/BlockchainApiController.ts b/packages/core/src/controllers/BlockchainApiController.ts index 6932251f59..a8fbfaab19 100644 --- a/packages/core/src/controllers/BlockchainApiController.ts +++ b/packages/core/src/controllers/BlockchainApiController.ts @@ -11,6 +11,8 @@ import type { BlockchainApiGenerateSwapCalldataResponse, BlockchainApiGenerateApproveCalldataRequest, BlockchainApiGenerateApproveCalldataResponse, + BlockchainApiSwapQuoteRequest, + BlockchainApiSwapQuoteResponse, BlockchainApiSwapAllowanceRequest, BlockchainApiSwapAllowanceResponse, BlockchainApiGasPriceRequest, @@ -140,6 +142,30 @@ export const BlockchainApiController = { }) }, + fetchSwapQuote({ + projectId, + amount, + userAddress, + from, + to, + gasPrice + }: BlockchainApiSwapQuoteRequest) { + return api.get({ + path: `/v1/convert/quotes`, + headers: { + 'Content-Type': 'application/json' + }, + params: { + projectId, + amount, + userAddress, + from, + to, + gasPrice + } + }) + }, + fetchSwapTokens({ projectId, chainId }: BlockchainApiSwapTokensRequest) { return api.get({ path: `/v1/convert/tokens?projectId=${projectId}&chainId=${chainId}` @@ -177,11 +203,15 @@ export const BlockchainApiController = { const { sdkType, sdkVersion } = OptionsController.state return api.get({ - path: `/v1/convert/gas-price?projectId=${projectId}&chainId=${chainId}`, + path: `/v1/convert/gas-price`, headers: { 'Content-Type': 'application/json', 'x-sdk-type': sdkType, 'x-sdk-version': sdkVersion + }, + params: { + projectId, + chainId } }) }, @@ -220,16 +250,22 @@ export const BlockchainApiController = { const { sdkType, sdkVersion } = OptionsController.state return api.get({ - path: `/v1/convert/build-approve?projectId=${projectId}&userAddress=${userAddress}&from=${from}&to=${to}`, + path: `/v1/convert/build-approve`, headers: { 'Content-Type': 'application/json', 'x-sdk-type': sdkType, 'x-sdk-version': sdkVersion + }, + params: { + projectId, + userAddress, + from, + to } }) }, - async getBalance(address: string, chainId?: string) { + async getBalance(address: string, chainId?: string, forceUpdate?: string) { const { sdkType, sdkVersion } = OptionsController.state return api.get({ @@ -241,7 +277,8 @@ export const BlockchainApiController = { params: { currency: 'usd', projectId: OptionsController.state.projectId, - chainId + chainId, + forceUpdate } }) }, diff --git a/packages/core/src/controllers/SwapController.ts b/packages/core/src/controllers/SwapController.ts index 9ba4554a79..ddf6059465 100644 --- a/packages/core/src/controllers/SwapController.ts +++ b/packages/core/src/controllers/SwapController.ts @@ -16,6 +16,7 @@ import { SwapCalculationUtil } from '../utils/SwapCalculationUtil.js' // -- Constants ---------------------------------------- // export const INITIAL_GAS_LIMIT = 150000 +export const TO_AMOUNT_DECIMALS = 6 // -- Types --------------------------------------------- // export type SwapInputTarget = 'sourceToken' | 'toToken' @@ -79,7 +80,7 @@ export interface SwapControllerState { tokensPriceMap: Record // Calculations - gasFee: bigint + gasFee: string gasPriceInUSD?: number priceImpact: number | undefined maxSlippage: number | undefined @@ -141,7 +142,7 @@ const initialState: SwapControllerState = { tokensPriceMap: {}, // Calculations - gasFee: BigInt(0), + gasFee: '0', gasPriceInUSD: 0, priceImpact: undefined, maxSlippage: undefined, @@ -232,6 +233,8 @@ export const SwapController = { setToTokenAmount(amount: string) { state.toTokenAmount = amount + ? NumberUtil.formatNumberToLocalString(amount, TO_AMOUNT_DECIMALS) + : '' }, async setTokenPrice(address: string, target: SwapInputTarget) { @@ -383,9 +386,8 @@ export const SwapController = { const fungibles = response.fungibles || [] const allTokens = [...(state.tokens || []), ...(state.myTokensWithBalance || [])] const symbol = allTokens?.find(token => token.address === address)?.symbol - const price = - fungibles.find(p => p.symbol.toLowerCase() === symbol?.toLowerCase())?.price || '0' - const priceAsFloat = parseFloat(price) + const price = fungibles.find(p => p.symbol.toLowerCase() === symbol?.toLowerCase())?.price || 0 + const priceAsFloat = parseFloat(price.toString()) state.tokensPriceMap[address] = priceAsFloat @@ -400,14 +402,14 @@ export const SwapController = { addresses: [networkAddress] }) const token = response.fungibles?.[0] - const price = token?.price || '0' + const price = token?.price.toString() || '0' state.tokensPriceMap[networkAddress] = parseFloat(price) state.networkTokenSymbol = token?.symbol || '' state.networkPrice = price }, - async getMyTokensWithBalance() { - const balances = await SwapApiUtil.getMyTokensWithBalance() + async getMyTokensWithBalance(forceUpdate?: string) { + const balances = await SwapApiUtil.getMyTokensWithBalance(forceUpdate) if (!balances) { return @@ -448,6 +450,7 @@ export const SwapController = { const gasLimit = BigInt(INITIAL_GAS_LIMIT) const gasPrice = SwapCalculationUtil.getGasPriceInUSD(state.networkPrice, gasLimit, gasFee) + state.gasFee = value state.gasPriceInUSD = gasPrice return { gasPrice: gasFee, gasPriceInUSD: state.gasPriceInUSD } @@ -455,6 +458,7 @@ export const SwapController = { // -- Swap -------------------------------------- // async swapTokens() { + const address = AccountController.state.address as `${string}:${string}:${string}` const sourceToken = state.sourceToken const toToken = state.toToken const haveSourceTokenAmount = NumberUtil.bigNumber(state.sourceTokenAmount).isGreaterThan(0) @@ -464,14 +468,32 @@ export const SwapController = { } state.loading = true - state.toTokenAmount = SwapCalculationUtil.getToTokenAmount({ - sourceToken: state.sourceToken, - toToken: state.toToken, - sourceTokenPrice: state.sourceTokenPriceInUSD, - toTokenPrice: state.toTokenPriceInUSD, - sourceTokenAmount: state.sourceTokenAmount + + const amountDecimal = NumberUtil.bigNumber(state.sourceTokenAmount).multipliedBy( + 10 ** sourceToken.decimals + ) + + const quoteResponse = await BlockchainApiController.fetchSwapQuote({ + userAddress: address, + projectId: OptionsController.state.projectId, + from: sourceToken.address, + to: toToken.address, + gasPrice: state.gasFee, + amount: amountDecimal.toString() }) + const quoteToAmount = quoteResponse?.quotes?.[0]?.toAmount + + if (!quoteToAmount) { + return + } + + const toTokenAmount = NumberUtil.bigNumber(quoteToAmount) + .dividedBy(10 ** toToken.decimals) + .toString() + + this.setToTokenAmount(toTokenAmount) + const isInsufficientToken = this.hasInsufficientToken( state.sourceTokenAmount, sourceToken.address @@ -481,8 +503,7 @@ export const SwapController = { state.inputError = 'Insufficient balance' } else { state.inputError = undefined - const transaction = await this.getTransaction() - this.setTransactionDetails(transaction) + this.setTransactionDetails() } state.loading = false @@ -494,7 +515,7 @@ export const SwapController = { const sourceToken = state.sourceToken const toToken = state.toToken - if (!fromCaipAddress || !availableToSwap || !sourceToken || !toToken || !state.loading) { + if (!fromCaipAddress || !availableToSwap || !sourceToken || !toToken || state.loading) { return undefined } @@ -514,11 +535,13 @@ export const SwapController = { } else { transaction = await this.createAllowanceTransaction() } + state.loading = false state.fetchError = false return transaction } catch (error) { + RouterController.goBack() SnackController.showError('Failed to check allowance') state.approvalTransaction = undefined state.swapTransaction = undefined @@ -566,6 +589,7 @@ export const SwapController = { return transaction } catch (error) { + RouterController.goBack() SnackController.showError('Failed to create approval transaction') state.approvalTransaction = undefined state.swapTransaction = undefined @@ -619,6 +643,8 @@ export const SwapController = { return transaction } catch (error) { + RouterController.goBack() + SnackController.showError('Failed to create transaction') state.approvalTransaction = undefined state.swapTransaction = undefined state.fetchError = true @@ -677,6 +703,7 @@ export const SwapController = { const successMessage = `Swapped ${state.sourceToken ?.symbol} to ${NumberUtil.formatNumberToLocalString(toTokenAmount, 3)} ${state.toToken ?.symbol}!` + const forceUpdateAddresses = [state.sourceToken?.address, state.toToken?.address].join(',') const transactionHash = await ConnectionController.sendTransaction({ address: fromAddress as `0x${string}`, to: data.to as `0x${string}`, @@ -689,7 +716,7 @@ export const SwapController = { SnackController.showSuccess(successMessage) SwapController.resetState() - SwapController.getMyTokensWithBalance() + SwapController.getMyTokensWithBalance(forceUpdateAddresses) return transactionHash } catch (err) { @@ -718,24 +745,23 @@ export const SwapController = { }, // -- Calculations -------------------------------------- // - setTransactionDetails(transaction: TransactionParams | undefined) { + setTransactionDetails() { const { toTokenAddress, toTokenDecimals } = this.getParams() - if (!transaction || !toTokenAddress || !toTokenDecimals) { + if (!toTokenAddress || !toTokenDecimals) { return } state.gasPriceInUSD = SwapCalculationUtil.getGasPriceInUSD( state.networkPrice, - transaction.gas, - transaction.gasPrice + BigInt(state.gasFee), + BigInt(INITIAL_GAS_LIMIT) ) state.priceImpact = SwapCalculationUtil.getPriceImpact({ sourceTokenAmount: state.sourceTokenAmount, sourceTokenPriceInUSD: state.sourceTokenPriceInUSD, toTokenPriceInUSD: state.toTokenPriceInUSD, - toTokenAmount: state.toTokenAmount, - gasPriceInUSD: state.gasPriceInUSD + toTokenAmount: state.toTokenAmount }) state.maxSlippage = SwapCalculationUtil.getMaxSlippage(state.slippage, state.toTokenAmount) state.providerFee = SwapCalculationUtil.getProviderFee(state.sourceTokenAmount) diff --git a/packages/core/src/utils/SwapApiUtil.ts b/packages/core/src/utils/SwapApiUtil.ts index 4ea46124bc..adddbcfd72 100644 --- a/packages/core/src/utils/SwapApiUtil.ts +++ b/packages/core/src/utils/SwapApiUtil.ts @@ -86,7 +86,7 @@ export const SwapApiUtil = { return false }, - async getMyTokensWithBalance() { + async getMyTokensWithBalance(forceUpdate?: string) { const address = AccountController.state.address const caipNetwork = NetworkController.state.caipNetwork @@ -94,8 +94,10 @@ export const SwapApiUtil = { return [] } - const response = await BlockchainApiController.getBalance(address, caipNetwork.id) - const balances = response.balances + const response = await BlockchainApiController.getBalance(address, caipNetwork.id, forceUpdate) + const balances = response.balances.filter(balance => balance.quantity.decimals !== '0') + + AccountController.setTokenBalance(balances) return this.mapBalancesToSwapTokens(balances) }, diff --git a/packages/core/src/utils/SwapCalculationUtil.ts b/packages/core/src/utils/SwapCalculationUtil.ts index 11fc8284a2..dd0988a405 100644 --- a/packages/core/src/utils/SwapCalculationUtil.ts +++ b/packages/core/src/utils/SwapCalculationUtil.ts @@ -24,23 +24,16 @@ export const SwapCalculationUtil = { sourceTokenAmount, sourceTokenPriceInUSD, toTokenPriceInUSD, - toTokenAmount, - gasPriceInUSD + toTokenAmount }: { sourceTokenAmount: string sourceTokenPriceInUSD: number toTokenPriceInUSD: number toTokenAmount: string - gasPriceInUSD: number }) { - const totalCostInUSD = NumberUtil.bigNumber(sourceTokenAmount) - .multipliedBy(sourceTokenPriceInUSD) - .plus(gasPriceInUSD) - const effectivePricePerToToken = totalCostInUSD.dividedBy(toTokenAmount) - const priceImpact = effectivePricePerToToken - .minus(toTokenPriceInUSD) - .dividedBy(toTokenPriceInUSD) - .multipliedBy(100) + const inputValue = NumberUtil.bigNumber(sourceTokenAmount).multipliedBy(sourceTokenPriceInUSD) + const outputValue = NumberUtil.bigNumber(toTokenAmount).multipliedBy(toTokenPriceInUSD) + const priceImpact = inputValue.minus(outputValue).dividedBy(inputValue).multipliedBy(100) return priceImpact.toNumber() }, diff --git a/packages/core/src/utils/TypeUtil.ts b/packages/core/src/utils/TypeUtil.ts index 0c5e8a4a1c..6836deabc8 100644 --- a/packages/core/src/utils/TypeUtil.ts +++ b/packages/core/src/utils/TypeUtil.ts @@ -194,6 +194,26 @@ export interface BlockchainApiSwapTokensResponse { tokens: SwapToken[] } +export interface BlockchainApiSwapQuoteRequest { + projectId: string + chainId?: string + amount: string + userAddress: string + from: string + to: string + gasPrice: string +} + +export interface BlockchainApiSwapQuoteResponse { + quotes: { + id: string | null + fromAmount: string + fromAccount: string + toAmount: string + toAccount: string + }[] +} + export interface BlockchainApiTokenPriceRequest { projectId: string currency?: 'usd' | 'eur' | 'gbp' | 'aud' | 'cad' | 'inr' | 'jpy' | 'btc' | 'eth' @@ -205,7 +225,7 @@ export interface BlockchainApiTokenPriceResponse { name: string symbol: string iconUrl: string - price: string + price: number }[] } diff --git a/packages/core/tests/controllers/SwapController.test.ts b/packages/core/tests/controllers/SwapController.test.ts index 4dcc5f6dbd..63effdb0ae 100644 --- a/packages/core/tests/controllers/SwapController.test.ts +++ b/packages/core/tests/controllers/SwapController.test.ts @@ -1,30 +1,26 @@ import { beforeAll, describe, expect, it, vi } from 'vitest' +import { parseUnits } from 'viem' import { AccountController, BlockchainApiController, + ConnectionController, NetworkController, SwapController, type CaipNetworkId, type NetworkControllerClient } from '../../index.js' import { + allowanceResponse, balanceResponse, gasPriceResponse, networkTokenPriceResponse, + swapCalldataResponse, + swapQuoteResponse, tokensResponse } from '../mocks/SwapController.js' -import { INITIAL_GAS_LIMIT } from '../../src/controllers/SwapController.js' import { SwapApiUtil } from '../../src/utils/SwapApiUtil.js' // - Mocks --------------------------------------------------------------------- -const mockTransaction = { - data: '0x11111', - gas: BigInt(INITIAL_GAS_LIMIT), - gasPrice: BigInt(10000000000), - to: '0x222', - toAmount: '1', - value: BigInt(1) -} const caipNetwork = { id: 'eip155:137', name: 'Polygon' } as const const approvedCaipNetworkIds = ['eip155:1', 'eip155:137'] as CaipNetworkId[] const client: NetworkControllerClient = { @@ -45,11 +41,14 @@ beforeAll(async () => { await NetworkController.switchActiveNetwork(caipNetwork) AccountController.setCaipAddress(caipAddress) + vi.spyOn(BlockchainApiController, 'fetchSwapTokens').mockResolvedValue(tokensResponse) vi.spyOn(BlockchainApiController, 'getBalance').mockResolvedValue(balanceResponse) + vi.spyOn(BlockchainApiController, 'fetchSwapQuote').mockResolvedValue(swapQuoteResponse) vi.spyOn(BlockchainApiController, 'fetchTokenPrice').mockResolvedValue(networkTokenPriceResponse) - vi.spyOn(SwapApiUtil, 'getTokenList').mockResolvedValue(tokensResponse) + vi.spyOn(BlockchainApiController, 'generateSwapCalldata').mockResolvedValue(swapCalldataResponse) + vi.spyOn(BlockchainApiController, 'fetchSwapAllowance').mockResolvedValue(allowanceResponse) vi.spyOn(SwapApiUtil, 'fetchGasPrice').mockResolvedValue(gasPriceResponse) - vi.spyOn(SwapController, 'getTransaction').mockResolvedValue(mockTransaction) + vi.spyOn(ConnectionController, 'parseUnits').mockResolvedValue(parseUnits('1', 18)) await SwapController.initializeState() @@ -65,15 +64,15 @@ describe('SwapController', () => { it('should set toToken as expected', () => { expect(SwapController.state.toToken?.address).toEqual(toTokenAddress) - expect(SwapController.state.toTokenPriceInUSD).toEqual(38.0742530944) + expect(SwapController.state.toTokenPriceInUSD).toEqual(40.101925674) }) it('should calculate swap values as expected', async () => { await SwapController.swapTokens() - expect(SwapController.state.gasPriceInUSD).toEqual(0.0010485260814) - expect(SwapController.state.priceImpact).toEqual(1.0003077978972612) - expect(SwapController.state.maxSlippage).toEqual(0.00019255219039488635) + expect(SwapController.state.gasPriceInUSD).toEqual(0.00648630001383744) + expect(SwapController.state.priceImpact).toEqual(3.952736601951709) + expect(SwapController.state.maxSlippage).toEqual(0.0001726) }) it('should reset values as expected', () => { diff --git a/packages/core/tests/mocks/SwapController.ts b/packages/core/tests/mocks/SwapController.ts index ca58ddf530..5704c2d5d3 100644 --- a/packages/core/tests/mocks/SwapController.ts +++ b/packages/core/tests/mocks/SwapController.ts @@ -1,55 +1,49 @@ -export const tokensResponse = [ - { - name: 'Matic Token', - symbol: 'MATIC', - address: - 'eip155:137:0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as `${string}:${string}:${string}`, - value: 15.945686877137186, - price: 0.6990173876, - decimals: 18, - quantity: { - numeric: '22.811574018044047908', - decimals: '18' - }, - logoUri: 'https://token-icons.s3.amazonaws.com/0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0.png' - }, - { - name: 'ShapeShift FOX', - symbol: 'FOX', - address: - 'eip155:137:0x65a05db8322701724c197af82c9cae41195b0aa8' as `${string}:${string}:${string}`, - value: 0.818151429070586, - price: 0.10315220553291868, - decimals: 18, - quantity: { - numeric: '9.348572710146769370', - decimals: '18' +export const tokensResponse = { + tokens: [ + { + name: 'MATIC', + symbol: 'MATIC', + address: + 'eip155:137:0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as `0x${string}:${string}:${string}`, + decimals: 18, + logoUri: 'https://tokens.1inch.io/0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0.png', + eip2612: false }, - logoUri: 'https://token-icons.s3.amazonaws.com/0xc770eefad204b5180df6a14ee197d99d808ee52d.png' - }, - { - name: 'Tether USD', - symbol: 'USDT', - address: - 'eip155:137:0xc2132d05d31c914a87c6611c10748aeb04b58e8f' as `${string}:${string}:${string}`, - value: 0.8888156632489365, - price: 0.9995840116762155, - decimals: 6, - quantity: { - numeric: '0.888765', - decimals: '6' + { + name: 'Avalanche Token', + symbol: 'AVAX', + address: + 'eip155:137:0x2c89bbc92bd86f8075d1decc58c7f4e0107f286b' as `0x${string}:${string}:${string}`, + decimals: 18, + logoUri: 'https://tokens.1inch.io/0x2c89bbc92bd86f8075d1decc58c7f4e0107f286b.png', + eip2612: false }, - logoUri: 'https://token-icons.s3.amazonaws.com/0xdac17f958d2ee523a2206206994597c13d831ec7.png' - } -] + { + name: 'USD Coin', + symbol: 'USDC', + address: + 'eip155:137:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359' as `0x${string}:${string}:${string}`, + decimals: 6, + logoUri: 'https://tokens.1inch.io/0x3c499c542cef5e3811e1192ce70d8cc03d5c3359.png', + eip2612: false + } + ] +} export const networkTokenPriceResponse = { fungibles: [ { - name: 'Matic Token', + name: 'MATIC', symbol: 'MATIC', - price: '0.6990173876', - iconUrl: 'https://token-icons.s3.amazonaws.com/0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0.png' + iconUrl: 'https://tokens.1inch.io/0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0.png', + price: 0.7207 + }, + { + name: 'USD Coin', + symbol: 'USDC', + iconUrl: + 'https://token-icons.s3.amazonaws.com/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', + price: 1.0000718186 } ] } @@ -60,11 +54,11 @@ export const balanceResponse = { name: 'Matic Token', symbol: 'MATIC', chainId: 'eip155:137', - value: 10.667935172031754, - price: 0.7394130944, + value: 7.1523453459986115, + price: 0.7206444126000001, quantity: { decimals: '18', - numeric: '14.427571343848456409' + numeric: '9.924929994522253814' }, iconUrl: 'https://token-icons.s3.amazonaws.com/0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0.png' }, @@ -73,30 +67,59 @@ export const balanceResponse = { symbol: 'AVAX', chainId: 'eip155:137', address: 'eip155:137:0x2c89bbc92bd86f8075d1decc58c7f4e0107f286b', - value: 3.751852120639868, - price: 38.0742530944, + value: 0.692163347318501, + price: 40.101925674, quantity: { decimals: '18', - numeric: '0.098540399764051957' + numeric: '0.017260102493463641' }, iconUrl: 'https://token-icons.s3.amazonaws.com/0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7.png' }, { - name: 'Tether USD', - symbol: 'USDT', + name: 'USD Coin', + symbol: 'USDC', chainId: 'eip155:137', - address: 'eip155:137:0xc2132d05d31c914a87c6611c10748aeb04b58e8f', - value: 2.3040319252130432, - price: 1.0010962048, + address: 'eip155:137:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + value: 2.711438769801216, + price: 0.999957504, quantity: { decimals: '6', - numeric: '2.301509' + numeric: '2.711554' }, - iconUrl: 'https://token-icons.s3.amazonaws.com/0xdac17f958d2ee523a2206206994597c13d831ec7.png' + iconUrl: 'https://token-icons.s3.amazonaws.com/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png' + } + ] +} + +export const allowanceResponse = { + allowance: '115792089237316195423570985008687907853269984665640564039457584007913129639935' +} + +export const swapQuoteResponse = { + quotes: [ + { + id: null, + fromAmount: '1000000000000000000', + fromAccount: 'eip155:137:0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + toAmount: '17259970548235021', + toAccount: 'eip155:137:0x2c89bbc92bd86f8075d1decc58c7f4e0107f286b' } ] } +export const swapCalldataResponse = { + tx: { + from: 'eip155:137:0xe8e0d27a1232ada1d76ac4032a100f8f9f3486b2' as `${string}:${string}:${string}`, + to: 'eip155:137:0x111111125421ca6dc452d289314280a0f8842a65' as `${string}:${string}:${string}`, + data: '0x07ed2379000000000000000000000000e37e799d5077682fa0a244d46e5649f71457bd09000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000d6df932a45c0f255f85145f286ea0b292b21c90b000000000000000000000000e37e799d5077682fa0a244d46e5649f71457bd09000000000000000000000000e8e0d27a1232ada1d76ac4032a100f8f9f3486b20000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000001a5256ff077cbc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000014900000000000000000000000000000000012b0000fd00006e00005400004e802026678dcd00000000000000000000000000000000000000003e26ca57697d2ad49edd5c3787256586d0b50525000000000000000000000000000000000000000000000000001e32b47897400000206b4be0b940410d500b1d8e8ef31e21c99d1db9a6444d3adf1270d0e30db00c200d500b1d8e8ef31e21c99d1db9a6444d3adf12707d88d931504d04bfbee6f9745297a93063cab24c6ae40711b8002dc6c07d88d931504d04bfbee6f9745297a93063cab24c111111125421ca6dc452d289314280a0f8842a65000000000000000000000000000000000000000000000000001a5256ff077cbc0d500b1d8e8ef31e21c99d1db9a6444d3adf12700020d6bdbf78d6df932a45c0f255f85145f286ea0b292b21c90b111111125421ca6dc452d289314280a0f8842a6500000000000000000000000000000000000000000000003bd94e2a' as `0x${string}`, + amount: '7483720195780716', + eip155: { + gas: '253421', + gasPrice: '151168582876' + } + } +} + export const gasPriceResponse = { standard: '60000000128', fast: '150000000128', diff --git a/packages/core/tests/utils/SwapCalculationUtil.test.ts b/packages/core/tests/utils/SwapCalculationUtil.test.ts index 80ca32d900..efcafd8ab3 100644 --- a/packages/core/tests/utils/SwapCalculationUtil.test.ts +++ b/packages/core/tests/utils/SwapCalculationUtil.test.ts @@ -1,19 +1,24 @@ import { describe, expect, it } from 'vitest' import { SwapCalculationUtil } from '../../src/utils/SwapCalculationUtil.js' import { INITIAL_GAS_LIMIT } from '../../src/controllers/SwapController.js' -import { networkTokenPriceResponse, tokensResponse } from '../mocks/SwapController.js' +import { balanceResponse, networkTokenPriceResponse } from '../mocks/SwapController.js' import type { SwapTokenWithBalance } from '../../src/utils/TypeUtil.js' import { NumberUtil } from '@web3modal/common' +import { SwapApiUtil } from '../../src/utils/SwapApiUtil.js' // - Mocks --------------------------------------------------------------------- const gasLimit = BigInt(INITIAL_GAS_LIMIT) const gasFee = BigInt(455966887160) -const sourceToken = tokensResponse[0] as SwapTokenWithBalance +const tokensWithBalance = SwapApiUtil.mapBalancesToSwapTokens(balanceResponse.balances) + +// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style +const sourceToken = tokensWithBalance[0] as SwapTokenWithBalance const sourceTokenAmount = '1' -const toToken = tokensResponse[1] as SwapTokenWithBalance +// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style +const toToken = tokensWithBalance[1] as SwapTokenWithBalance -const networkPrice = networkTokenPriceResponse.fungibles[0]?.price || '0' +const networkPrice = networkTokenPriceResponse.fungibles[0]?.price.toString() || '0' // -- Tests -------------------------------------------------------------------- describe('SwapCalculationUtil', () => { @@ -22,7 +27,7 @@ describe('SwapCalculationUtil', () => { const gasPriceInUSD = SwapCalculationUtil.getGasPriceInUSD(networkPrice, gasLimit, gasFee) expect(gasPriceInEther).toEqual(0.068395033074) - expect(gasPriceInUSD).toEqual(0.04780931734420308) + expect(gasPriceInUSD).toEqual(0.0492923003364318) }) it('should return insufficient balance as expected', () => { @@ -42,7 +47,6 @@ describe('SwapCalculationUtil', () => { }) it('should get the price impact as expected', () => { - const gasPriceInUSD = SwapCalculationUtil.getGasPriceInUSD(networkPrice, gasLimit, gasFee) const toTokenAmount = SwapCalculationUtil.getToTokenAmount({ sourceToken, sourceTokenAmount, @@ -52,13 +56,12 @@ describe('SwapCalculationUtil', () => { }) const priceImpact = SwapCalculationUtil.getPriceImpact({ - gasPriceInUSD, sourceTokenAmount, sourceTokenPriceInUSD: sourceToken.price, toTokenAmount, toTokenPriceInUSD: toToken.price }) - expect(priceImpact).equal(7.755424414926879) + expect(priceImpact).equal(0.8499999999999975) }) it('should get to token amount with same decimals including provider fee as expected', () => { @@ -69,11 +72,12 @@ describe('SwapCalculationUtil', () => { toToken, toTokenPrice: toToken.price }) - expect(toTokenAmount).equal('6.718961909003687207') + expect(toTokenAmount).equal('0.017817571677266286') }) it('should get to token amount with different decimals including provider fee as expected', () => { - const newToToken = tokensResponse[2] as SwapTokenWithBalance + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + const newToToken = tokensWithBalance[2] as SwapTokenWithBalance const toTokenAmount = SwapCalculationUtil.getToTokenAmount({ sourceToken, @@ -82,7 +86,7 @@ describe('SwapCalculationUtil', () => { toToken: newToToken, toTokenPrice: newToToken.price }) - expect(toTokenAmount).equal('0.693364') + expect(toTokenAmount).equal('0.714549') }) it('should calculate the maximum slippage as expected', () => { diff --git a/packages/scaffold/src/partials/w3m-account-wallet-features-widget/index.ts b/packages/scaffold/src/partials/w3m-account-wallet-features-widget/index.ts index 5d300a429a..b641b9d731 100644 --- a/packages/scaffold/src/partials/w3m-account-wallet-features-widget/index.ts +++ b/packages/scaffold/src/partials/w3m-account-wallet-features-widget/index.ts @@ -24,7 +24,7 @@ export class W3mAccountWalletFeaturesWidget extends LitElement { public static override styles = styles // -- Members ------------------------------------------- // - @state() private watchTokenBalance?: NodeJS.Timeout + @state() private watchTokenBalance?: ReturnType private unsubscribe: (() => void)[] = [] diff --git a/packages/scaffold/src/partials/w3m-header/index.ts b/packages/scaffold/src/partials/w3m-header/index.ts index d367178709..8fe43822ae 100644 --- a/packages/scaffold/src/partials/w3m-header/index.ts +++ b/packages/scaffold/src/partials/w3m-header/index.ts @@ -13,6 +13,9 @@ import { LitElement, html } from 'lit' import { state } from 'lit/decorators.js' import styles from './styles.js' +// -- Constants ----------------------------------------- // +const BETA_SCREENS = ['Swap', 'SwapSelectToken', 'SwapPreview'] + // -- Helpers ------------------------------------------- // function headings() { const connectorName = RouterController.state.data?.connector?.name @@ -134,7 +137,14 @@ export class W3mHeader extends LitElement { } private titleTemplate() { - return html`${this.heading}` + const isBeta = BETA_SCREENS.includes(RouterController.state.view) + + return html` + + ${this.heading} + ${isBeta ? html`Beta` : null} + + ` } private dynamicButtonTemplate() { @@ -172,7 +182,8 @@ export class W3mHeader extends LitElement { } private async onViewChange(view: RouterControllerState['view']) { - const headingEl = this.shadowRoot?.querySelector('wui-text') + const headingEl = this.shadowRoot?.querySelector('wui-flex.w3m-header-title') + if (headingEl) { const preset = headings()[view] await headingEl.animate([{ opacity: 1 }, { opacity: 0 }], { diff --git a/packages/scaffold/src/partials/w3m-swap-details/index.ts b/packages/scaffold/src/partials/w3m-swap-details/index.ts index 9ac46e0245..0004e9074c 100644 --- a/packages/scaffold/src/partials/w3m-swap-details/index.ts +++ b/packages/scaffold/src/partials/w3m-swap-details/index.ts @@ -1,40 +1,64 @@ import { html, LitElement } from 'lit' -import { property } from 'lit/decorators.js' +import { property, state } from 'lit/decorators.js' import styles from './styles.js' import { UiHelperUtil, customElement } from '@web3modal/ui' import { NumberUtil } from '@web3modal/common' -import { NetworkController } from '@web3modal/core' +import { ConstantsUtil, NetworkController, SwapController } from '@web3modal/core' + +// -- Constants ----------------------------------------- // +const slippageRate = ConstantsUtil.CONVERT_SLIPPAGE_TOLERANCE @customElement('w3m-swap-details') export class WuiSwapDetails extends LitElement { public static override styles = [styles] + private unsubscribe: ((() => void) | undefined)[] = [] + // -- State & Properties -------------------------------- // - @property() public networkName = NetworkController.state.caipNetwork?.name + @state() public networkName = NetworkController.state.caipNetwork?.name @property() public detailsOpen = false - @property() public sourceTokenSymbol?: string + @state() public sourceToken = SwapController.state.sourceToken - @property() public sourceTokenPrice?: number + @state() public toToken = SwapController.state.toToken - @property() public toTokenSymbol?: string + @state() public toTokenAmount = SwapController.state.toTokenAmount - @property() public toTokenAmount?: string + @state() public sourceTokenPriceInUSD = SwapController.state.sourceTokenPriceInUSD - @property() public toTokenSwappedAmount?: number + @state() public toTokenPriceInUSD = SwapController.state.toTokenPriceInUSD - @property() public gasPriceInUSD?: number + @state() public gasPriceInUSD = SwapController.state.gasPriceInUSD - @property() public priceImpact?: number + @state() public priceImpact = SwapController.state.priceImpact - @property() public slippageRate = 1 + @state() public maxSlippage = SwapController.state.maxSlippage - @property() public maxSlippage?: number + @state() public networkTokenSymbol = SwapController.state.networkTokenSymbol - @property() public providerFee?: string + @state() public inputError = SwapController.state.inputError - @property() public networkTokenSymbol?: string + // -- Lifecycle ----------------------------------------- // + public constructor() { + super() + + this.unsubscribe.push( + ...[ + SwapController.subscribe(newState => { + this.sourceToken = newState.sourceToken + this.toToken = newState.toToken + this.toTokenAmount = newState.toTokenAmount + this.gasPriceInUSD = newState.gasPriceInUSD + this.priceImpact = newState.priceImpact + this.maxSlippage = newState.maxSlippage + this.sourceTokenPriceInUSD = newState.sourceTokenPriceInUSD + this.toTokenPriceInUSD = newState.toTokenPriceInUSD + this.inputError = newState.inputError + }) + ] + ) + } // -- Render -------------------------------------------- // public override render() { @@ -43,19 +67,28 @@ export class WuiSwapDetails extends LitElement { ? NumberUtil.bigNumber(this.toTokenAmount).minus(this.maxSlippage).toString() : null + if (!this.sourceToken || !this.toToken || this.inputError) { + return null + } + + const toTokenSwappedAmount = + this.sourceTokenPriceInUSD && this.toTokenPriceInUSD + ? (1 / this.toTokenPriceInUSD) * this.sourceTokenPriceInUSD + : 0 + return html`