diff --git a/src/composables/fungibleTokens.ts b/src/composables/fungibleTokens.ts new file mode 100644 index 000000000..673a22cae --- /dev/null +++ b/src/composables/fungibleTokens.ts @@ -0,0 +1,317 @@ +import { watch } from 'vue'; +import camelCaseKeysDeep from 'camelcase-keys-deep'; +import BigNumber from 'bignumber.js'; +import { Encoded, Encoding } from '@aeternity/aepp-sdk'; +import { fetchAllPages, handleUnknownError, toShiftedBigNumber } from '@/utils'; +import type { + BigNumberPublic, + IDefaultComposableOptions, + IToken, + ITokenBalanceResponse, + ITokenList, + ITransaction, + TokenPair, +} from '@/types'; +import { PROTOCOL_AETERNITY, STORAGE_KEYS, TX_DIRECTION } from '@/constants'; +import FungibleTokenFullInterfaceACI from '@/lib/contracts/FungibleTokenFullInterfaceACI.json'; +import AedexV2PairACI from '@/lib/contracts/AedexV2PairACI.json'; +import ZeitTokenACI from '@/lib/contracts/FungibleTokenFullACI.json'; + +import { aettosToAe, calculateSupplyAmount, categorizeContractCallTxObject } from '@/protocols/aeternity/helpers'; +import { AE_SYMBOL } from '@/protocols/aeternity/config'; + +import { useAccounts } from './accounts'; +import { useAeSdk } from './aeSdk'; +import { useMiddleware } from './middleware'; +import { useTippingContracts } from './tippingContracts'; +import { createNetworkWatcher } from './networks'; +import { createPollingBasedOnMountedComponents } from './composablesHelpers'; +import { useStorageRef } from './storageRef'; + +/** + * List of all custom tokens available (currently only AE network). + * As this list is quite big (hundreds of items) it requires processing optimizations. + */ +const availableTokens = useStorageRef( + {}, + STORAGE_KEYS.fungibleTokenList, +); + +/** + * List of tokens (assets) owned by active account with the balance value + */ +const tokenBalances = useStorageRef>( + {}, + STORAGE_KEYS.fungibleTokenBalances, +); + +const { onNetworkChange } = createNetworkWatcher(); +const availableTokensPooling = createPollingBasedOnMountedComponents(60000); +const tokenBalancesPooling = createPollingBasedOnMountedComponents(10000); + +export function useFungibleTokens({ store }: IDefaultComposableOptions) { + const { getAeSdk } = useAeSdk({ store }); + const { fetchFromMiddleware } = useMiddleware(); + const { tippingContractAddresses } = useTippingContracts({ store }); + const { + isLoggedIn, + aeAccounts, + getLastActiveProtocolAccount, + } = useAccounts(); + + function getAccountTokenBalances(address?: string): IToken[] { + const account = getLastActiveProtocolAccount(PROTOCOL_AETERNITY); + return tokenBalances.value[address || account?.address!] || []; + } + + async function loadAvailableTokens() { + const response: IToken[] = camelCaseKeysDeep(await fetchAllPages( + () => fetchFromMiddleware('/v2/aex9?by=name&limit=100&direction=forward'), + fetchFromMiddleware, + )); + + if (!response.length) { + availableTokens.value = {}; + } + + availableTokens.value = response.reduce((accumulator, token) => { + // eslint-disable-next-line no-param-reassign + accumulator[token.contractId] = token; + return accumulator; + }, {} as ITokenList); + } + + async function loadTokenBalances() { + if (!isLoggedIn.value) { + return; + } + const addresses = aeAccounts.value.map((account) => account.address); + + await Promise.all(addresses.map(async (address) => { + try { + const tokens: ITokenBalanceResponse[] = camelCaseKeysDeep(await fetchAllPages( + () => fetchFromMiddleware(`/v2/aex9/account-balances/${address}?limit=100`), + fetchFromMiddleware, + )); + if (!tokens.length) { + return; + } + + tokenBalances.value[address] = tokens + .filter(({ contractId }) => availableTokens.value[contractId]) + .map(({ amount, contractId }): IToken => { + const availableToken = availableTokens.value[contractId]; + const balance = toShiftedBigNumber(amount!, -availableToken.decimals); + const convertedBalance = Number(balance.toFixed(2)); + + return { + ...availableToken, // TODO store the balance and amount separately from asset data + amount, + convertedBalance, + }; + }); + } catch (error) { + handleUnknownError(error); + } + })); + } + + async function createOrChangeAllowance(contractId: string, amount: number | string) { + const aeSdk = await getAeSdk(); + const account = getLastActiveProtocolAccount(PROTOCOL_AETERNITY); + const selectedToken = tokenBalances.value?.[account?.address!] + ?.find((token) => token?.contractId === contractId); + + const tokenContract = await aeSdk.initializeContract({ + aci: FungibleTokenFullInterfaceACI, + address: selectedToken?.contractId as any, + }); + + const { decodedResult } = await tokenContract.allowance({ + from_account: account?.address, + for_account: tippingContractAddresses?.value?.tippingV2?.replace('ct_', 'ak_'), + }); + + const allowanceAmount = (decodedResult !== undefined) + ? new BigNumber(decodedResult) + .multipliedBy(-1) + .plus(toShiftedBigNumber(amount, selectedToken?.decimals!)) + .toNumber() + : toShiftedBigNumber(amount, selectedToken?.decimals!) + .toNumber(); + + const getContractFunction = (tokenContract.methods as any)[ + decodedResult !== undefined ? 'change_allowance' : 'create_allowance' + ]; + + return getContractFunction( + tippingContractAddresses.value?.tippingV2?.replace( + `${Encoding.ContractAddress}_`, + `${Encoding.AccountAddress}_`, + ), + allowanceAmount, + ); + } + + async function getContractTokenPairs( + address: Encoded.ContractAddress, + ): Promise & Record> { + try { + const aeSdk = await getAeSdk(); + const account = getLastActiveProtocolAccount(PROTOCOL_AETERNITY); + const tokenContract = await aeSdk.initializeContract({ + aci: AedexV2PairACI, + address, + }); + + const [ + { decodedResult: balances }, + { decodedResult: balance }, + { decodedResult: token0 }, + { decodedResult: token1 }, + { decodedResult: reserves }, + { decodedResult: totalSupply }, + ] = await Promise.all([ + tokenContract.balances(), + tokenContract.balance(account?.address), + tokenContract.token0(), + tokenContract.token1(), + tokenContract.get_reserves(), + tokenContract.total_supply(), + ]); + + return { + token0: { + ...availableTokens.value?.[token0], + amount: calculateSupplyAmount( + balance, + totalSupply, + reserves.reserve0, + ), + }, + token1: { + ...availableTokens.value?.[token1], + amount: calculateSupplyAmount( + balance, + totalSupply, + reserves.reserve1, + ), + }, + totalSupply, + balance, + balances, + }; + } catch (error) { + return {}; + } + } + + async function transferToken( + tokenContractId: Encoded.ContractAddress, + toAccount: Encoded.AccountAddress, + amount: number, + option: { + waitMined: boolean, + modal: boolean, + }, + ) { + const aeSdk = await getAeSdk(); + const tokenContract = await aeSdk.initializeContract({ + aci: FungibleTokenFullInterfaceACI, + address: tokenContractId, + }); + return tokenContract.transfer(toAccount, amount.toFixed(), option); + } + + async function burnTriggerPoS( + address: Encoded.ContractAddress, + posAddress: string, + invoiceId: string, + amount: number, + option: { + waitMined: boolean, + modal: boolean, + }, + ) { + const aeSdk = await getAeSdk(); + const tokenContract = await aeSdk.initializeContract({ + aci: ZeitTokenACI, + address, + }); + return tokenContract.burn_trigger_pos( + amount.toFixed(), + posAddress, + invoiceId, + option, + ); + } + + function getTxSymbol(transaction?: ITransaction) { + if (transaction?.pendingTokenTx) { + return availableTokens.value[transaction.tx.contractId]?.symbol; + } + const contractCallData = transaction?.tx && categorizeContractCallTxObject(transaction); + return availableTokens.value[contractCallData?.token!]?.symbol || AE_SYMBOL; + } + + function getTxAmountTotal( + transaction: ITransaction, + direction: string = TX_DIRECTION.sent, + ) { + const contractCallData = transaction.tx && categorizeContractCallTxObject(transaction); + if (contractCallData && availableTokens.value[contractCallData.token!]) { + return +toShiftedBigNumber( + contractCallData.amount || 0, + -availableTokens.value[contractCallData.token!].decimals, + ); + } + const isReceived = direction === TX_DIRECTION.received; + + const rawAmount = ( + transaction.tx?.amount + || (transaction.tx?.tx?.tx as any)?.amount + || transaction.tx?.nameFee + || 0 + ); + + const amount: BigNumberPublic = (typeof rawAmount === 'object') + ? rawAmount + : new BigNumber(Number(rawAmount)); + + return +aettosToAe(amount + .plus(isReceived ? 0 : transaction.tx?.fee || 0) + .plus(isReceived ? 0 : transaction.tx?.tx?.tx?.fee || 0)); + } + + onNetworkChange(async (network, oldNetwork) => { + const newMiddlewareUrl = network.protocols[PROTOCOL_AETERNITY].middlewareUrl; + const oldMiddlewareUrl = oldNetwork?.protocols?.[PROTOCOL_AETERNITY]?.middlewareUrl; + if (newMiddlewareUrl !== oldMiddlewareUrl) { + await loadAvailableTokens(); + await loadTokenBalances(); + } + }); + + watch(aeAccounts, (val, oldVal) => { + if (val !== oldVal) { + loadTokenBalances(); + } + }); + + availableTokensPooling(() => loadAvailableTokens()); + tokenBalancesPooling(() => loadTokenBalances()); + + return { + availableTokens, + tokenBalances, + burnTriggerPoS, + createOrChangeAllowance, + getAccountTokenBalances, + getContractTokenPairs, + getTxSymbol, + getTxAmountTotal, + transferToken, + loadTokenBalances, + loadAvailableTokens, + }; +} diff --git a/src/composables/index.ts b/src/composables/index.ts index 43d3cd62a..c36f09e8b 100644 --- a/src/composables/index.ts +++ b/src/composables/index.ts @@ -27,6 +27,7 @@ export * from './transactionTokens'; export * from './transactionList'; export * from './ui'; export * from './viewport'; +export * from './fungibleTokens'; export * from './coinTokensProps'; export * from './scrollTransactions'; export * from './languages'; diff --git a/src/composables/latestTransactionList.ts b/src/composables/latestTransactionList.ts index 6a3cdf8fc..fbb8710aa 100644 --- a/src/composables/latestTransactionList.ts +++ b/src/composables/latestTransactionList.ts @@ -15,6 +15,7 @@ import { } from '@/utils'; import { ProtocolAdapterFactory } from '@/lib/ProtocolAdapterFactory'; import { AE_MDW_TO_NODE_APPROX_DELAY_TIME } from '@/protocols/aeternity/config'; +import { useFungibleTokens } from './fungibleTokens'; import { useAccounts } from './accounts'; import { useBalances } from './balances'; import { useTransactionTx } from './transactionTx'; @@ -40,10 +41,9 @@ export function useLatestTransactionList({ store }: IDefaultComposableOptions) { fetchTransactions, } = useTransactionList({ store }); + const { tokenBalances } = useFungibleTokens({ store }); const btcTransactions = ref([]); - const tokens = computed(() => store.state.fungibleTokens.tokens); - const latestTransactions = computed(() => { const aeTransactions = Object.entries(transactions.value) .map(([ @@ -131,7 +131,7 @@ export function useLatestTransactionList({ store }: IDefaultComposableOptions) { ); watch( - tokens, + tokenBalances, (oldTokens, newTokens) => { if (!isEqual(oldTokens, newTokens)) { updateTransactionListData(); diff --git a/src/composables/tokensList.ts b/src/composables/tokensList.ts index 8459d2eeb..e94eac649 100644 --- a/src/composables/tokensList.ts +++ b/src/composables/tokensList.ts @@ -13,7 +13,9 @@ import type { import { AE_CONTRACT_ID } from '@/protocols/aeternity/config'; import { ProtocolAdapterFactory } from '@/lib/ProtocolAdapterFactory'; import { PROTOCOL_AETERNITY } from '@/constants'; -import { useCurrencies } from '@/composables/currencies'; +import { useCurrencies } from './currencies'; +import { useFungibleTokens } from './fungibleTokens'; +import { useAccounts } from './accounts'; import { useMultisigAccounts } from './multisigAccounts'; import { useBalances } from './balances'; @@ -46,14 +48,18 @@ export function useTokensList({ }: UseTokensListOptions) { const { marketData } = useCurrencies(); const { balance } = useBalances(); + const { activeAccount } = useAccounts(); const { activeMultisigAccount } = useMultisigAccounts({ store }); + const { + availableTokens: allAvailableTokens, + getAccountTokenBalances, + } = useFungibleTokens({ store }); const availableTokens = computed(() => ( isMultisig - ? [] - : (store.state as any).fungibleTokens.availableTokens + ? {} + : allAvailableTokens.value )); - const tokenBalances = computed(() => store.getters['fungibleTokens/tokenBalances']); const aeTokenBalance = computed((): Balance => ( isMultisig @@ -61,6 +67,8 @@ export function useTokensList({ : balance.value || new BigNumber(0) )); + const tokenBalances = computed(() => getAccountTokenBalances(activeAccount.value.address)); + /** * Returns the default aeternity meta information */ diff --git a/src/composables/transactionList.ts b/src/composables/transactionList.ts index fbeadbf6c..d4a5eb796 100644 --- a/src/composables/transactionList.ts +++ b/src/composables/transactionList.ts @@ -77,7 +77,7 @@ export function useTransactionList({ store }: IDefaultComposableOptions) { } const { pending, loaded } = getAccountTransactionsState(address); - return [...loaded, ...(pending[nodeNetworkId.value!] || [])]; + return [...loaded, ...((pending || {})[nodeNetworkId.value!] || [])]; } function getTransactionByHash(address: Encoded.AccountAddress, hash: string) { @@ -277,7 +277,7 @@ export function useTransactionList({ store }: IDefaultComposableOptions) { preparedTransactions = orderBy(preparedTransactions, ['microTime'], ['desc']); const oldPendingTransactionForAccount: ITransaction[] = ( - transactions.value[address]?.pending[nodeNetworkId.value!] || [] + transactions.value[address]?.pending?.[nodeNetworkId.value!] || [] ); oldPendingTransactionForAccount.forEach(({ hash }) => { @@ -321,7 +321,7 @@ export function useTransactionList({ store }: IDefaultComposableOptions) { transactions.value = getLocalStorageItem([TRANSACTIONS_LOCAL_STORAGE_KEY, value!]) || {}; Object.entries(transactions.value).forEach(([address, transactionState]) => { - (transactionState.pending[nodeNetworkId.value!])?.filter(({ sent = false }) => !sent) + (transactionState.pending?.[nodeNetworkId.value!])?.filter(({ sent = false }) => !sent) .forEach((transaction) => { if (Date.now() - (transaction.microTime || 0) > 600000) { removePendingTransactionByAccount( diff --git a/src/composables/transactionTokens.ts b/src/composables/transactionTokens.ts index 40c1174c3..1af7a05f9 100644 --- a/src/composables/transactionTokens.ts +++ b/src/composables/transactionTokens.ts @@ -2,7 +2,6 @@ import { computed } from 'vue'; import { camelCase } from 'lodash-es'; import type { IDefaultComposableOptions, - ITokenList, ITokenResolved, ITransaction, TxFunctionParsed, @@ -20,6 +19,7 @@ import { } from '@/protocols/aeternity/helpers'; import { BTC_SYMBOL } from '@/protocols/bitcoin/config'; import { getTxAmountTotal as getBitcoinTxAmountTotal } from '@/protocols/bitcoin/helpers'; +import { useFungibleTokens } from './fungibleTokens'; interface UseTransactionTokensOptions extends IDefaultComposableOptions { transaction: ITransaction @@ -35,13 +35,8 @@ export function useTransactionTokens({ transaction, showDetailedAllowanceInfo = false, }: UseTransactionTokensOptions) { - const getTxSymbol = computed(() => store.getters.getTxSymbol); - const getTxAmountTotal = computed(() => store.getters.getTxAmountTotal); const innerTx = computed(() => getInnerTransaction(transaction.tx)); - - const availableTokens = computed( - () => (store.state as any).fungibleTokens.availableTokens, - ); + const { availableTokens, getTxAmountTotal, getTxSymbol } = useFungibleTokens({ store }); const transactionFunction = computed(() => { if (innerTx.value?.function) { @@ -83,13 +78,13 @@ export function useTransactionTokens({ ...innerTx.value || {}, amount: isAllowance ? toShiftedBigNumber(innerTx.value?.fee || 0, -AE_COIN_PRECISION) - : getTxAmountTotal.value(transaction, direction), - symbol: isAllowance ? AE_SYMBOL : getTxSymbol.value(transaction), + : getTxAmountTotal(transaction, direction), + symbol: isAllowance ? AE_SYMBOL : getTxSymbol(transaction), isReceived: direction === TX_DIRECTION.received, isAe: isAllowance || ( - getTxSymbol.value(transaction) === AE_SYMBOL + getTxSymbol(transaction) === AE_SYMBOL && !isTransactionAex9(transaction) ), }]; diff --git a/src/composables/transactionTx.ts b/src/composables/transactionTx.ts index 8c03879e3..ced0a51b7 100644 --- a/src/composables/transactionTx.ts +++ b/src/composables/transactionTx.ts @@ -3,7 +3,6 @@ import { Encoded, Tag } from '@aeternity/aepp-sdk'; import type { IAccountOverview, IDefaultComposableOptions, - ITokenList, ITx, ObjectValues, TxFunctionRaw, @@ -35,6 +34,7 @@ import { isTxFunctionDexRemoveLiquidity, isTxFunctionDexPool, } from '@/protocols/aeternity/helpers'; +import { useFungibleTokens } from '@/composables/fungibleTokens'; import { useAccounts } from './accounts'; import { useAeSdk } from './aeSdk'; @@ -53,15 +53,12 @@ export function useTransactionTx({ const { dexContracts } = useAeSdk({ store }); const { accounts, activeAccount } = useAccounts(); const { tippingContractAddresses } = useTippingContracts({ store }); + const { availableTokens } = useFungibleTokens({ store }); const outerTx = ref(tx); const innerTx = ref(tx ? getInnerTransaction(tx) : undefined); const ownerAddress = ref(externalAddress); - const availableTokens = computed( - () => (store.state as any).fungibleTokens.availableTokens, - ); - const hasNestedTx = computed(() => outerTx.value && isContainingNestedTx(outerTx.value)); const innerTxTag = computed((): Tag | null => innerTx.value ? getTxTag(innerTx.value) : null); const outerTxTag = computed((): Tag | null => tx ? getTxTag(tx) : null); diff --git a/src/constants/common.ts b/src/constants/common.ts index 0ded417b0..2e4ef1806 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -154,6 +154,8 @@ export const STORAGE_KEYS = { namesOwned: 'names-owned', namesDefault: 'names-default', lastRoute: 'last-route', + fungibleTokenList: 'fungible-token-list', + fungibleTokenBalances: 'fungible-token-balances', permissions: 'permissions', } as const; diff --git a/src/popup/components/AccountCardTotalTokens.vue b/src/popup/components/AccountCardTotalTokens.vue index f4816590f..962e248d6 100644 --- a/src/popup/components/AccountCardTotalTokens.vue +++ b/src/popup/components/AccountCardTotalTokens.vue @@ -16,9 +16,9 @@