From a13b878f84c234ae77235c666edc1a60997de7f9 Mon Sep 17 00:00:00 2001 From: Enes Date: Mon, 6 May 2024 18:55:34 +0300 Subject: [PATCH] refactor: swap business logic improvements (#2199) --- packages/common/tests/NumberUtil.test.ts | 10 + .../core/src/controllers/RouterController.ts | 1 + .../core/src/controllers/SwapController.ts | 304 ++++++++---------- packages/core/src/utils/ConstantsUtil.ts | 34 +- packages/core/src/utils/SwapApiUtil.ts | 53 ++- .../core/src/utils/SwapCalculationUtil.ts | 142 ++++++++ .../tests/controllers/SwapController.test.ts | 117 +++---- packages/core/tests/mocks/SwapController.ts | 94 ++++-- .../tests/utils/SwapCalculationUtil.test.ts | 97 ++++++ packages/scaffold/index.ts | 1 + .../index.ts | 11 +- .../src/partials/w3m-swap-details/index.ts | 78 ++++- .../src/partials/w3m-swap-details/styles.ts | 10 +- .../partials/w3m-swap-input-skeleton/index.ts | 61 ++++ .../w3m-swap-input-skeleton/styles.ts | 45 +++ .../src/partials/w3m-swap-input/index.ts | 8 +- .../src/views/w3m-swap-preview-view/index.ts | 9 +- .../src/views/w3m-swap-preview-view/styles.ts | 6 - .../views/w3m-swap-select-token-view/index.ts | 25 +- .../scaffold/src/views/w3m-swap-view/index.ts | 79 ++--- .../src/views/w3m-swap-view/styles.ts | 22 +- .../views/w3m-unsupported-chain-view/index.ts | 31 +- .../ui/src/components/wui-shimmer/index.ts | 5 + .../ui/src/components/wui-shimmer/styles.ts | 14 + 24 files changed, 889 insertions(+), 368 deletions(-) create mode 100644 packages/common/tests/NumberUtil.test.ts create mode 100644 packages/core/src/utils/SwapCalculationUtil.ts create mode 100644 packages/core/tests/utils/SwapCalculationUtil.test.ts create mode 100644 packages/scaffold/src/partials/w3m-swap-input-skeleton/index.ts create mode 100644 packages/scaffold/src/partials/w3m-swap-input-skeleton/styles.ts diff --git a/packages/common/tests/NumberUtil.test.ts b/packages/common/tests/NumberUtil.test.ts new file mode 100644 index 0000000000..1b373f6466 --- /dev/null +++ b/packages/common/tests/NumberUtil.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { NumberUtil } from '../src/utils/NumberUtil.js' + +// -- Tests -------------------------------------------------------------------- +describe('NumberUtil', () => { + it('should return isGreaterThan as expected', () => { + const isGreaterThan = NumberUtil.bigNumber('6.348').isGreaterThan('0') + expect(isGreaterThan).toBe(true) + }) +}) diff --git a/packages/core/src/controllers/RouterController.ts b/packages/core/src/controllers/RouterController.ts index 34dcd339dd..b6baa29047 100644 --- a/packages/core/src/controllers/RouterController.ts +++ b/packages/core/src/controllers/RouterController.ts @@ -58,6 +58,7 @@ export interface RouterControllerState { email?: string newEmail?: string target?: SwapInputTarget + swapUnsupportedChain?: boolean } transactionStack: TransactionAction[] } diff --git a/packages/core/src/controllers/SwapController.ts b/packages/core/src/controllers/SwapController.ts index 984f826542..f6962192ff 100644 --- a/packages/core/src/controllers/SwapController.ts +++ b/packages/core/src/controllers/SwapController.ts @@ -12,6 +12,7 @@ import { NetworkController } from './NetworkController.js' import { CoreHelperUtil } from '../utils/CoreHelperUtil.js' import { BlockchainApiController } from './BlockchainApiController.js' import { OptionsController } from './OptionsController.js' +import { SwapCalculationUtil } from '../utils/SwapCalculationUtil.js' // -- Constants ---------------------------------------- // export const INITIAL_GAS_LIMIT = 150000 @@ -40,6 +41,7 @@ class TransactionError extends Error { export interface SwapControllerState { // Loading states + initializing: boolean initialized: boolean loadingPrices: boolean loading?: boolean @@ -59,6 +61,7 @@ export interface SwapControllerState { toTokenPriceInUSD: number networkPrice: string networkBalanceInUSD: string + networkTokenSymbol: string inputError: string | undefined // Request values @@ -77,6 +80,7 @@ export interface SwapControllerState { gasPriceInUSD?: number priceImpact: number | undefined maxSlippage: number | undefined + providerFee: string | undefined } export interface TokenInfo { @@ -94,8 +98,9 @@ export interface TokenInfo { type StateKey = keyof SwapControllerState // -- State --------------------------------------------- // -const state = proxy({ +const initialState: SwapControllerState = { // Loading states + initializing: false, initialized: false, loading: false, loadingPrices: false, @@ -115,6 +120,7 @@ const state = proxy({ toTokenPriceInUSD: 0, networkPrice: '0', networkBalanceInUSD: '0', + networkTokenSymbol: '', inputError: undefined, // Request values @@ -132,8 +138,11 @@ const state = proxy({ gasFee: BigInt(0), gasPriceInUSD: 0, priceImpact: undefined, - maxSlippage: undefined -}) + maxSlippage: undefined, + providerFee: undefined +} + +const state = proxy(initialState) // -- Controller ---------------------------------------- // export const SwapController = { @@ -155,6 +164,11 @@ export const SwapController = { throw new Error('No address found to swap the tokens from.') } + const caipAddress = AccountController.state.caipAddress + const invalidToToken = !state.toToken?.address || !state.toToken?.decimals + const invalidSourceToken = !state.sourceToken?.address || !state.sourceToken?.decimals + const invalidSourceTokenAmount = !state.sourceTokenAmount + return { networkAddress, fromAddress: address, @@ -164,7 +178,12 @@ export const SwapController = { toTokenAmount: state.toTokenAmount, toTokenDecimals: state.toToken?.decimals, sourceTokenAmount: state.sourceTokenAmount, - sourceTokenDecimals: state.sourceToken?.decimals + sourceTokenDecimals: state.sourceToken?.decimals, + invalidToToken, + invalidSourceToken, + invalidSourceTokenAmount, + availableToSwap: + caipAddress && !invalidToToken && !invalidSourceToken && !invalidSourceTokenAmount } }, @@ -178,51 +197,36 @@ export const SwapController = { } state.sourceToken = sourceToken - this.setTokenValues(sourceToken.address, 'sourceToken') + this.setTokenPrice(sourceToken.address, 'sourceToken') }, setSourceTokenAmount(amount: string) { - const { sourceTokenAddress } = this.getParams() - state.sourceTokenAmount = amount - - if (sourceTokenAddress) { - this.setTokenValues(sourceTokenAddress, 'sourceToken') - } }, setToToken(toToken: SwapTokenWithBalance | undefined) { - const { sourceTokenAddress, sourceTokenAmount } = this.getParams() - if (!toToken) { - state.toTokenAmount = '0' + state.toToken = toToken + state.toTokenAmount = '' state.toTokenPriceInUSD = 0 return } state.toToken = toToken - this.setTokenValues(toToken.address, 'toToken') - - if (sourceTokenAddress && sourceTokenAmount) { - this.makeChecks() - } + this.setTokenPrice(toToken.address, 'toToken') }, setToTokenAmount(amount: string) { - const { toTokenAddress } = this.getParams() - state.toTokenAmount = amount - - if (toTokenAddress) { - this.setTokenValues(toTokenAddress, 'toToken') - } }, - async setTokenValues(address: string, target: SwapInputTarget) { + async setTokenPrice(address: string, target: SwapInputTarget) { + const { availableToSwap } = this.getParams() let price = state.tokensPriceMap[address] || 0 if (!price) { + state.loadingPrices = true price = await this.getAddressPrice(address) } @@ -231,24 +235,42 @@ export const SwapController = { } else if (target === 'toToken') { state.toTokenPriceInUSD = price } + + if (state.loadingPrices) { + state.loadingPrices = false + if (availableToSwap) { + this.swapTokens() + } + } }, switchTokens() { const newSourceToken = state.toToken ? { ...state.toToken } : undefined const newToToken = state.sourceToken ? { ...state.sourceToken } : undefined + const newSourceTokenAmount = state.toTokenAmount this.setSourceToken(newSourceToken) this.setToToken(newToToken) - this.setSourceTokenAmount(state.toTokenAmount || '0') - SwapController.swapTokens() + this.setSourceTokenAmount(newSourceTokenAmount) + this.setToTokenAmount('') + this.swapTokens() }, - resetTokens() { - state.tokens = undefined - state.popularTokens = undefined - state.myTokensWithBalance = undefined - state.initialized = false + resetState() { + state.myTokensWithBalance = initialState.myTokensWithBalance + state.tokensPriceMap = initialState.tokensPriceMap + state.initialized = initialState.initialized + state.sourceToken = initialState.sourceToken + state.sourceTokenAmount = initialState.sourceTokenAmount + state.sourceTokenPriceInUSD = initialState.sourceTokenPriceInUSD + state.toToken = initialState.toToken + state.toTokenAmount = initialState.toTokenAmount + state.toTokenPriceInUSD = initialState.toTokenPriceInUSD + state.networkPrice = initialState.networkPrice + state.networkTokenSymbol = initialState.networkTokenSymbol + state.networkBalanceInUSD = initialState.networkBalanceInUSD + state.inputError = initialState.inputError }, resetValues() { @@ -256,10 +278,7 @@ export const SwapController = { const networkToken = state.tokens?.find(token => token.address === networkAddress) this.setSourceToken(networkToken) - state.sourceTokenPriceInUSD = state.tokensPriceMap[networkAddress] || 0 - state.sourceTokenAmount = '0' this.setToToken(undefined) - state.gasPriceInUSD = 0 }, clearError() { @@ -267,10 +286,22 @@ export const SwapController = { }, async initializeState() { + if (state.initializing) { + return + } + + state.initializing = true if (!state.initialized) { - await this.fetchTokens() - state.initialized = true + try { + await this.fetchTokens() + state.initialized = true + } catch (error) { + state.initialized = false + SnackController.showError('Failed to initialize swap') + RouterController.goBack() + } } + state.initializing = false }, async fetchTokens() { @@ -283,7 +314,9 @@ export const SwapController = { const networkToken = state.tokens?.find(token => token.address === networkAddress) if (networkToken) { + state.networkTokenSymbol = networkToken.symbol this.setSourceToken(networkToken) + this.setSourceTokenAmount('1') } }, @@ -303,14 +336,14 @@ export const SwapController = { return 0 }) .filter(token => { - if (ConstantsUtil.POPULAR_TOKENS.includes(token.symbol)) { + if (ConstantsUtil.SWAP_POPULAR_TOKENS.includes(token.symbol)) { return true } return false }, {}) state.suggestedTokens = tokens.filter(token => { - if (ConstantsUtil.SUGGESTED_TOKENS.includes(token.symbol)) { + if (ConstantsUtil.SWAP_SUGGESTED_TOKENS.includes(token.symbol)) { return true } @@ -332,7 +365,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 === symbol)?.price || '0' + const price = + fungibles.find(p => p.symbol.toLowerCase() === symbol?.toLowerCase())?.price || '0' const priceAsFloat = parseFloat(price) state.tokensPriceMap[address] = priceAsFloat @@ -350,6 +384,7 @@ export const SwapController = { const token = response.fungibles?.[0] const price = token?.price || '0' state.tokensPriceMap[networkAddress] = parseFloat(price) + state.networkTokenSymbol = token?.symbol || '' state.networkPrice = price }, @@ -366,13 +401,18 @@ export const SwapController = { setBalances(balances: SwapTokenWithBalance[]) { const { networkAddress } = this.getParams() + const caipNetwork = NetworkController.state.caipNetwork + + if (!caipNetwork) { + return + } const networkToken = balances.find(token => token.address === networkAddress) balances.forEach(token => { state.tokensPriceMap[token.address] = token.price || 0 }) - state.myTokensWithBalance = balances + state.myTokensWithBalance = balances.filter(token => token.address.startsWith(caipNetwork.id)) state.networkBalanceInUSD = networkToken ? NumberUtil.multiply(networkToken.quantity.numeric, networkToken.price).toString() : '0' @@ -388,7 +428,7 @@ export const SwapController = { const value = res.instant const gasFee = BigInt(value) const gasLimit = BigInt(INITIAL_GAS_LIMIT) - const gasPrice = this.calculateGasPriceInUSD(gasLimit, gasFee) + const gasPrice = SwapCalculationUtil.getGasPriceInUSD(state.networkPrice, gasLimit, gasFee) state.gasPriceInUSD = gasPrice }, @@ -402,63 +442,22 @@ export const SwapController = { } }, - calculateGasPriceInEther(gas: bigint, gasPrice: bigint) { - const totalGasCostInWei = gasPrice * gas - const totalGasCostInEther = Number(totalGasCostInWei) / 1e18 - - return totalGasCostInEther - }, - - calculateGasPriceInUSD(gas: bigint, gasPrice: bigint) { - const totalGasCostInEther = this.calculateGasPriceInEther(gas, gasPrice) - const networkPriceInUSD = NumberUtil.bigNumber(state.networkPrice) - const gasCostInUSD = networkPriceInUSD.multipliedBy(totalGasCostInEther) - - return gasCostInUSD.toNumber() - }, - - calculatePriceImpact(toTokenAmount: string, gasPriceInUSD: number) { - const sourceTokenAmount = state.sourceTokenAmount - const sourceTokenPrice = state.sourceTokenPriceInUSD - const toTokenPrice = state.toTokenPriceInUSD - - const totalCostInUSD = NumberUtil.bigNumber(sourceTokenAmount) - .multipliedBy(sourceTokenPrice) - .plus(gasPriceInUSD) - const effectivePricePerToToken = totalCostInUSD.dividedBy(toTokenAmount) - const priceImpact = effectivePricePerToToken - .minus(toTokenPrice) - .dividedBy(toTokenPrice) - .multipliedBy(100) - - return priceImpact.toNumber() - }, - - calculateMaxSlippage() { - const slippageToleranceDecimal = NumberUtil.bigNumber(state.slippage).dividedBy(100) - const maxSlippageAmount = NumberUtil.multiply(state.sourceTokenAmount, slippageToleranceDecimal) - - return maxSlippageAmount.toNumber() - }, - + // -- Transactions -------------------------------------- // async swapTokens() { - const { sourceTokenAddress, toTokenAddress } = this.getParams() - - if (!sourceTokenAddress || !toTokenAddress) { - return - } + const { availableToSwap } = this.getParams() - await this.makeChecks() - }, - - async makeChecks() { - const { toTokenDecimals, toTokenAddress } = this.getParams() - - if (!toTokenDecimals || !toTokenAddress) { + if (!availableToSwap || state.loadingPrices) { return } state.loading = true + state.toTokenAmount = SwapCalculationUtil.getToTokenAmount({ + sourceToken: state.sourceToken, + toToken: state.toToken, + sourceTokenPrice: state.sourceTokenPriceInUSD, + toTokenPrice: state.toTokenPriceInUSD, + sourceTokenAmount: state.sourceTokenAmount + }) const transaction = await this.getTransaction() this.setTransactionDetails(transaction) state.loading = false @@ -468,16 +467,28 @@ export const SwapController = { const { fromCaipAddress, sourceTokenAddress, sourceTokenAmount, sourceTokenDecimals } = this.getParams() - if ( - !fromCaipAddress || - !sourceTokenAddress || - !sourceTokenAmount || - parseFloat(sourceTokenAmount) === 0 || - !sourceTokenDecimals - ) { + if (!fromCaipAddress || !sourceTokenAddress || !sourceTokenAmount || !sourceTokenDecimals) { return undefined } + const isInsufficientSourceTokenForSwap = SwapCalculationUtil.isInsufficientSourceTokenForSwap( + sourceTokenAmount, + sourceTokenAddress, + state.myTokensWithBalance + ) + const insufficientNetworkTokenForGas = SwapCalculationUtil.isInsufficientNetworkTokenForGas( + state.networkBalanceInUSD, + state.gasPriceInUSD + ) + + if (insufficientNetworkTokenForGas || isInsufficientSourceTokenForSwap) { + state.inputError = 'Insufficient balance' + + return undefined + } + + state.inputError = undefined + const hasAllowance = await SwapApiUtil.fetchSwapAllowance({ userAddress: fromCaipAddress, tokenAddress: sourceTokenAddress, @@ -500,21 +511,6 @@ export const SwapController = { return transaction }, - getToAmount() { - const { sourceTokenDecimals } = this.getParams() - const decimals = sourceTokenDecimals || 18 - const multiplier = 10 ** decimals - - const toTokenSwapedAmount = - state.sourceTokenPriceInUSD && state.toTokenPriceInUSD && state.sourceTokenAmount - ? NumberUtil.bigNumber(state.sourceTokenAmount) - .multipliedBy(state.sourceTokenPriceInUSD) - .dividedBy(state.toTokenPriceInUSD) - : NumberUtil.bigNumber(0) - - return toTokenSwapedAmount.multipliedBy(multiplier).toString() - }, - async createTokenAllowance() { const { fromCaipAddress, fromAddress, sourceTokenAddress, toTokenAddress } = this.getParams() @@ -539,15 +535,13 @@ export const SwapController = { data: response.tx.data }) - const toAmount = this.getToAmount() - const transaction = { data: response.tx.data, to: CoreHelperUtil.getPlainAddress(response.tx.from) as `0x${string}`, gas: gasLimit, gasPrice: BigInt(response.tx.eip155.gasPrice), value: BigInt(response.tx.value), - toAmount + toAmount: state.toTokenAmount } return transaction @@ -573,7 +567,7 @@ export const SwapController = { state.approvalTransaction = undefined state.transactionLoading = false - this.makeChecks() + this.swapTokens() } catch (err) { const error = err as TransactionError state.transactionError = error?.shortMessage as unknown as string @@ -617,7 +611,6 @@ export const SwapController = { const isSourceTokenIsNetworkToken = sourceTokenAddress === networkAddress - const toAmount = this.getToAmount() const gas = BigInt(response.tx.eip155.gas) const gasPrice = BigInt(response.tx.eip155.gasPrice) @@ -627,10 +620,10 @@ export const SwapController = { gas, gasPrice, value: isSourceTokenIsNetworkToken ? BigInt(amount) : BigInt('0'), - toAmount + toAmount: state.toTokenAmount } - state.gasPriceInUSD = this.calculateGasPriceInUSD(gas, gasPrice) + state.gasPriceInUSD = SwapCalculationUtil.getGasPriceInUSD(state.networkPrice, gas, gasPrice) return transaction } catch (error) { @@ -651,7 +644,7 @@ export const SwapController = { view: 'Account', goBack: false, onSuccess() { - SwapController.resetValues() + SwapController.resetState() } }) @@ -666,10 +659,8 @@ export const SwapController = { }) state.transactionLoading = false - setTimeout(() => { - this.resetValues() - this.getMyTokensWithBalance() - }, 1000) + SwapController.resetState() + SwapController.getMyTokensWithBalance() return transactionHash } catch (err) { @@ -682,34 +673,6 @@ export const SwapController = { } }, - getToTokenValues(amountBigInt: string, decimals: number) { - const { toTokenAddress } = this.getParams() - - if (!toTokenAddress) { - return { - toTokenAmount: '0', - toTokenPriceInUSD: 0 - } - } - - const toTokenAmount = NumberUtil.bigNumber(amountBigInt) - .dividedBy(10 ** decimals) - .toFixed(20) - const toTokenPrice = state.tokensPriceMap[toTokenAddress] || '0' - const toTokenPriceInUSD = NumberUtil.bigNumber(toTokenPrice).toNumber() - - return { - toTokenAmount, - toTokenPriceInUSD - } - }, - - isInsufficientNetworkTokenForGas() { - return NumberUtil.bigNumber(NumberUtil.bigNumber(state.gasPriceInUSD || '0')).isGreaterThan( - state.networkBalanceInUSD - ) - }, - setTransactionDetails(transaction: TransactionParams | undefined) { const { toTokenAddress, toTokenDecimals } = this.getParams() @@ -717,22 +680,19 @@ export const SwapController = { return } - const insufficientNetworkToken = this.isInsufficientNetworkTokenForGas() - - if (insufficientNetworkToken) { - state.inputError = 'Insufficient balance' - } else { - state.inputError = undefined - } - - const { toTokenAmount, toTokenPriceInUSD } = this.getToTokenValues( - transaction.toAmount, - toTokenDecimals + state.gasPriceInUSD = SwapCalculationUtil.getGasPriceInUSD( + state.networkPrice, + transaction.gas, + transaction.gasPrice ) - state.toTokenAmount = toTokenAmount - state.toTokenPriceInUSD = toTokenPriceInUSD - state.gasPriceInUSD = this.calculateGasPriceInUSD(transaction.gas, transaction.gasPrice) - state.priceImpact = this.calculatePriceImpact(state.toTokenAmount, state.gasPriceInUSD) - state.maxSlippage = this.calculateMaxSlippage() + state.priceImpact = SwapCalculationUtil.getPriceImpact({ + sourceTokenAmount: state.sourceTokenAmount, + sourceTokenPriceInUSD: state.sourceTokenPriceInUSD, + toTokenPriceInUSD: state.toTokenPriceInUSD, + toTokenAmount: state.toTokenAmount, + gasPriceInUSD: state.gasPriceInUSD + }) + state.maxSlippage = SwapCalculationUtil.getMaxSlippage(state.slippage, state.toTokenAmount) + state.providerFee = SwapCalculationUtil.getProviderFee(state.sourceTokenAmount) } } diff --git a/packages/core/src/utils/ConstantsUtil.ts b/packages/core/src/utils/ConstantsUtil.ts index 63d9aaf66a..c31b0226f7 100644 --- a/packages/core/src/utils/ConstantsUtil.ts +++ b/packages/core/src/utils/ConstantsUtil.ts @@ -63,7 +63,7 @@ export const ConstantsUtil = { WC_COINBASE_ONRAMP_APP_ID: 'bf18c88d-495a-463b-b249-0b9d3656cf5e', - SUGGESTED_TOKENS: [ + SWAP_SUGGESTED_TOKENS: [ 'ETH', 'UNI', '1INCH', @@ -93,7 +93,7 @@ export const ConstantsUtil = { 'OP' ], - POPULAR_TOKENS: [ + SWAP_POPULAR_TOKENS: [ 'ETH', 'UNI', '1INCH', @@ -121,7 +121,8 @@ export const ConstantsUtil = { 'ENS', 'MATIC', 'OP', - // Some Polygon tokens + + 'METAL', 'DAI', 'CHAMP', 'WOLF', @@ -153,6 +154,33 @@ export const ConstantsUtil = { 'WNT' ], + SWAP_SUPPORTED_NETWORKS: [ + // Ethereum' + 'eip155:1', + // Arbitrum One' + 'eip155:42161', + // Optimism' + 'eip155:10', + // ZKSync Era' + 'eip155:324', + // Base' + 'eip155:8453', + // BNB Smart Chain' + 'eip155:56', + // Polygon' + 'eip155:137', + // Gnosis' + 'eip155:100', + // Avalanche' + 'eip155:43114', + // Fantom' + 'eip155:250', + // Klaytn' + 'eip155:8217', + // Aurora + 'eip155:1313161554' + ], + NATIVE_TOKEN_ADDRESS: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', CONVERT_SLIPPAGE_TOLERANCE: 1 diff --git a/packages/core/src/utils/SwapApiUtil.ts b/packages/core/src/utils/SwapApiUtil.ts index db513e2916..f429f11de3 100644 --- a/packages/core/src/utils/SwapApiUtil.ts +++ b/packages/core/src/utils/SwapApiUtil.ts @@ -28,18 +28,19 @@ export const SwapApiUtil = { projectId: OptionsController.state.projectId }) - const tokens = response.tokens.map(token => { - return { - ...token, - eip2612: false, - quantity: { - decimals: '0', - numeric: '0' - }, - price: 0, - value: 0 - } as SwapTokenWithBalance - }) + const tokens = + response?.tokens?.map(token => { + return { + ...token, + eip2612: false, + quantity: { + decimals: '0', + numeric: '0' + }, + price: 0, + value: 0 + } as SwapTokenWithBalance + }) || [] return tokens }, @@ -100,20 +101,18 @@ export const SwapApiUtil = { }, mapBalancesToSwapTokens(balances: BlockchainApiBalanceResponse['balances']) { - return balances.map(token => { - return { - symbol: token.symbol, - name: token.name, - address: token?.address - ? token.address - : `${NetworkController.state.caipNetwork?.id}:${ConstantsUtil.NATIVE_TOKEN_ADDRESS}`, - decimals: parseInt(token.quantity.decimals, 10), - logoUri: token.iconUrl, - eip2612: false, - quantity: token.quantity, - price: token.price, - value: token.value - } as SwapTokenWithBalance - }) + return ( + balances?.map(token => { + return { + ...token, + address: token?.address + ? token.address + : `${token.chainId}:${ConstantsUtil.NATIVE_TOKEN_ADDRESS}`, + decimals: parseInt(token.quantity.decimals, 10), + logoUri: token.iconUrl, + eip2612: false + } as SwapTokenWithBalance + }) || [] + ) } } diff --git a/packages/core/src/utils/SwapCalculationUtil.ts b/packages/core/src/utils/SwapCalculationUtil.ts new file mode 100644 index 0000000000..e580baa1eb --- /dev/null +++ b/packages/core/src/utils/SwapCalculationUtil.ts @@ -0,0 +1,142 @@ +// -- Types --------------------------------------------- // + +import { NumberUtil } from '@web3modal/common' +import type { SwapTokenWithBalance } from './TypeUtil.js' + +// -- Util ---------------------------------------- // +export const SwapCalculationUtil = { + getGasPriceInEther(gas: bigint, gasPrice: bigint) { + const totalGasCostInWei = gasPrice * gas + const totalGasCostInEther = Number(totalGasCostInWei) / 1e18 + + return totalGasCostInEther + }, + + getGasPriceInUSD(networkPrice: string, gas: bigint, gasPrice: bigint) { + const totalGasCostInEther = SwapCalculationUtil.getGasPriceInEther(gas, gasPrice) + const networkPriceInUSD = NumberUtil.bigNumber(networkPrice) + const gasCostInUSD = networkPriceInUSD.multipliedBy(totalGasCostInEther) + + return gasCostInUSD.toNumber() + }, + + getPriceImpact({ + sourceTokenAmount, + sourceTokenPriceInUSD, + toTokenPriceInUSD, + toTokenAmount, + gasPriceInUSD + }: { + 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) + + return priceImpact.toNumber() + }, + + getMaxSlippage(slippage: number, toTokenAmount: string) { + const slippageToleranceDecimal = NumberUtil.bigNumber(slippage).dividedBy(100) + const maxSlippageAmount = NumberUtil.multiply(toTokenAmount, slippageToleranceDecimal) + + return maxSlippageAmount.toNumber() + }, + + getProviderFee(sourceTokenAmount: string, feePercentage = 0.0075) { + const providerFee = NumberUtil.bigNumber(sourceTokenAmount).multipliedBy(feePercentage) + + return providerFee.toString() + }, + + isInsufficientNetworkTokenForGas(networkBalanceInUSD: string, gasPriceInUSD: number | undefined) { + const gasPrice = gasPriceInUSD || '0' + + if (NumberUtil.bigNumber(networkBalanceInUSD).isZero()) { + return true + } + + return NumberUtil.bigNumber(NumberUtil.bigNumber(gasPrice)).isGreaterThan(networkBalanceInUSD) + }, + + isInsufficientSourceTokenForSwap( + sourceTokenAmount: string, + sourceTokenAddress: string, + balance: SwapTokenWithBalance[] | undefined + ) { + const sourceTokenBalance = balance?.find(token => token.address === sourceTokenAddress) + ?.quantity?.numeric + + const isInSufficientBalance = NumberUtil.bigNumber(sourceTokenBalance || '0').isLessThan( + sourceTokenAmount + ) + + return isInSufficientBalance + }, + + getToTokenAmount({ + sourceToken, + toToken, + sourceTokenPrice, + toTokenPrice, + sourceTokenAmount + }: { + sourceToken: SwapTokenWithBalance | undefined + toToken: SwapTokenWithBalance | undefined + sourceTokenPrice: number + toTokenPrice: number + sourceTokenAmount: string + }) { + if (sourceTokenAmount === '0') { + return '0' + } + + if (!sourceToken || !toToken) { + return '0' + } + + const sourceTokenDecimals = sourceToken.decimals + const sourceTokenPriceInUSD = sourceTokenPrice + const toTokenDecimals = toToken.decimals + const toTokenPriceInUSD = toTokenPrice + + if (toTokenPriceInUSD <= 0) { + return '0' + } + + // Calculate the provider fee (0.75% of the source token amount) + const providerFee = NumberUtil.bigNumber(sourceTokenAmount).multipliedBy(0.0075) + + // Adjust the source token amount by subtracting the provider fee + const adjustedSourceTokenAmount = NumberUtil.bigNumber(sourceTokenAmount).minus(providerFee) + + // Proceed with conversion using the adjusted source token amount + const sourceAmountInSmallestUnit = adjustedSourceTokenAmount.multipliedBy( + NumberUtil.bigNumber(10).pow(sourceTokenDecimals) + ) + + const priceRatio = NumberUtil.bigNumber(sourceTokenPriceInUSD).dividedBy(toTokenPriceInUSD) + + const decimalDifference = sourceTokenDecimals - toTokenDecimals + const toTokenAmountInSmallestUnit = sourceAmountInSmallestUnit + .multipliedBy(priceRatio) + .dividedBy(NumberUtil.bigNumber(10).pow(decimalDifference)) + + const toTokenAmount = toTokenAmountInSmallestUnit.dividedBy( + NumberUtil.bigNumber(10).pow(toTokenDecimals) + ) + + const amount = toTokenAmount.toFixed(toTokenDecimals).toString() + + return amount + } +} diff --git a/packages/core/tests/controllers/SwapController.test.ts b/packages/core/tests/controllers/SwapController.test.ts index e07853f097..c84f1904ec 100644 --- a/packages/core/tests/controllers/SwapController.test.ts +++ b/packages/core/tests/controllers/SwapController.test.ts @@ -1,78 +1,85 @@ -import { beforeAll, describe, expect, it } from 'vitest' -import { AccountController, SwapController, type SwapTokenWithBalance } from '../../index.js' -import { prices, tokenInfo } from '../mocks/SwapController.js' +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { + AccountController, + BlockchainApiController, + NetworkController, + SwapController, + type CaipNetworkId, + type NetworkControllerClient +} from '../../index.js' +import { + balanceResponse, + gasPriceResponse, + networkTokenPriceResponse, + 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 = { + switchCaipNetwork: async _caipNetwork => Promise.resolve(), + getApprovedCaipNetworksData: async () => + Promise.resolve({ approvedCaipNetworkIds, supportsAllNetworks: false }) +} const caipAddress = 'eip155:1:0x123' -const gasLimit = BigInt(INITIAL_GAS_LIMIT) -const gasFee = BigInt(455966887160) +// MATIC +const networkTokenAddress = 'eip155:137:0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' +// AVAX +const toTokenAddress = 'eip155:137:0x2c89bbc92bd86f8075d1decc58c7f4e0107f286b' -const sourceTokenAddress = 'eip155:137:0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' +// - Setup --------------------------------------------------------------------- +beforeAll(async () => { + // -- Set Account and + NetworkController.setClient(client) + await NetworkController.switchActiveNetwork(caipNetwork) + AccountController.setCaipAddress(caipAddress) -const sourceToken = tokenInfo[0] as SwapTokenWithBalance -const toToken = tokenInfo[1] as SwapTokenWithBalance + vi.spyOn(BlockchainApiController, 'getBalance').mockResolvedValue(balanceResponse) + vi.spyOn(BlockchainApiController, 'fetchTokenPrice').mockResolvedValue(networkTokenPriceResponse) + vi.spyOn(SwapApiUtil, 'getTokenList').mockResolvedValue(tokensResponse) + vi.spyOn(SwapApiUtil, 'fetchGasPrice').mockResolvedValue(gasPriceResponse) + vi.spyOn(SwapController, 'getTransaction').mockResolvedValue(mockTransaction) -// - Helpers -function setSourceTokenAmount(value: string) { - SwapController.setSourceTokenAmount(value) - const toTokenAmount = SwapController.getToAmount() - const toTokenValues = SwapController.getToTokenValues(toTokenAmount, toToken?.decimals) - SwapController.state.toTokenAmount = toTokenValues.toTokenAmount - SwapController.state.toTokenPriceInUSD = toTokenValues.toTokenPriceInUSD -} + await SwapController.initializeState() -// - Setup --------------------------------------------------------------------- -beforeAll(() => { - AccountController.setCaipAddress(caipAddress) - SwapController.state.tokensPriceMap = prices - SwapController.state.networkPrice = prices[sourceTokenAddress].toString() - SwapController.state.networkBalanceInUSD = '2' - SwapController.state.gasPriceInUSD = SwapController.calculateGasPriceInUSD(gasLimit, gasFee) - SwapController.setSourceToken(sourceToken) - SwapController.state.sourceTokenPriceInUSD = sourceToken.price + const toToken = SwapController.state.myTokensWithBalance?.[1] SwapController.setToToken(toToken) - setSourceTokenAmount('1') }) // -- Tests -------------------------------------------------------------------- describe('SwapController', () => { - it('should set toToken as expected', () => { - expect(SwapController.state.toToken?.address).toEqual(toToken.address) - }) - it('should set sourceToken as expected', () => { - expect(SwapController.state.sourceToken?.address).toEqual(sourceToken.address) + expect(SwapController.state.sourceToken?.address).toEqual(networkTokenAddress) }) - it('should calculate gas price in Ether and USD as expected', () => { - const gasPriceInEther = SwapController.calculateGasPriceInEther(gasLimit, gasFee) - const gasPriceInUSD = SwapController.calculateGasPriceInUSD(gasLimit, gasFee) - - expect(gasPriceInEther).toEqual(0.068395033074) - expect(gasPriceInUSD).toEqual(0.06395499714651795) - }) - - it('should return insufficient balance as expected', () => { - SwapController.state.networkBalanceInUSD = '0' - expect(SwapController.isInsufficientNetworkTokenForGas()).toEqual(true) + it('should set toToken as expected', () => { + expect(SwapController.state.toToken?.address).toEqual(toTokenAddress) + expect(SwapController.state.toTokenPriceInUSD).toEqual(38.0742530944) }) - it('should calculate swap values as expected', () => { - expect(SwapController.state.toTokenAmount).toEqual('6.77656269188470721788') - expect(SwapController.state.toTokenPriceInUSD).toEqual(0.10315220553291868) - }) + it('should calculate swap values as expected', async () => { + await SwapController.swapTokens() - it('should calculate the price impact as expected', () => { - const priceImpact = SwapController.calculatePriceImpact( - SwapController.state.toTokenAmount, - SwapController.calculateGasPriceInUSD(gasLimit, gasFee) - ) - expect(priceImpact).equal(9.14927128867287) + expect(SwapController.state.gasPriceInUSD).toEqual(0.0010485260814) + expect(SwapController.state.priceImpact).toEqual(0.898544263592072) + expect(SwapController.state.maxSlippage).toEqual(0.00019274639331006023) }) - it('should calculate the maximum slippage as expected', () => { - const maxSlippage = SwapController.calculateMaxSlippage() - expect(maxSlippage).toEqual(0.01) + it('should reset values as expected', () => { + SwapController.resetValues() + expect(SwapController.state.toToken).toEqual(undefined) + expect(SwapController.state.toTokenAmount).toEqual('') + expect(SwapController.state.toTokenPriceInUSD).toEqual(0) }) }) diff --git a/packages/core/tests/mocks/SwapController.ts b/packages/core/tests/mocks/SwapController.ts index ec056e622c..ca58ddf530 100644 --- a/packages/core/tests/mocks/SwapController.ts +++ b/packages/core/tests/mocks/SwapController.ts @@ -1,8 +1,9 @@ -export const tokenInfo = [ +export const tokensResponse = [ { name: 'Matic Token', symbol: 'MATIC', - chainId: 'eip155:137', + address: + 'eip155:137:0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as `${string}:${string}:${string}`, value: 15.945686877137186, price: 0.6990173876, decimals: 18, @@ -15,10 +16,10 @@ export const tokenInfo = [ { name: 'ShapeShift FOX', symbol: 'FOX', - chainId: 'eip155:137', - address: 'eip155:137:0x65a05db8322701724c197af82c9cae41195b0aa8', + address: + 'eip155:137:0x65a05db8322701724c197af82c9cae41195b0aa8' as `${string}:${string}:${string}`, value: 0.818151429070586, - price: 0.0875161861, + price: 0.10315220553291868, decimals: 18, quantity: { numeric: '9.348572710146769370', @@ -27,24 +28,77 @@ export const tokenInfo = [ logoUri: 'https://token-icons.s3.amazonaws.com/0xc770eefad204b5180df6a14ee197d99d808ee52d.png' }, { - name: 'SMARTMALL TOKEN', - symbol: 'SMT', - chainId: 'eip155:137', - address: 'eip155:137:0x658cda444ac43b0a7da13d638700931319b64014', - price: 0, - decimals: 18, + name: 'Tether USD', + symbol: 'USDT', + address: + 'eip155:137:0xc2132d05d31c914a87c6611c10748aeb04b58e8f' as `${string}:${string}:${string}`, + value: 0.8888156632489365, + price: 0.9995840116762155, + decimals: 6, quantity: { - numeric: '0.110019000000000000', - decimals: '18' + numeric: '0.888765', + decimals: '6' }, - logoUri: '' + logoUri: 'https://token-icons.s3.amazonaws.com/0xdac17f958d2ee523a2206206994597c13d831ec7.png' } ] -export const prices = { - 'eip155:137:0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee': 0.93508248, - 'eip155:137:0x65a05db8322701724c197af82c9cae41195b0aa8': 0.10315220553291868, - 'eip155:137:0xb33eaad8d922b1083446dc23f610c2567fb5180f': 11.772471201328177, - 'eip155:137:0xc2132d05d31c914a87c6611c10748aeb04b58e8f': 0.9995840116762155, - 'eip155:137:0x2c89bbc92bd86f8075d1decc58c7f4e0107f286b': 52.0920927465256 +export const networkTokenPriceResponse = { + fungibles: [ + { + name: 'Matic Token', + symbol: 'MATIC', + price: '0.6990173876', + iconUrl: 'https://token-icons.s3.amazonaws.com/0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0.png' + } + ] +} + +export const balanceResponse = { + balances: [ + { + name: 'Matic Token', + symbol: 'MATIC', + chainId: 'eip155:137', + value: 10.667935172031754, + price: 0.7394130944, + quantity: { + decimals: '18', + numeric: '14.427571343848456409' + }, + iconUrl: 'https://token-icons.s3.amazonaws.com/0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0.png' + }, + { + name: 'Wrapped AVAX', + symbol: 'AVAX', + chainId: 'eip155:137', + address: 'eip155:137:0x2c89bbc92bd86f8075d1decc58c7f4e0107f286b', + value: 3.751852120639868, + price: 38.0742530944, + quantity: { + decimals: '18', + numeric: '0.098540399764051957' + }, + iconUrl: 'https://token-icons.s3.amazonaws.com/0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7.png' + }, + { + name: 'Tether USD', + symbol: 'USDT', + chainId: 'eip155:137', + address: 'eip155:137:0xc2132d05d31c914a87c6611c10748aeb04b58e8f', + value: 2.3040319252130432, + price: 1.0010962048, + quantity: { + decimals: '6', + numeric: '2.301509' + }, + iconUrl: 'https://token-icons.s3.amazonaws.com/0xdac17f958d2ee523a2206206994597c13d831ec7.png' + } + ] +} + +export const gasPriceResponse = { + standard: '60000000128', + fast: '150000000128', + instant: '195000000166' } diff --git a/packages/core/tests/utils/SwapCalculationUtil.test.ts b/packages/core/tests/utils/SwapCalculationUtil.test.ts new file mode 100644 index 0000000000..fd4859c7e5 --- /dev/null +++ b/packages/core/tests/utils/SwapCalculationUtil.test.ts @@ -0,0 +1,97 @@ +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 type { SwapTokenWithBalance } from '../../src/utils/TypeUtil.js' +import { NumberUtil } from '@web3modal/common' + +// - Mocks --------------------------------------------------------------------- +const gasLimit = BigInt(INITIAL_GAS_LIMIT) +const gasFee = BigInt(455966887160) + +const sourceToken = tokensResponse[0] as SwapTokenWithBalance +const sourceTokenAmount = '1' +const toToken = tokensResponse[1] as SwapTokenWithBalance + +const networkPrice = networkTokenPriceResponse.fungibles[0]?.price || '0' + +// -- Tests -------------------------------------------------------------------- +describe('SwapCalculationUtil', () => { + it('should get gas price in Ether and USD as expected', () => { + const gasPriceInEther = SwapCalculationUtil.getGasPriceInEther(gasLimit, gasFee) + const gasPriceInUSD = SwapCalculationUtil.getGasPriceInUSD(networkPrice, gasLimit, gasFee) + + expect(gasPriceInEther).toEqual(0.068395033074) + expect(gasPriceInUSD).toEqual(0.04780931734420308) + }) + + it('should return insufficient balance as expected', () => { + expect(SwapCalculationUtil.isInsufficientNetworkTokenForGas('0', 0.01)).toEqual(true) + }) + + it('should return insufficient balance for gas as expected', () => { + const gasPriceInUSD = SwapCalculationUtil.getGasPriceInUSD(networkPrice, gasLimit, gasFee) + const networkBalanceInUSD = NumberUtil.multiply( + sourceToken.quantity.numeric, + sourceToken.price + ).toString() + + expect( + SwapCalculationUtil.isInsufficientNetworkTokenForGas(networkBalanceInUSD, gasPriceInUSD) + ).toEqual(false) + }) + + it('should get the price impact as expected', () => { + const gasPriceInUSD = SwapCalculationUtil.getGasPriceInUSD(networkPrice, gasLimit, gasFee) + const toTokenAmount = SwapCalculationUtil.getToTokenAmount({ + sourceToken, + sourceTokenAmount, + sourceTokenPrice: sourceToken.price, + toToken, + toTokenPrice: toToken.price + }) + + const priceImpact = SwapCalculationUtil.getPriceImpact({ + gasPriceInUSD, + sourceTokenAmount, + sourceTokenPriceInUSD: sourceToken.price, + toTokenAmount, + toTokenPriceInUSD: toToken.price + }) + expect(priceImpact).equal(7.646854717783376) + }) + + it('should get to token amount with same decimals including provider fee as expected', () => { + const toTokenAmount = SwapCalculationUtil.getToTokenAmount({ + sourceToken, + sourceTokenAmount, + sourceTokenPrice: sourceToken.price, + toToken, + toTokenPrice: toToken.price + }) + expect(toTokenAmount).equal('6.725738471695571914') + }) + + it('should get to token amount with different decimals including provider fee as expected', () => { + const newToToken = tokensResponse[2] as SwapTokenWithBalance + + const toTokenAmount = SwapCalculationUtil.getToTokenAmount({ + sourceToken, + sourceTokenAmount, + sourceTokenPrice: sourceToken.price, + toToken: newToToken, + toTokenPrice: newToToken.price + }) + expect(toTokenAmount).equal('0.694063') + }) + + it('should calculate the maximum slippage as expected', () => { + const maxSlippage = SwapCalculationUtil.getMaxSlippage(1, '1') + expect(maxSlippage).toEqual(0.01) + }) + + it('should calculate the provider fee as expected', () => { + const providerFee = SwapCalculationUtil.getProviderFee(sourceTokenAmount) + expect(providerFee).toEqual('0.0075') + }) +}) diff --git a/packages/scaffold/index.ts b/packages/scaffold/index.ts index 9d14b0d477..8c2a9c3232 100644 --- a/packages/scaffold/index.ts +++ b/packages/scaffold/index.ts @@ -55,6 +55,7 @@ export * from './src/partials/w3m-connecting-wc-unsupported/index.js' export * from './src/partials/w3m-connecting-wc-web/index.js' export * from './src/partials/w3m-swap-details/index.js' export * from './src/partials/w3m-swap-input/index.js' +export * from './src/partials/w3m-swap-input-skeleton/index.js' export * from './src/partials/w3m-header/index.js' export * from './src/partials/w3m-help-widget/index.js' export * from './src/partials/w3m-onramp-input/index.js' 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 74b77d20fa..07832dc660 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 @@ -4,7 +4,8 @@ import { NetworkController, AssetUtil, RouterController, - CoreHelperUtil + CoreHelperUtil, + ConstantsUtil as CoreConstantsUtil } from '@web3modal/core' import { customElement } from '@web3modal/ui' import { LitElement, html } from 'lit' @@ -186,7 +187,13 @@ export class W3mAccountWalletFeaturesWidget extends LitElement { } private onSwapClick() { - RouterController.push('Swap') + if (this.network?.id && !CoreConstantsUtil.SWAP_SUPPORTED_NETWORKS.includes(this.network?.id)) { + RouterController.push('UnsupportedChain', { + swapUnsupportedChain: true + }) + } else { + RouterController.push('Swap') + } } private onReceiveClick() { diff --git a/packages/scaffold/src/partials/w3m-swap-details/index.ts b/packages/scaffold/src/partials/w3m-swap-details/index.ts index 16f144aa91..b0a80a9acf 100644 --- a/packages/scaffold/src/partials/w3m-swap-details/index.ts +++ b/packages/scaffold/src/partials/w3m-swap-details/index.ts @@ -2,12 +2,16 @@ import { html, LitElement } from 'lit' import { property } 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' @customElement('w3m-swap-details') export class WuiSwapDetails extends LitElement { public static override styles = [styles] // -- State & Properties -------------------------------- // + @property() public networkName = NetworkController.state.caipNetwork?.name + @property() public detailsOpen = false @property() public sourceTokenSymbol?: string @@ -16,7 +20,9 @@ export class WuiSwapDetails extends LitElement { @property() public toTokenSymbol?: string - @property() public toTokenSwapedAmount?: number + @property() public toTokenAmount?: string + + @property() public toTokenSwappedAmount?: number @property() public gasPriceInUSD?: number @@ -26,8 +32,17 @@ export class WuiSwapDetails extends LitElement { @property() public maxSlippage?: number + @property() public providerFee?: string + + @property() public networkTokenSymbol?: string + // -- Render -------------------------------------------- // public override render() { + const minReceivedAmount = + this.toTokenAmount && this.maxSlippage + ? NumberUtil.bigNumber(this.toTokenAmount).minus(this.maxSlippage).toString() + : null + return html` @@ -36,7 +51,7 @@ export class WuiSwapDetails extends LitElement { 1 ${this.sourceTokenSymbol} = - ${UiHelperUtil.formatNumberToLocalString(this.toTokenSwapedAmount, 3)} + ${UiHelperUtil.formatNumberToLocalString(this.toTokenSwappedAmount, 3)} ${this.toTokenSymbol} @@ -55,7 +70,16 @@ export class WuiSwapDetails extends LitElement { alignItems="center" class="details-row" > - Network cost + + + Network cost + + + + + $${UiHelperUtil.formatNumberToLocalString(this.gasPriceInUSD, 3)} @@ -68,7 +92,16 @@ export class WuiSwapDetails extends LitElement { alignItems="center" class="details-row" > - Price impact + + + Price impact + + + + + ${UiHelperUtil.formatNumberToLocalString(this.priceImpact, 3)}% @@ -84,11 +117,27 @@ export class WuiSwapDetails extends LitElement { alignItems="center" class="details-row" > - Max. slippage + + + Max. slippage + + + + + ${UiHelperUtil.formatNumberToLocalString(this.maxSlippage, 6)} - ${this.sourceTokenSymbol} ${this.slippageRate}% + ${this.toTokenSymbol} ${this.slippageRate}% @@ -100,9 +149,20 @@ export class WuiSwapDetails extends LitElement { alignItems="center" class="details-row provider-free-row" > - Provider fee - - Free + + + Provider fee (0.75%) + + + + ${this.providerFee + ? html` + + ${UiHelperUtil.formatNumberToLocalString(this.providerFee, 6)} + ${this.sourceTokenSymbol} + + ` + : null} diff --git a/packages/scaffold/src/partials/w3m-swap-details/styles.ts b/packages/scaffold/src/partials/w3m-swap-details/styles.ts index b6abae5746..e901680780 100644 --- a/packages/scaffold/src/partials/w3m-swap-details/styles.ts +++ b/packages/scaffold/src/partials/w3m-swap-details/styles.ts @@ -40,13 +40,11 @@ export default css` background: var(--wui-gray-glass-002); } - .details-row.provider-free-row { - padding-right: var(--wui-spacing-xs); + .details-row-title { + white-space: nowrap; } - .free-badge { - background: rgba(38, 217, 98, 0.15); - border-radius: var(--wui-border-radius-4xs); - padding: 4.5px 6px; + .details-row.provider-free-row { + padding-right: var(--wui-spacing-xs); } ` diff --git a/packages/scaffold/src/partials/w3m-swap-input-skeleton/index.ts b/packages/scaffold/src/partials/w3m-swap-input-skeleton/index.ts new file mode 100644 index 0000000000..91b69b762c --- /dev/null +++ b/packages/scaffold/src/partials/w3m-swap-input-skeleton/index.ts @@ -0,0 +1,61 @@ +import { html, LitElement } from 'lit' +import { property } from 'lit/decorators.js' +import { type SwapInputTarget } from '@web3modal/core' +import { customElement, swapInputMaskBottomSvg, swapInputMaskTopSvg } from '@web3modal/ui' +import styles from './styles.js' + +@customElement('w3m-swap-input-skeleton') +export class W3mSwapInputSkeleton extends LitElement { + public static override styles = [styles] + + // -- State & Properties -------------------------------- // + @property() public target: SwapInputTarget = 'sourceToken' + + // -- Render -------------------------------------------- // + public override render() { + return html` + + ${this.target === 'sourceToken' ? swapInputMaskTopSvg : swapInputMaskBottomSvg} + + + + + ${this.templateTokenSelectButton()} + + ` + } + + // -- Private ------------------------------------------- // + private templateTokenSelectButton() { + return html` + + + + + ` + } +} + +declare global { + interface HTMLElementTagNameMap { + 'w3m-swap-input-skeleton': W3mSwapInputSkeleton + } +} diff --git a/packages/scaffold/src/partials/w3m-swap-input-skeleton/styles.ts b/packages/scaffold/src/partials/w3m-swap-input-skeleton/styles.ts new file mode 100644 index 0000000000..af0fbc905d --- /dev/null +++ b/packages/scaffold/src/partials/w3m-swap-input-skeleton/styles.ts @@ -0,0 +1,45 @@ +import { css } from 'lit' + +export default css` + :host { + width: 100%; + } + + :host > wui-flex { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + border-radius: var(--wui-border-radius-s); + padding: var(--wui-spacing-xl); + padding-right: var(--wui-spacing-s); + width: 100%; + height: 100px; + box-sizing: border-box; + position: relative; + } + + wui-shimmer.market-value { + opacity: 0; + } + + :host > wui-flex > svg.input_mask { + position: absolute; + inset: 0; + z-index: 5; + } + + :host wui-flex .input_mask__border, + :host wui-flex .input_mask__background { + transition: fill var(--wui-duration-md) var(--wui-ease-out-power-1); + will-change: fill; + } + + :host wui-flex .input_mask__border { + fill: var(--wui-gray-glass-020); + } + + :host wui-flex .input_mask__background { + fill: var(--wui-gray-glass-002); + } +` diff --git a/packages/scaffold/src/partials/w3m-swap-input/index.ts b/packages/scaffold/src/partials/w3m-swap-input/index.ts index ea2b1752bb..c3fd796431 100644 --- a/packages/scaffold/src/partials/w3m-swap-input/index.ts +++ b/packages/scaffold/src/partials/w3m-swap-input/index.ts @@ -30,7 +30,7 @@ export class W3mSwapInput extends LitElement { @property() public price = 0 - @property() public marketValue?: string = '$1.0345,00' + @property() public marketValue?: string @property() public disabled?: boolean @@ -47,7 +47,7 @@ export class W3mSwapInput extends LitElement { // -- Render -------------------------------------------- // public override render() { const marketValue = this.marketValue || '0' - const isMarketValueGreaterThanZero = NumberUtil.bigNumber(marketValue).isGreaterThan(0) + const isMarketValueGreaterThanZero = NumberUtil.bigNumber(marketValue).isGreaterThan('0') return html` @@ -69,7 +69,9 @@ export class W3mSwapInput extends LitElement { placeholder="0" /> - ${isMarketValueGreaterThanZero ? `$${this.marketValue}` : null} + ${isMarketValueGreaterThanZero + ? `$${UiHelperUtil.formatNumberToLocalString(this.marketValue, 3)}` + : null} ${this.templateTokenSelectButton()} diff --git a/packages/scaffold/src/views/w3m-swap-preview-view/index.ts b/packages/scaffold/src/views/w3m-swap-preview-view/index.ts index 4959e850c2..a02f74d8b7 100644 --- a/packages/scaffold/src/views/w3m-swap-preview-view/index.ts +++ b/packages/scaffold/src/views/w3m-swap-preview-view/index.ts @@ -47,6 +47,8 @@ export class W3mSwapPreviewView extends LitElement { @state() private maxSlippage = SwapController.state.maxSlippage + @state() private providerFee = SwapController.state.providerFee + // -- Lifecycle ----------------------------------------- // public constructor() { super() @@ -77,6 +79,7 @@ export class W3mSwapPreviewView extends LitElement { this.toTokenAmount = newState.toTokenAmount ?? '' this.priceImpact = newState.priceImpact this.maxSlippage = newState.maxSlippage + this.providerFee = newState.providerFee }) ] ) @@ -182,7 +185,7 @@ export class W3mSwapPreviewView extends LitElement { } private templateDetails() { - const toTokenSwapedAmount = + const toTokenSwappedAmount = this.sourceTokenPriceInUSD && this.toTokenPriceInUSD ? (1 / this.toTokenPriceInUSD) * this.sourceTokenPriceInUSD : 0 @@ -193,11 +196,13 @@ export class W3mSwapPreviewView extends LitElement { sourceTokenSymbol=${this.sourceToken?.symbol} sourceTokenPrice=${this.sourceTokenPriceInUSD} toTokenSymbol=${this.toToken?.symbol} - toTokenSwapedAmount=${toTokenSwapedAmount} + toTokenSwappedAmount=${toTokenSwappedAmount} + toTokenAmount=${this.toTokenAmount} gasPriceInUSD=${UiHelperUtil.formatNumberToLocalString(this.gasPriceInUSD, 3)} .priceImpact=${this.priceImpact} slippageRate=${ConstantsUtil.CONVERT_SLIPPAGE_TOLERANCE} .maxSlippage=${this.maxSlippage} + providerFee=${this.providerFee} > ` } diff --git a/packages/scaffold/src/views/w3m-swap-preview-view/styles.ts b/packages/scaffold/src/views/w3m-swap-preview-view/styles.ts index 6577ca5278..05b3c9fd90 100644 --- a/packages/scaffold/src/views/w3m-swap-preview-view/styles.ts +++ b/packages/scaffold/src/views/w3m-swap-preview-view/styles.ts @@ -127,10 +127,4 @@ export default css` border-radius: var(--wui-border-radius-xxs); background: var(--wui-gray-glass-002); } - - .free-badge { - background: rgba(38, 217, 98, 0.15); - border-radius: var(--wui-border-radius-4xs); - padding: 4.5px 6px; - } ` diff --git a/packages/scaffold/src/views/w3m-swap-select-token-view/index.ts b/packages/scaffold/src/views/w3m-swap-select-token-view/index.ts index 3fc54aad6c..b668914d79 100644 --- a/packages/scaffold/src/views/w3m-swap-select-token-view/index.ts +++ b/packages/scaffold/src/views/w3m-swap-select-token-view/index.ts @@ -17,8 +17,14 @@ export class W3mSwapSelectTokenView extends LitElement { @state() private sourceToken = SwapController.state.sourceToken + @state() private sourceTokenAmount = SwapController.state.sourceTokenAmount + @state() private toToken = SwapController.state.toToken + @state() private myTokensWithBalance = SwapController.state.myTokensWithBalance + + @state() private popularTokens = SwapController.state.popularTokens + @state() private searchValue = '' // -- Lifecycle ----------------------------------------- // @@ -30,11 +36,10 @@ export class W3mSwapSelectTokenView extends LitElement { SwapController.subscribe(newState => { this.sourceToken = newState.sourceToken this.toToken = newState.toToken + this.myTokensWithBalance = newState.myTokensWithBalance }) ] ) - - this.watchTokens() } public override updated() { @@ -71,18 +76,14 @@ export class W3mSwapSelectTokenView extends LitElement { } // -- Private ------------------------------------------- // - private watchTokens() { - this.interval = setInterval(() => { - SwapController.getNetworkTokenPrice() - SwapController.getMyTokensWithBalance() - }, 5000) - } - private onSelectToken(token: SwapTokenWithBalance) { if (this.targetToken === 'sourceToken') { SwapController.setSourceToken(token) } else { SwapController.setToToken(token) + if (this.sourceToken && this.sourceTokenAmount) { + SwapController.swapTokens() + } } RouterController.goBack() } @@ -103,10 +104,8 @@ export class W3mSwapSelectTokenView extends LitElement { } private templateTokens() { - const yourTokens = SwapController.state.myTokensWithBalance - ? Object.values(SwapController.state.myTokensWithBalance) - : [] - const tokens = SwapController.state.popularTokens ? SwapController.state.popularTokens : [] + const yourTokens = this.myTokensWithBalance ? Object.values(this.myTokensWithBalance) : [] + const tokens = this.popularTokens ? this.popularTokens : [] const filteredYourTokens: SwapTokenWithBalance[] = this.filterTokensWithText< SwapTokenWithBalance[] diff --git a/packages/scaffold/src/views/w3m-swap-view/index.ts b/packages/scaffold/src/views/w3m-swap-view/index.ts index f8e8cd2f0a..71fc73ee70 100644 --- a/packages/scaffold/src/views/w3m-swap-view/index.ts +++ b/packages/scaffold/src/views/w3m-swap-view/index.ts @@ -1,4 +1,4 @@ -import { UiHelperUtil, customElement } from '@web3modal/ui' +import { customElement } from '@web3modal/ui' import { LitElement, html } from 'lit' import { state } from 'lit/decorators.js' import styles from './styles.js' @@ -53,26 +53,27 @@ export class W3mSwapView extends LitElement { @state() private maxSlippage = SwapController.state.maxSlippage + @state() private providerFee = SwapController.state.providerFee + @state() private transactionLoading = SwapController.state.transactionLoading + @state() private networkTokenSymbol = SwapController.state.networkTokenSymbol + // -- Lifecycle ----------------------------------------- // public constructor() { super() - NetworkController.subscribeKey('caipNetwork', newCaipNetwork => { if (this.caipNetworkId !== newCaipNetwork?.id) { this.caipNetworkId = newCaipNetwork?.id - SwapController.resetTokens() - SwapController.resetValues() + SwapController.resetState() SwapController.initializeState() } }) - this.unsubscribe.push( ...[ ModalController.subscribeKey('open', isOpen => { if (!isOpen) { - SwapController.resetValues() + SwapController.resetState() } }), RouterController.subscribeKey('view', newRoute => { @@ -95,21 +96,20 @@ export class W3mSwapView extends LitElement { this.gasPriceInUSD = newState.gasPriceInUSD this.priceImpact = newState.priceImpact this.maxSlippage = newState.maxSlippage + this.providerFee = newState.providerFee }) ] ) - - this.watchTokensAndValues() } public override firstUpdated() { - if (!this.initialized) { - SwapController.initializeState() - } + SwapController.initializeState() + setTimeout(() => { + this.watchTokensAndValues() + }, 10_000) } public override disconnectedCallback() { - SwapController.setLoading(false) this.unsubscribe.forEach(unsubscribe => unsubscribe?.()) clearInterval(this.interval) } @@ -129,7 +129,7 @@ export class W3mSwapView extends LitElement { SwapController.getNetworkTokenPrice() SwapController.getMyTokensWithBalance() SwapController.refreshSwapValues() - }, 20000) + }, 10_000) } private templateSwap() { @@ -161,26 +161,23 @@ export class W3mSwapView extends LitElement { } private templateLoading() { - return html` - - - - ` + return html` + + + + + + + + + + ` } private templateTokenInput(target: SwapInputTarget, token?: SwapToken) { @@ -203,7 +200,7 @@ export class W3mSwapView extends LitElement { .token=${token} .balance=${myToken?.quantity?.numeric} .price=${this.sourceTokenPriceInUSD} - .marketValue=${isNaN(value) ? '' : UiHelperUtil.formatNumberToLocalString(value)} + .marketValue=${value} .onSetMaxValue=${this.onSetMaxValue.bind(this)} >` } @@ -239,7 +236,7 @@ export class W3mSwapView extends LitElement { } private templateDetails() { - if (this.loading || this.inputError) { + if (this.inputError) { return null } @@ -247,7 +244,7 @@ export class W3mSwapView extends LitElement { return null } - const toTokenSwapedAmount = + const toTokenSwappedAmount = this.sourceTokenPriceInUSD && this.toTokenPriceInUSD ? (1 / this.toTokenPriceInUSD) * this.sourceTokenPriceInUSD : 0 @@ -258,11 +255,14 @@ export class W3mSwapView extends LitElement { sourceTokenSymbol=${this.sourceToken?.symbol} sourceTokenPrice=${this.sourceTokenPriceInUSD} toTokenSymbol=${this.toToken?.symbol} - toTokenSwapedAmount=${toTokenSwapedAmount} + toTokenSwappedAmount=${toTokenSwappedAmount} + toTokenAmount=${this.toTokenAmount} gasPriceInUSD=${this.gasPriceInUSD} .priceImpact=${this.priceImpact} slippageRate=${ConstantsUtil.CONVERT_SLIPPAGE_TOLERANCE} .maxSlippage=${this.maxSlippage} + providerFee=${this.providerFee} + networkTokenSymbol=${this.networkTokenSymbol} > ` } @@ -280,6 +280,7 @@ export class W3mSwapView extends LitElement { private templateActionButton() { const haveNoTokenSelected = !this.toToken || !this.sourceToken const loading = this.loading || this.loadingPrices || this.transactionLoading + const disabled = loading || haveNoTokenSelected || this.inputError return html` ${this.actionButtonLabel()} @@ -299,7 +300,7 @@ export class W3mSwapView extends LitElement { private onDebouncedGetSwapCalldata = CoreHelperUtil.debounce(async () => { await SwapController.swapTokens() - }, 500) + }, 200) private onSwitchTokens() { SwapController.switchTokens() diff --git a/packages/scaffold/src/views/w3m-swap-view/styles.ts b/packages/scaffold/src/views/w3m-swap-view/styles.ts index f3a0671f2d..3e3f1f8346 100644 --- a/packages/scaffold/src/views/w3m-swap-view/styles.ts +++ b/packages/scaffold/src/views/w3m-swap-view/styles.ts @@ -45,6 +45,22 @@ export default css` z-index: 20; } + .replace-tokens-button-shimmer { + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + height: 40px; + width: 40px; + border: 1ps solid var(--wui-gray-glass-020); + border-radius: var(--wui-border-radius-xxs); + z-index: 20; + } + .replace-tokens-button:hover { background: var(--wui-gray-glass-010); } @@ -84,10 +100,4 @@ export default css` border-radius: var(--wui-border-radius-xxs); background: var(--wui-gray-glass-002); } - - .free-badge { - background: rgba(38, 217, 98, 0.15); - border-radius: var(--wui-border-radius-4xs); - padding: 4.5px 6px; - } ` diff --git a/packages/scaffold/src/views/w3m-unsupported-chain-view/index.ts b/packages/scaffold/src/views/w3m-unsupported-chain-view/index.ts index 92a14499f0..617ba00685 100644 --- a/packages/scaffold/src/views/w3m-unsupported-chain-view/index.ts +++ b/packages/scaffold/src/views/w3m-unsupported-chain-view/index.ts @@ -2,6 +2,7 @@ import { AccountController, AssetUtil, ConnectionController, + ConstantsUtil, CoreHelperUtil, EventsController, ModalController, @@ -22,6 +23,9 @@ import styles from './styles.js' export class W3mUnsupportedChainView extends LitElement { public static override styles = styles + // -- Members ------------------------------------------- // + protected readonly swapUnsupportedChain = RouterController.state.data?.swapUnsupportedChain + // -- State & Properties --------------------------------- // @state() private disconecting = false @@ -36,10 +40,7 @@ export class W3mUnsupportedChainView extends LitElement { alignItems="center" gap="xl" > - - This app doesn’t support your current network. Switch to an available option following - to continue. - + ${this.descriptionTemplate()} @@ -65,6 +66,22 @@ export class W3mUnsupportedChainView extends LitElement { } // -- Private ------------------------------------------- // + private descriptionTemplate() { + if (this.swapUnsupportedChain) { + return html` + + The swap feature doesn’t support your current network. Switch to an available option to + continue. + + ` + } + + return html` + + This app doesn’t support your current network. Switch to an available option to continue. + + ` + } private networksTemplate() { const { approvedCaipNetworkIds, requestedCaipNetworks } = NetworkController.state @@ -74,7 +91,11 @@ export class W3mUnsupportedChainView extends LitElement { requestedCaipNetworks ) - return sortedNetworks.map( + const filteredNetworks = this.swapUnsupportedChain + ? sortedNetworks.filter(network => ConstantsUtil.SWAP_SUPPORTED_NETWORKS.includes(network.id)) + : sortedNetworks + + return filteredNetworks.map( network => html`