Skip to content
Closed
403 changes: 204 additions & 199 deletions contracts/compiled/DeFiAAVEPosition.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion contracts/deployless/DeFiAAVEPosition.sol
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,10 @@ struct AAVEUserBalance {
UserAccountData accountData;
bytes userBalanceErr;
bytes accountDataErr;

// Total number of reserves in the pool. Returned on every page so the
// caller can page through all reserves without a separate reserves-count
// request first.
uint256 reservesCount;
}

interface IPoolAddressesProvider {
Expand Down Expand Up @@ -413,6 +416,7 @@ contract DeFiAAVEPosition {
function getAAVEPosition(address userAddr, address poolAddr, uint from, uint to) external view returns (AAVEUserBalance memory result) {
(result.userBalance, result.accountDataErr) = positions.getTokenBalancesFromPool(userAddr, poolAddr, from, to);
(result.accountData, result.accountDataErr) = positions.getUserAccountData(userAddr, poolAddr);
result.reservesCount = IPOOL(poolAddr).getReservesList().length;
return result;
}
}
3 changes: 1 addition & 2 deletions src/controllers/hintsController/hintsController.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { getAddress } from 'ethers'
import { zeroAddress } from 'viem'
import { getAddress, zeroAddress } from 'viem'

import EventEmitter from '@/controllers/eventEmitter/eventEmitter'
import { AccountId, IAccountsController } from '@/interfaces/account'
Expand Down
3 changes: 2 additions & 1 deletion src/controllers/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,8 @@ export class MainController extends EventEmitter implements IMainController {
storage: this.storage,
accounts: this.accounts,
autoLogin: this.autoLogin,
banner: this.banner
banner: this.banner,
ui: this.ui
})

this.portfolio = new PortfolioController(
Expand Down
5 changes: 5 additions & 0 deletions src/controllers/portfolio/portfolio.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { getAddress } from 'ethers'

import { yieldToMain } from '@/utils/scheduler'

import {
IRecurringTimeout,
RecurringTimeout
Expand Down Expand Up @@ -1454,6 +1456,7 @@ export class PortfolioController
defiMaxDataAgeMs,
hasKeys: portfolioProps.hasKeys
})
await yieldToMain()
const allHints = this.hints.getAllHints(
account.addr,
network.chainId,
Expand Down Expand Up @@ -1507,6 +1510,8 @@ export class PortfolioController
!t.flags.rewardsType
) ?? null

await yieldToMain()

