diff --git a/apps/laboratory/tests/wallet-features.spec.ts b/apps/laboratory/tests/wallet-features.spec.ts index 01710ae8ac..f962cf715f 100644 --- a/apps/laboratory/tests/wallet-features.spec.ts +++ b/apps/laboratory/tests/wallet-features.spec.ts @@ -57,8 +57,10 @@ walletFeaturesTest('it should initialize swap as expected', async () => { await page.openAccount() const walletFeatureButton = await page.getWalletFeaturesButton('swaps') await walletFeatureButton.click() - await expect(page.page.getByTestId('swap-input-sourceToken')).toHaveValue('1') + await expect(page.page.getByTestId('swap-input-sourceToken')).toHaveValue('0') await expect(page.page.getByTestId('swap-input-token-sourceToken')).toHaveText('ETH') + await expect(page.page.getByTestId('swap-action-button')).toHaveText('Select token') + await page.page.getByTestId('swap-input-sourceToken').fill('1') await page.page.getByTestId('swap-select-token-button-toToken').click() await page.page .getByTestId('swap-select-token-search-input') diff --git a/packages/common/src/utils/SafeLocalStorage.ts b/packages/common/src/utils/SafeLocalStorage.ts index b70d0f2acc..3ae3246f78 100644 --- a/packages/common/src/utils/SafeLocalStorage.ts +++ b/packages/common/src/utils/SafeLocalStorage.ts @@ -23,6 +23,8 @@ export type SafeLocalStorageItems = { '@appkit/preferred_account_types': string '@appkit/connections': string '@appkit/disconnected_connector_ids': string + '@appkit/history_transactions_cache': string + '@appkit/token_price_cache': string '@appkit/recent_emails': string /* * DO NOT CHANGE: @walletconnect/universal-provider requires us to set this specific key @@ -54,6 +56,8 @@ export const SafeLocalStorageKeys = { PREFERRED_ACCOUNT_TYPES: '@appkit/preferred_account_types', CONNECTIONS: '@appkit/connections', DISCONNECTED_CONNECTOR_IDS: '@appkit/disconnected_connector_ids', + HISTORY_TRANSACTIONS_CACHE: '@appkit/history_transactions_cache', + TOKEN_PRICE_CACHE: '@appkit/token_price_cache', RECENT_EMAILS: '@appkit/recent_emails' } as const satisfies Record diff --git a/packages/controllers/src/controllers/BlockchainApiController.ts b/packages/controllers/src/controllers/BlockchainApiController.ts index f0082ec869..f420c28d66 100644 --- a/packages/controllers/src/controllers/BlockchainApiController.ts +++ b/packages/controllers/src/controllers/BlockchainApiController.ts @@ -241,7 +241,15 @@ export const BlockchainApiController = { return { data: [], next: undefined } } - return BlockchainApiController.get({ + const transactionsCache = StorageUtil.getTransactionsCacheForAddress({ + address: account, + chainId + }) + if (transactionsCache) { + return transactionsCache as BlockchainApiTransactionsResponse + } + + const result = await BlockchainApiController.get({ path: `/v1/account/${account}/history`, params: { cursor, @@ -250,6 +258,15 @@ export const BlockchainApiController = { signal, cache }) + + StorageUtil.updateTransactionsCache({ + address: account, + chainId, + timestamp: Date.now(), + transactions: result + }) + + return result }, async fetchSwapQuote({ amount, userAddress, from, to, gasPrice }: BlockchainApiSwapQuoteRequest) { @@ -299,7 +316,12 @@ export const BlockchainApiController = { return { fungibles: [] } } - return state.api.post({ + const tokenPriceCache = StorageUtil.getTokenPriceCacheForAddresses(addresses) + if (tokenPriceCache) { + return tokenPriceCache as BlockchainApiTokenPriceResponse + } + + const result = await state.api.post({ path: '/v1/fungible/price', body: { currency: 'usd', @@ -310,6 +332,14 @@ export const BlockchainApiController = { 'Content-Type': 'application/json' } }) + + StorageUtil.updateTokenPriceCache({ + addresses, + timestamp: Date.now(), + tokenPrice: result + }) + + return result }, async fetchSwapAllowance({ tokenAddress, userAddress }: BlockchainApiSwapAllowanceRequest) { diff --git a/packages/controllers/src/controllers/SwapController.ts b/packages/controllers/src/controllers/SwapController.ts index d6d27dbabe..2143fc8599 100644 --- a/packages/controllers/src/controllers/SwapController.ts +++ b/packages/controllers/src/controllers/SwapController.ts @@ -362,7 +362,7 @@ const controller = { if (networkToken) { state.networkTokenSymbol = networkToken.symbol SwapController.setSourceToken(networkToken) - SwapController.setSourceTokenAmount('1') + SwapController.setSourceTokenAmount('0') } }, diff --git a/packages/controllers/src/utils/StorageUtil.ts b/packages/controllers/src/utils/StorageUtil.ts index 0680fbce09..bf4898af9a 100644 --- a/packages/controllers/src/utils/StorageUtil.ts +++ b/packages/controllers/src/utils/StorageUtil.ts @@ -13,6 +13,8 @@ import type { BlockchainApiBalanceResponse, BlockchainApiIdentityResponse, BlockchainApiLookupEnsName, + BlockchainApiTokenPriceResponse, + BlockchainApiTransactionsResponse, ConnectionStatus, PreferredAccountTypes, SocialProvider, @@ -33,7 +35,9 @@ export const StorageUtil = { portfolio: 30000, nativeBalance: 30000, ens: 300000, - identity: 300000 + identity: 300000, + transactionsHistory: 15000, + tokenPrice: 15000 }, isCacheExpired(timestamp: number, cacheExpiry: number) { return Date.now() - timestamp > cacheExpiry @@ -567,6 +571,7 @@ export const StorageUtil = { SafeLocalStorage.removeItem(SafeLocalStorageKeys.NATIVE_BALANCE_CACHE) SafeLocalStorage.removeItem(SafeLocalStorageKeys.ENS_CACHE) SafeLocalStorage.removeItem(SafeLocalStorageKeys.IDENTITY_CACHE) + SafeLocalStorage.removeItem(SafeLocalStorageKeys.HISTORY_TRANSACTIONS_CACHE) } catch { console.info('Unable to clear address cache') } @@ -765,5 +770,143 @@ export const StorageUtil = { } return false + }, + getTransactionsCache() { + try { + const result = SafeLocalStorage.getItem(SafeLocalStorageKeys.HISTORY_TRANSACTIONS_CACHE) + + return result ? JSON.parse(result) : {} + } catch { + console.info('Unable to get transactions cache') + } + + return {} + }, + + getTransactionsCacheForAddress({ address, chainId = '' }: { address: string; chainId?: string }) { + try { + const cache = StorageUtil.getTransactionsCache() + const transactionsCache = cache[address]?.[chainId] + + // We want to discard cache if it's older than the cache expiry + if ( + transactionsCache && + !this.isCacheExpired(transactionsCache.timestamp, this.cacheExpiry.transactionsHistory) + ) { + return transactionsCache.transactions + } + StorageUtil.removeTransactionsCache({ address, chainId }) + } catch { + console.info('Unable to get transactions cache') + } + + return undefined + }, + updateTransactionsCache({ + address, + chainId = '', + timestamp, + transactions + }: { + address: string + chainId?: string + timestamp: number + transactions: BlockchainApiTransactionsResponse + }) { + try { + const cache = StorageUtil.getTransactionsCache() + cache[address] = { + ...cache[address], + [chainId]: { + timestamp, + transactions + } + } + SafeLocalStorage.setItem( + SafeLocalStorageKeys.HISTORY_TRANSACTIONS_CACHE, + JSON.stringify(cache) + ) + } catch { + console.info('Unable to update transactions cache', { + address, + chainId, + timestamp, + transactions + }) + } + }, + removeTransactionsCache({ address, chainId }: { address: string; chainId: string }) { + try { + const cache = StorageUtil.getTransactionsCache() + const addressCache = cache?.[address] || {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [chainId]: _removed, ...updatedChainData } = addressCache + + SafeLocalStorage.setItem( + SafeLocalStorageKeys.HISTORY_TRANSACTIONS_CACHE, + JSON.stringify({ + ...cache, + [address]: updatedChainData + }) + ) + } catch { + console.info('Unable to remove transactions cache', { address, chainId }) + } + }, + getTokenPriceCache() { + try { + const result = SafeLocalStorage.getItem(SafeLocalStorageKeys.TOKEN_PRICE_CACHE) + + return result ? JSON.parse(result) : {} + } catch { + console.info('Unable to get token price cache') + } + + return {} + }, + getTokenPriceCacheForAddresses(addresses: string[]) { + try { + const cache = StorageUtil.getTokenPriceCache() + const tokenPriceCache = cache[addresses.join(',')] + if ( + tokenPriceCache && + !this.isCacheExpired(tokenPriceCache.timestamp, this.cacheExpiry.tokenPrice) + ) { + return tokenPriceCache.tokenPrice + } + StorageUtil.removeTokenPriceCache(addresses) + } catch { + console.info('Unable to get token price cache for addresses', addresses) + } + + return undefined + }, + updateTokenPriceCache(params: { + addresses: string[] + timestamp: number + tokenPrice: BlockchainApiTokenPriceResponse + }) { + try { + const cache = StorageUtil.getTokenPriceCache() + cache[params.addresses.join(',')] = { + timestamp: params.timestamp, + tokenPrice: params.tokenPrice + } + SafeLocalStorage.setItem(SafeLocalStorageKeys.TOKEN_PRICE_CACHE, JSON.stringify(cache)) + } catch { + console.info('Unable to update token price cache', params) + } + }, + removeTokenPriceCache(addresses: string[]) { + try { + const cache = StorageUtil.getTokenPriceCache() + SafeLocalStorage.setItem( + SafeLocalStorageKeys.TOKEN_PRICE_CACHE, + JSON.stringify({ ...cache, [addresses.join(',')]: undefined }) + ) + } catch { + console.info('Unable to remove token price cache', addresses) + } } } diff --git a/packages/controllers/tests/controllers/SwapController.test.ts b/packages/controllers/tests/controllers/SwapController.test.ts index 66bc8ce0d2..900b7bfd16 100644 --- a/packages/controllers/tests/controllers/SwapController.test.ts +++ b/packages/controllers/tests/controllers/SwapController.test.ts @@ -56,6 +56,8 @@ const networkTokenAddress = 'eip155:137:0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee // AVAX const toTokenAddress = 'eip155:137:0x2c89bbc92bd86f8075d1decc58c7f4e0107f286b' +const sourceTokenAmount = '1' + // - Setup --------------------------------------------------------------------- beforeAll(async () => { const mockAdapter = { @@ -70,7 +72,6 @@ beforeAll(async () => { ChainController.setActiveCaipNetwork(caipNetwork) AccountController.setCaipAddress(caipAddress, chain) - vi.spyOn(BlockchainApiController, 'fetchSwapTokens').mockResolvedValue(tokensResponse) vi.spyOn(BlockchainApiController, 'getBalance').mockResolvedValue(balanceResponse) vi.spyOn(BlockchainApiController, 'fetchSwapQuote').mockResolvedValue(swapQuoteResponse) @@ -98,6 +99,8 @@ describe('SwapController', () => { }) it('should calculate swap values as expected', async () => { + SwapController.setSourceTokenAmount(sourceTokenAmount) + await SwapController.swapTokens() expect(SwapController.state.gasPriceInUSD).toEqual(0.00648630001383744) diff --git a/packages/scaffold-ui/src/views/w3m-swap-view/index.ts b/packages/scaffold-ui/src/views/w3m-swap-view/index.ts index 1d1e3f95f4..2f173eba60 100644 --- a/packages/scaffold-ui/src/views/w3m-swap-view/index.ts +++ b/packages/scaffold-ui/src/views/w3m-swap-view/index.ts @@ -80,6 +80,10 @@ export class W3mSwapView extends LitElement { @state() private fetchError = SwapController.state.fetchError + @state() private lastTokenPriceUpdate = 0 + + private minTokenPriceUpdateInterval = 10_000 + // -- Lifecycle ----------------------------------------- // public constructor() { super() @@ -136,6 +140,10 @@ export class W3mSwapView extends LitElement { this.toTokenPriceInUSD = newState.toTokenPriceInUSD this.inputError = newState.inputError this.fetchError = newState.fetchError + + if (newState.sourceToken && newState.toToken) { + this.watchTokensAndValues() + } }) ] ) @@ -150,6 +158,7 @@ export class W3mSwapView extends LitElement { public override disconnectedCallback() { this.unsubscribe.forEach(unsubscribe => unsubscribe?.()) clearInterval(this.interval) + document?.removeEventListener('visibilitychange', this.visibilityChangeHandler) } // -- Render -------------------------------------------- // @@ -162,12 +171,57 @@ export class W3mSwapView extends LitElement { } // -- Private ------------------------------------------- // - private watchTokensAndValues() { + + private visibilityChangeHandler = () => { + if (document?.hidden) { + clearInterval(this.interval) + this.interval = undefined + } else { + this.startTokenPriceInterval() + } + } + + private subscribeToVisibilityChange() { + document?.removeEventListener('visibilitychange', this.visibilityChangeHandler) + document?.addEventListener('visibilitychange', this.visibilityChangeHandler) + } + + private startTokenPriceInterval = () => { + if ( + this.interval && + Date.now() - this.lastTokenPriceUpdate < this.minTokenPriceUpdateInterval + ) { + return + } + + // Quick fetch tokens and values if last update is more than 10 seconds ago + if ( + this.lastTokenPriceUpdate && + Date.now() - this.lastTokenPriceUpdate > this.minTokenPriceUpdateInterval + ) { + this.fetchTokensAndValues() + } + clearInterval(this.interval) this.interval = setInterval(() => { - SwapController.getNetworkTokenPrice() - SwapController.getMyTokensWithBalance() - SwapController.swapTokens() - }, 10_000) + this.fetchTokensAndValues() + }, this.minTokenPriceUpdateInterval) + } + + private watchTokensAndValues = () => { + // Only fetch tokens and values if source and to token are set + if (!this.sourceToken || !this.toToken) { + return + } + + this.subscribeToVisibilityChange() + this.startTokenPriceInterval() + } + + private fetchTokensAndValues() { + SwapController.getNetworkTokenPrice() + SwapController.getMyTokensWithBalance() + SwapController.swapTokens() + this.lastTokenPriceUpdate = Date.now() } private templateSwap() { diff --git a/packages/scaffold-ui/src/views/w3m-wallet-send-select-token-view/index.ts b/packages/scaffold-ui/src/views/w3m-wallet-send-select-token-view/index.ts index 072ed9ba19..ae6e22363b 100644 --- a/packages/scaffold-ui/src/views/w3m-wallet-send-select-token-view/index.ts +++ b/packages/scaffold-ui/src/views/w3m-wallet-send-select-token-view/index.ts @@ -6,7 +6,8 @@ import { ChainController, CoreHelperUtil, RouterController, - SendController + SendController, + SwapController } from '@reown/appkit-controllers' import { customElement } from '@reown/appkit-ui' import '@reown/appkit-ui/wui-flex' @@ -39,6 +40,7 @@ export class W3mSendSelectTokenView extends LitElement { // -- Lifecycle ----------------------------------------- // public constructor() { super() + this.fetchBalancesAndNetworkPrice() this.unsubscribe.push( ...[ SendController.subscribe(val => { @@ -63,6 +65,22 @@ export class W3mSendSelectTokenView extends LitElement { // -- Private ------------------------------------------- // + private async fetchBalancesAndNetworkPrice() { + if (!this.tokenBalances || this.tokenBalances?.length === 0) { + await this.fetchBalances() + await this.fetchNetworkPrice() + } + } + + private async fetchBalances() { + await SendController.fetchTokenBalance() + SendController.fetchNetworkBalance() + } + + private async fetchNetworkPrice() { + await SwapController.getNetworkTokenPrice() + } + private templateSearchInput() { return html` diff --git a/packages/scaffold-ui/src/views/w3m-wallet-send-view/index.ts b/packages/scaffold-ui/src/views/w3m-wallet-send-view/index.ts index 7cbd4c4764..31609464ee 100644 --- a/packages/scaffold-ui/src/views/w3m-wallet-send-view/index.ts +++ b/packages/scaffold-ui/src/views/w3m-wallet-send-view/index.ts @@ -46,8 +46,12 @@ export class W3mWalletSendView extends LitElement { public constructor() { super() - this.fetchNetworkPrice() - this.fetchBalances() + // Only load balances and network price if a token is set, else they will be loaded in the select token view + if (this.token) { + this.fetchBalances() + this.fetchNetworkPrice() + } + this.unsubscribe.push( ...[ SendController.subscribe(val => { diff --git a/packages/scaffold-ui/test/views/w3m-swap-view.test.ts b/packages/scaffold-ui/test/views/w3m-swap-view.test.ts index aff7968f75..3e3d25c455 100644 --- a/packages/scaffold-ui/test/views/w3m-swap-view.test.ts +++ b/packages/scaffold-ui/test/views/w3m-swap-view.test.ts @@ -1,7 +1,7 @@ import { expect, fixture, html } from '@open-wc/testing' import { afterEach, beforeEach, describe, it, vi, expect as vitestExpect } from 'vitest' -import { type CaipAddress, type CaipNetwork } from '@reown/appkit-common' +import type { CaipAddress, CaipNetwork } from '@reown/appkit-common' import { AccountController, ChainController, @@ -428,6 +428,7 @@ describe('W3mSwapView', () => { vitestExpect(resetStateSpy).toHaveBeenCalled() vitestExpect(initializeStateSpy).not.toHaveBeenCalled() }) + it('should call handleChangeAmount with max value when setting max value', async () => { vi.useFakeTimers() const swapTokensSpy = vi.spyOn(SwapController, 'swapTokens') diff --git a/packages/scaffold-ui/test/views/w3m-wallet-send-select-token-view.test.ts b/packages/scaffold-ui/test/views/w3m-wallet-send-select-token-view.test.ts index 98c345df78..eab78c68c0 100644 --- a/packages/scaffold-ui/test/views/w3m-wallet-send-select-token-view.test.ts +++ b/packages/scaffold-ui/test/views/w3m-wallet-send-select-token-view.test.ts @@ -1,7 +1,7 @@ import { expect, fixture, html } from '@open-wc/testing' import { afterEach, beforeEach, describe, it, vi, expect as viExpect } from 'vitest' -import type { Balance, CaipNetwork } from '@reown/appkit-common' +import type { Balance, CaipAddress, CaipNetwork } from '@reown/appkit-common' import { ChainController, RouterController, SendController } from '@reown/appkit-controllers' import { W3mSendSelectTokenView } from '../../src/views/w3m-wallet-send-select-token-view' @@ -65,10 +65,15 @@ const mockNetwork: CaipNetwork = { } } +const mockCaipAddress: CaipAddress = 'eip155:1:0x123' + describe('W3mSendSelectTokenView', () => { beforeEach(() => { vi.spyOn(SendController.state, 'tokenBalances', 'get').mockReturnValue(mockTokens) vi.spyOn(ChainController.state, 'activeCaipNetwork', 'get').mockReturnValue(mockNetwork) + vi.spyOn(ChainController.state, 'activeCaipAddress', 'get').mockReturnValue(mockCaipAddress) + vi.spyOn(SendController, 'fetchTokenBalance').mockResolvedValue(mockTokens) + vi.spyOn(SendController, 'fetchNetworkBalance').mockResolvedValue(undefined) }) afterEach(() => { @@ -89,6 +94,28 @@ describe('W3mSendSelectTokenView', () => { expect(searchInput).to.exist }) + it('should not fetch balances and network price if tokens are already set', async () => { + const element = await fixture( + html`` + ) + + await element.updateComplete + + viExpect(SendController.fetchTokenBalance).toHaveBeenCalledTimes(0) + }) + + it('should fetch balances and network price if tokens are not set', async () => { + vi.spyOn(SendController.state, 'tokenBalances', 'get').mockReturnValue([]) + + const element = await fixture( + html`` + ) + + await element.updateComplete + + viExpect(SendController.fetchTokenBalance).toHaveBeenCalled() + }) + it('should filter tokens by search input', async () => { const element = await fixture( html`` diff --git a/packages/scaffold-ui/test/views/w3m-wallet-send-view.test.ts b/packages/scaffold-ui/test/views/w3m-wallet-send-view.test.ts index e6a74f8971..83ae9eb985 100644 --- a/packages/scaffold-ui/test/views/w3m-wallet-send-view.test.ts +++ b/packages/scaffold-ui/test/views/w3m-wallet-send-view.test.ts @@ -218,13 +218,25 @@ describe('W3mWalletSendView', () => { viExpect(routerSpy).toHaveBeenCalledWith('WalletSendPreview') }) - it('should fetch network price on initialization', async () => { + it('should fetch network price on initialization if token is set', async () => { + SendController.setToken(mockToken) await fixture(html``) viExpect(SwapController.getNetworkTokenPrice).toHaveBeenCalled() }) - it('should fetch balances on initialization', async () => { + it('should not fetch network price on initialization if no token is set', async () => { + await fixture(html``) + viExpect(SwapController.getNetworkTokenPrice).toHaveBeenCalledTimes(0) + }) + + it('should not fetch balances on initialization if no token is set', async () => { + await fixture(html``) + viExpect(SendController.fetchTokenBalance).toHaveBeenCalledTimes(0) + }) + + it('should fetch balances on initialization if token is set', async () => { + SendController.setToken(mockToken) await fixture(html``) viExpect(SendController.fetchTokenBalance).toHaveBeenCalled() }) diff --git a/packages/ui/src/composites/wui-token-list-item/index.ts b/packages/ui/src/composites/wui-token-list-item/index.ts index 3cb3ed7d69..856be407a8 100644 --- a/packages/ui/src/composites/wui-token-list-item/index.ts +++ b/packages/ui/src/composites/wui-token-list-item/index.ts @@ -85,7 +85,7 @@ export class WuiTokenListItem extends LitElement { ${this.symbol} ${this.amount ? html` - ${UiHelperUtil.formatNumberToLocalString(this.amount, 4)} + ${UiHelperUtil.formatNumberToLocalString(this.amount, 3)} ` : null}