const newDefiState = getNewDefiState(
state.result,
discoveryData,
Expand Down
18 changes: 16 additions & 2 deletions src/controllers/selectedAccount/selectedAccount.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { formatEther, getAddress, isAddress } from 'ethers'

import { IUiController } from '@/interfaces/ui'

import { STK_WALLET, UNI_V3_WALLET_WETH_POOL, WALLET_TOKEN } from '../../consts/addresses'
import { AMBIRE_ACCOUNT_FACTORY } from '../../consts/deploy'
import { Account, IAccountsController } from '../../interfaces/account'
Expand Down Expand Up @@ -57,6 +59,8 @@ export class SelectedAccountController extends EventEmitter implements ISelected

#domains: IDomainsController | null = null

#ui: IUiController | null = null

account: Account | null = null

/**
Expand Down Expand Up @@ -92,20 +96,23 @@ export class SelectedAccountController extends EventEmitter implements ISelected
storage,
accounts,
autoLogin,
banner
banner,
ui
}: {
eventEmitterRegistry?: IEventEmitterRegistryController
storage: IStorageController
accounts: IAccountsController
autoLogin: IAutoLoginController
banner: IBannerController
ui: IUiController
}) {
super(eventEmitterRegistry)

this.#storage = storage
this.#accounts = accounts
this.#autoLogin = autoLogin
this.#banner = banner
this.#ui = ui

this.initialLoadPromise = this.#load().finally(() => {
this.initialLoadPromise = undefined
Expand Down Expand Up @@ -341,7 +348,14 @@ export class SelectedAccountController extends EventEmitter implements ISelected
}

// Set the loading timestamp when the portfolio starts loading
if (!this.#portfolioLoadingTimeout && !newSelectedAccountPortfolio.isAllReady) {
if (
!this.#portfolioLoadingTimeout &&
!newSelectedAccountPortfolio.isAllReady &&
// Don't start the timeout until the user is on the dashboard
// to avoid showing the waiting too long warning on mobile when the
// loading has started before the user has navigated to the dashboard
this.#ui?.views.some((v) => v.currentRoute === 'dashboard')
) {
this.#portfolioLoadingTimeout = setTimeout(() => {
this.portfolio.shouldShowPartialResult = true
this.updateSelectedAccountPortfolio()
Expand Down
3 changes: 2 additions & 1 deletion src/controllers/signAccountOp/signAccountOp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,8 @@ const init = async (
storage: storageCtrl,
accounts: accountsCtrl,
autoLogin: autoLoginCtrl,
banner: bannerCtrl
banner: bannerCtrl,
ui: uiCtrl
})
const addressBookCtrl = new AddressBookController(storageCtrl, accountsCtrl, selectedAccountCtrl)
await accountsCtrl.initialLoadPromise
Expand Down
3 changes: 2 additions & 1 deletion src/controllers/swapAndBridge/swapAndBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,8 @@ const selectedAccountCtrl = new SelectedAccountController({
storage: storageCtrl,
accounts: accountsCtrl,
autoLogin: autoLoginCtrl,
banner: bannerCtrl
banner: bannerCtrl,
ui: uiCtrl
})

const addressBookCtrl = new AddressBookController(storageCtrl, accountsCtrl, selectedAccountCtrl)
Expand Down
18 changes: 11 additions & 7 deletions src/controllers/swapAndBridge/swapAndBridge.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { formatUnits, getAddress, isAddress, parseUnits, ZeroAddress } from 'ethers'
import { formatUnits, isAddress, parseUnits, ZeroAddress } from 'ethers'

import { getAccountNetworks } from '@/libs/networks/networks'
import { BindedRelayerCall } from '@/libs/relayerCall/relayerCall'
Expand Down Expand Up @@ -1454,16 +1454,20 @@ export class SwapAndBridgeController extends EventEmitter implements ISwapAndBri
})
const portfolioTokens = this.portfolioTokenList.filter((t) => t.chainId === BigInt(toChainId))

const apiTokenAddresses = new Set(apiTokens.map((t) => t.address.toLowerCase()))
const additionalTokensFromPortfolio = portfolioTokens
.filter((token) => !apiTokens.some((t) => t.address === token.address))
.filter((token) => !apiTokenAddresses.has(token.address.toLowerCase()))
.map((t) => convertPortfolioTokenToSwapAndBridgeToToken(t, toChainId))

const chainBannedTokens: string[] = getBannedToTokenList(toChainId.toString())
const chainBannedTokens = new Set(
getBannedToTokenList(toChainId.toString()).map((address) => address.toLowerCase())
)

const tokens = [...apiTokens, ...additionalTokensFromPortfolio].filter(
(t) => !chainBannedTokens.has(t.address.toLowerCase())
)

return sortTokenListResponse(
[...apiTokens, ...additionalTokensFromPortfolio],
portfolioTokens
).filter((t) => !chainBannedTokens.includes(getAddress(t.address)))
return sortTokenListResponse(tokens, portfolioTokens)
}

get updateToTokenListStatus() {
Expand Down
25 changes: 21 additions & 4 deletions src/controllers/ui/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,26 @@ import { EventEmitter as UiEventEmitter } from 'events'
import { IEventEmitterRegistryController } from '../../interfaces/eventEmitter'
import {
FocusWindowParams,
isExtensionOverlayView,
IUiController,
OpenWindowOptions,
UiManager,
View,
WindowId,
WindowProps,
isExtensionOverlayView
WindowProps
} from '../../interfaces/ui'
import EventEmitter from '../eventEmitter/eventEmitter'

function areSearchParamsEqual(a: View['searchParams'], b: View['searchParams']): boolean {
if (a === b) return true
if (!a || !b) return !a && !b

const aKeys = Object.keys(a)
if (aKeys.length !== Object.keys(b).length) return false

return aKeys.every((key) => a[key] === b[key])
}

/**
* The surface that shows a request: the panel when it is open (nothing to open, focus or close
* there), a dedicated request window otherwise. Consumers only open, focus and close.
Expand Down Expand Up @@ -101,8 +111,15 @@ export class UiController extends EventEmitter implements IUiController {
const view = this.views.find((v) => v.id === viewId)
if (!view) return

// @ts-expect-error
const shouldUpdate = Object.entries(updatedProps).some(([key, value]) => view[key] !== value)
const shouldUpdate = Object.entries(updatedProps).some(([key, value]) => {
// searchParams is a plain object rebuilt by the caller on every dispatch,
// so a reference check always reports a change. Compare it by value.
if (key === 'searchParams') {
return !areSearchParamsEqual(view.searchParams, value as View['searchParams'])
}

return view[key as keyof View] !== value
})
if (!shouldUpdate) return

let previousRoute = view.previousRoute
Expand Down
141 changes: 94 additions & 47 deletions src/libs/defiPositions/defiPositions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getAddress, parseUnits, ZeroAddress } from 'ethers'
import { isHex } from 'viem'
import { parseUnits, ZeroAddress } from 'ethers'
import { getAddress, isHex } from 'viem'

import { getSanitizedAmount } from '@/libs/transfer/amount'

Expand Down Expand Up @@ -321,6 +321,94 @@ const getFormattedApiPositions = (result: Omit<PositionsByProvider, 'source'>[])
}))
}

/**
* Groups the portfolio tokens by their lowercased address, keeping the original
* order within each group, so that looking a token up by address doesn't
* require a scan of the whole list.
*/
const groupTokensByLowercasedAddress = (
portfolioTokens: TokenResult[]
): Map<string, TokenResult[]> => {
const tokensByAddress = new Map<string, TokenResult[]>()

portfolioTokens.forEach((token) => {
const address = token.address.toLowerCase()
const sameAddressTokens = tokensByAddress.get(address)

if (sameAddressTokens) sameAddressTokens.push(token)
else tokensByAddress.set(address, [token])
})

return tokensByAddress
}

/**
* Finds the portfolio token that a DeFi position asset refers to by its address.
* An exact address match wins over a case-insensitive one, and rewards and gas
* tank tokens are never a protocol asset. Returns undefined if the portfolio
* holds no such token.
*/
const findTokenByProtocolAssetAddress = (
tokensByAddress: Map<string, TokenResult[]>,
protocolAssetAddress: string
): TokenResult | undefined =>
tokensByAddress
.get(protocolAssetAddress.toLowerCase())
?.find(
(token) =>
token.address === protocolAssetAddress ||
(!token.flags.rewardsType && !token.flags.onGasTank)
)

/**
* Finds the portfolio token that a DeFi position asset with no protocol asset
* refers to. Nothing but the symbol links the two, so a match is accepted only
* when both also hold nearly the same value, otherwise two unrelated tokens
* sharing a symbol would be treated as one. Returns undefined when there is no
* confident match.
*
* This scans every token and prices each candidate, so only reach for it when
* there is no address to match on.
*/
const findTokenBySimilarSymbolAndValue = (
portfolioTokens: TokenResult[],
asset: PositionAsset
): TokenResult | undefined => {
const assetValue = asset.value

// If the token or asset don't have a value we MUST! not compare them
// by value as that would lead to false positives
if (!assetValue) return undefined

const assetSymbol = asset.symbol.toLowerCase()

return portfolioTokens.find((token) => {
if (token.flags.rewardsType || token.flags.onGasTank) return false

const symbol = token.symbol.toLowerCase()
// The portfolio token should contain the asset symbol, but be a different token
if (symbol === assetSymbol || !symbol.includes(assetSymbol)) return false

const priceUSD = token.priceIn.find(
({ baseCurrency }: { baseCurrency: string }) => baseCurrency.toLowerCase() === 'usd'
)?.price

if (!priceUSD) return false

const tokenBalanceUSD = Number(
safeTokenAmountAndNumberMultiplication(
BigInt(token.amountPostSimulation || token.amount),
token.decimals,
priceUSD
)
)

if (!tokenBalanceUSD) return false

return isTokenPriceWithinHalfPercent(tokenBalanceUSD, assetValue)
})
}

/**
* Enhances the portfolio tokens with Defi position data.
* Examples:
Expand Down Expand Up @@ -348,6 +436,7 @@ const enhancePortfolioTokensWithDefiPositions = (
}
>()
const notYetHandledTokensToAdd: TokenResult[] = []
const tokensByAddress = groupTokensByLowercasedAddress(portfolioTokens)

defiPositionsState.positionsByProvider.forEach((posByProvider) => {
// Skip app providers
Expand All @@ -369,51 +458,9 @@ const enhancePortfolioTokensWithDefiPositions = (
pos.assets.forEach((asset) => {
const protocolAsset = asset.protocolAsset || null

const tokenCorrespondingToProtocolAsset = portfolioTokens.find((t) => {
const isSameAddress = t.address === protocolAsset?.address

if (isSameAddress) return true

const priceUSD = t.priceIn.find(
({ baseCurrency }: { baseCurrency: string }) => baseCurrency.toLowerCase() === 'usd'
)?.price

const tokenBalanceUSD = priceUSD
? Number(
safeTokenAmountAndNumberMultiplication(
BigInt(t.amountPostSimulation || t.amount),
t.decimals,
priceUSD
)
)
: undefined

if (protocolAsset?.address) {
return (
!t.flags.rewardsType &&
!t.flags.onGasTank &&
t.address.toLowerCase() === protocolAsset.address.toLowerCase()
)
}

// If the token or asset don't have a value we MUST! not compare them
// by value as that would lead to false positives
if (!tokenBalanceUSD || !asset.value) return false

// If there is no protocol asset we have to fallback to finding the token
// by symbol and chainId. In that case we must ensure that the value of the two
// assets is similar
return (
!t.flags.rewardsType &&
!t.flags.onGasTank &&
// the portfolio token should contain the original asset symbol
t.symbol.toLowerCase().includes(asset.symbol.toLowerCase()) &&
// but should be a different token symbol
t.symbol.toLowerCase() !== asset.symbol.toLowerCase() &&
// and prices should have no more than 0.5% diff
isTokenPriceWithinHalfPercent(tokenBalanceUSD || 0, asset.value || 0)
)
})
const tokenCorrespondingToProtocolAsset = protocolAsset?.address
? findTokenByProtocolAssetAddress(tokensByAddress, protocolAsset.address)
: findTokenBySimilarSymbolAndValue(portfolioTokens, asset)

if (tokenCorrespondingToProtocolAsset) {
defiAssetsMap.set(tokenCorrespondingToProtocolAsset.address.toLowerCase(), {
Expand Down
Loading