diff --git a/src/antelope/chains/EVMChainSettings.ts b/src/antelope/chains/EVMChainSettings.ts index 5e256bd40..3f5e974b3 100644 --- a/src/antelope/chains/EVMChainSettings.ts +++ b/src/antelope/chains/EVMChainSettings.ts @@ -27,13 +27,17 @@ import { IndexerAccountNftsFilter, IndexerAccountNftsResponse, GenericIndexerNft, - IndexerNftContract, + IndexerContract, NftRawData, IndexerCollectionNftsResponse, Erc721Nft, getErc721Owner, Erc1155Nft, AntelopeError, + IndexerAllowanceFilter, + IndexerAllowanceResponseErc20, + IndexerAllowanceResponseErc721, + IndexerAllowanceResponseErc1155, getErc1155OwnersFromIndexer, } from 'src/antelope/types'; import EvmContract from 'src/antelope/stores/utils/contracts/EvmContract'; @@ -378,12 +382,12 @@ export default abstract class EVMChainSettings implements ChainSettings { imageCache: nftResponse.imageCache, tokenUri: nftResponse.tokenUri, supply: nftResponse.supply, + owner: nftResponse.owner, })); // we fix the supportedInterfaces property if it is undefined in the response but present in the request Object.values(response.contracts).forEach((contract) => { - contract.supportedInterfaces = contract.supportedInterfaces || - params.type ? [params.type?.toLowerCase() as string] : undefined; + contract.supportedInterfaces = contract.supportedInterfaces || (params.type ? [params.type.toLowerCase()] : undefined); }); this.processNftContractsCalldata(response.contracts); @@ -431,7 +435,7 @@ export default abstract class EVMChainSettings implements ChainSettings { } // ensure NFT contract calldata is an object - processNftContractsCalldata(contracts: Record) { + processNftContractsCalldata(contracts: Record) { for (const contract of Object.values(contracts)) { try { contract.calldata = typeof contract.calldata === 'string' ? JSON.parse(contract.calldata) : contract.calldata; @@ -444,7 +448,7 @@ export default abstract class EVMChainSettings implements ChainSettings { // shape the raw data from the indexer into a format that can be used to construct NFTs shapeNftRawData( raw: GenericIndexerNft[], - contracts: Record, + contracts: Record, ): NftRawData[] { const shaped = [] as NftRawData[]; for (const item_source of raw) { @@ -718,4 +722,36 @@ export default abstract class EVMChainSettings implements ChainSettings { return response.result as EvmBlockData; }); } + + // allowances + + async fetchErc20Allowances(account: string, filter: IndexerAllowanceFilter): Promise { + const params = { + ...filter, + type: 'erc20', + all: true, + }; + const response = await this.indexer.get(`v1/account/${account}/approvals`, { params }); + return response.data as IndexerAllowanceResponseErc20; + } + + async fetchErc721Allowances(account: string, filter: IndexerAllowanceFilter): Promise { + const params = { + ...filter, + type: 'erc721', + all: true, + }; + const response = await this.indexer.get(`v1/account/${account}/approvals`, { params }); + return response.data as IndexerAllowanceResponseErc721; + } + + async fetchErc1155Allowances(account: string, filter: IndexerAllowanceFilter): Promise { + const params = { + ...filter, + type: 'erc1155', + all: true, + }; + const response = await this.indexer.get(`v1/account/${account}/approvals`, { params }); + return response.data as IndexerAllowanceResponseErc1155; + } } diff --git a/src/antelope/chains/chain-constants.ts b/src/antelope/chains/chain-constants.ts index 6822ad20b..f77fabd88 100644 --- a/src/antelope/chains/chain-constants.ts +++ b/src/antelope/chains/chain-constants.ts @@ -15,3 +15,5 @@ export const TELOS_ANALYTICS_EVENT_IDS = { loginFailedWalletConnect: '9V4IV1BV', loginSuccessfulWalletConnect: '2EG2OR3H', }; + +export const ZERO_ADDRESS = '0x'.concat('0'.repeat(40)); diff --git a/src/antelope/index.ts b/src/antelope/index.ts index f7c27e6d9..5252cdd2a 100644 --- a/src/antelope/index.ts +++ b/src/antelope/index.ts @@ -10,6 +10,7 @@ import { ChainModel } from 'src/antelope/stores/chain'; import { useAccountStore, + useAllowancesStore, useBalancesStore, useChainStore, useContractStore, @@ -90,20 +91,21 @@ export class Antelope { get stores() { return { - user: useUserStore(), - chain: useChainStore(), account: useAccountStore(), + allowances: useAllowancesStore(), + balances: useBalancesStore(), + chain: useChainStore(), + contract: useContractStore(), + evm: useEVMStore(), + feedback: useFeedbackStore(), + history: useHistoryStore(), + nfts: useNftsStore(), + platform: usePlatformStore(), profile: useProfileStore(), resources: useResourcesStore(), rex: useRexStore(), tokens: useTokensStore(), - contract: useContractStore(), - balances: useBalancesStore(), - history: useHistoryStore(), - feedback: useFeedbackStore(), - platform: usePlatformStore(), - evm: useEVMStore(), - nfts: useNftsStore(), + user: useUserStore(), }; } @@ -128,19 +130,20 @@ export const installAntelope = (app: App) => { }; export { useAccountStore } from 'src/antelope/stores/account'; +export { useAllowancesStore } from 'src/antelope/stores/allowances'; +export { useBalancesStore } from 'src/antelope/stores/balances'; export { useChainStore } from 'src/antelope/stores/chain'; -export { useUserStore } from 'src/antelope/stores/user'; +export { useContractStore } from 'src/antelope/stores/contract'; +export { useEVMStore } from 'src/antelope/stores/evm'; +export { useFeedbackStore } from 'src/antelope/stores/feedback'; +export { useHistoryStore } from 'src/antelope/stores/history'; +export { useNftsStore } from 'src/antelope/stores/nfts'; +export { usePlatformStore } from 'src/antelope/stores/platform'; export { useProfileStore } from 'src/antelope/stores/profile'; export { useResourcesStore } from 'src/antelope/stores/resources'; export { useRexStore } from 'src/antelope/stores/rex'; export { useTokensStore } from 'src/antelope/stores/tokens'; -export { useContractStore } from 'src/antelope/stores/contract'; -export { useBalancesStore } from 'src/antelope/stores/balances'; -export { useHistoryStore } from 'src/antelope/stores/history'; -export { useFeedbackStore } from 'src/antelope/stores/feedback'; -export { usePlatformStore } from 'src/antelope/stores/platform'; -export { useEVMStore } from 'src/antelope/stores/evm'; -export { useNftsStore } from 'src/antelope/stores/nfts'; +export { useUserStore } from 'src/antelope/stores/user'; // this constant is used for a temporal workaround for the multi-context issue // https://github.com/telosnetwork/telos-wallet/issues/582 diff --git a/src/antelope/stores/account.ts b/src/antelope/stores/account.ts index 1ef84f283..ab2824ab1 100644 --- a/src/antelope/stores/account.ts +++ b/src/antelope/stores/account.ts @@ -20,6 +20,7 @@ import { createInitFunction, createTraceFunction } from 'src/antelope/stores/fee import { initFuelUserWrapper } from 'src/api/fuel'; import { CURRENT_CONTEXT, + useAllowancesStore, useBalancesStore, useFeedbackStore, useHistoryStore, @@ -213,6 +214,7 @@ export const useAccountStore = defineStore(store_name, { useHistoryStore().clearEvmNftTransfers(); useBalancesStore().clearBalances(); useNftsStore().clearNFTs(); + useAllowancesStore().clearAllowances(); try { localStorage.removeItem('network'); diff --git a/src/antelope/stores/allowances.ts b/src/antelope/stores/allowances.ts new file mode 100644 index 000000000..91dbcd122 --- /dev/null +++ b/src/antelope/stores/allowances.ts @@ -0,0 +1,683 @@ +import { defineStore } from 'pinia'; +import { filter } from 'rxjs'; +import { formatUnits } from 'ethers/lib/utils'; +import { BigNumber } from 'ethers'; + +import { + CURRENT_CONTEXT, + getAntelope, + useAccountStore, + useChainStore, + useContractStore, + useFeedbackStore, + useNftsStore, + useTokensStore, +} from 'src/antelope'; +import { + AntelopeError, + IndexerAllowanceResponse, + IndexerAllowanceResponseErc1155, + IndexerAllowanceResponseErc20, + IndexerAllowanceResponseErc721, + IndexerErc1155AllowanceResult, + IndexerErc20AllowanceResult, + IndexerErc721AllowanceResult, + Label, + ShapedAllowanceRow, + ShapedAllowanceRowERC20, + ShapedAllowanceRowNftCollection, + ShapedAllowanceRowSingleERC721, + ShapedCollectionAllowanceRow, + Sort, + TransactionResponse, + isErc20AllowanceRow, + isErc721SingleAllowanceRow, + isNftCollectionAllowanceRow, +} from 'src/antelope/types'; +import { createTraceFunction, isTracingAll } from 'src/antelope/stores/feedback'; +import EVMChainSettings from 'src/antelope/chains/EVMChainSettings'; +import { ZERO_ADDRESS } from 'src/antelope/chains/chain-constants'; +import { WriteContractResult } from '@wagmi/core'; +import { AccountModel, EvmAccountModel } from 'src/antelope/stores/account'; +import { subscribeForTransactionReceipt } from 'src/antelope/stores/utils/trx-utils'; + +const store_name = 'allowances'; + +const ALLOWANCES_LIMIT = 10000; + +function sortAllowanceRowsByCollection(a: ShapedCollectionAllowanceRow, b: ShapedCollectionAllowanceRow, order: Sort): number { + const aContractString = a?.collectionName ?? a.collectionAddress; + const bContractString = b?.collectionName ?? b.collectionAddress; + return order === Sort.ascending ? aContractString.localeCompare(bContractString) : bContractString.localeCompare(aContractString); +} + +function filterCancelledAllowances(includeCancelled: boolean, row: ShapedAllowanceRow): boolean { + if (includeCancelled) { + return true; + } + + return isErc20AllowanceRow(row) ? row.allowance.gt(0) : row.allowed; +} + +export interface AllowancesState { + __erc_20_allowances: { [label: Label]: ShapedAllowanceRowERC20[] }; + __erc_721_allowances: { [label: Label]: (ShapedAllowanceRowNftCollection | ShapedAllowanceRowSingleERC721)[] }; + __erc_1155_allowances: { [label: Label]: ShapedAllowanceRowNftCollection[] }; +} + +export const useAllowancesStore = defineStore(store_name, { + state: (): AllowancesState => allowancesInitialState, + getters: { + allowances: state => (label: Label): ShapedAllowanceRow[] => ((state.__erc_20_allowances[label] ?? []) as ShapedAllowanceRow[]) + .concat(state.__erc_721_allowances[label] ?? []) + .concat(state.__erc_1155_allowances[label] ?? []), + nonErc20Allowances: state => (label: Label): ShapedCollectionAllowanceRow[] => ((state.__erc_1155_allowances[label] ?? []) as ShapedCollectionAllowanceRow[]).concat(state.__erc_721_allowances[label] ?? []), + singleErc721Allowances: state => (label: Label): ShapedAllowanceRowSingleERC721[] => (state.__erc_721_allowances[label] ?? []).filter(allowance => isErc721SingleAllowanceRow(allowance)) as ShapedAllowanceRowSingleERC721[], + allowancesSortedByAssetQuantity: () => (label: Label, order: Sort, includeCancelled: boolean): ShapedAllowanceRow[] => useAllowancesStore().allowances(label).sort((a, b) => { + let quantityA: number; + let quantityB: number; + + if (isErc20AllowanceRow(a)) { + quantityA = Number(formatUnits(a.balance, a.tokenDecimals)); + } else if (isErc721SingleAllowanceRow(a)) { + quantityA = 1; + } else { + quantityA = a.balance.toNumber(); + } + + if (isErc20AllowanceRow(b)) { + quantityB = Number(formatUnits(b.balance, b.tokenDecimals)); + } else if (isErc721SingleAllowanceRow(b)) { + quantityB = 1; + } else { + quantityB = b.balance.toNumber(); + } + + return order === Sort.ascending ? quantityA - quantityB : quantityB - quantityA; + }).filter(row => filterCancelledAllowances(includeCancelled, row)), + allowancesSortedByAllowanceFiatValue: state => (label: Label, order: Sort, includeCancelled: boolean): ShapedAllowanceRow[] => { + const erc20WithFiatValue = state.__erc_20_allowances[label].filter(allowance => allowance.tokenPrice) + .sort((a, b) => order === Sort.ascending ? a.tokenPrice - b.tokenPrice : b.tokenPrice - a.tokenPrice) + .filter(row => filterCancelledAllowances(includeCancelled, row)); + const erc20WithoutFiatValue = state.__erc_20_allowances[label] + .filter(allowance => !allowance.tokenPrice && filterCancelledAllowances(includeCancelled, allowance)); + const rowsWithoutFiatValue = (state.__erc_721_allowances[label] + .concat(state.__erc_1155_allowances[label]) as ShapedAllowanceRow[]) + .concat(erc20WithoutFiatValue) + .sort((a, b) => (a.spenderName ?? a.spenderAddress).localeCompare(b.spenderName ?? b.spenderAddress)) + .filter(row => filterCancelledAllowances(includeCancelled, row)); + + return order === Sort.descending ? [...erc20WithFiatValue, ...rowsWithoutFiatValue] : [...rowsWithoutFiatValue, ...erc20WithFiatValue]; + }, + allowancesSortedByAllowanceAmount: state => (label: Label, order: Sort, includeCancelled: boolean): ShapedAllowanceRow[] => { + /* + Sort order: + 1. assets with allowances which are allowed (ERC721 collections, single ERC721s, and ERC1155 collections) - secondary sort descending by contract name or address + 2. assets with numerical allowances (ERC20s) - secondary sort descending by numerical allowance amount + 3. assets with allowances which are not allowed (ERC721 collections, single ERC721s, and ERC1155 collections) - secondary sort descending by contract name or address + */ + const nonErc20Allowances = useAllowancesStore().nonErc20Allowances(label); + + const erc20AllowancesSorted = state.__erc_20_allowances[label] + .sort((a, b) => { + const normalizedAAllowance = Number(formatUnits(a.allowance, a.tokenDecimals)); + const normalizedBAllowance = Number(formatUnits(b.allowance, b.tokenDecimals)); + + return order === Sort.ascending ? normalizedAAllowance - normalizedBAllowance : normalizedBAllowance - normalizedAAllowance; + }) + .filter(row => filterCancelledAllowances(includeCancelled, row)); + + const allowedAllowancesSorted = nonErc20Allowances + .filter(allowance => allowance.allowed) + .sort((a, b) => sortAllowanceRowsByCollection(a, b, order)); + + const notAllowedAllowancesSorted = includeCancelled ? nonErc20Allowances + .filter(allowance => !allowance.allowed) + .sort((a, b) => sortAllowanceRowsByCollection(a, b, order)) : []; + + return [ + ...allowedAllowancesSorted, + ...erc20AllowancesSorted, + ...notAllowedAllowancesSorted, + ]; + }, + allowancesSortedBySpender: () => (label: Label, order: Sort, includeCancelled: boolean): ShapedAllowanceRow[] => { + const allAllowances = useAllowancesStore().allowances(label); + const allowancesWithSpenderName = allAllowances + .filter(allowance => allowance.spenderName && filterCancelledAllowances(includeCancelled, allowance)); + const allowancesWithoutSpenderName = allAllowances + .filter(allowance => !allowance.spenderName && filterCancelledAllowances(includeCancelled, allowance)); + + const sortedAllowancesWithSpenderName = allowancesWithSpenderName.sort((a, b) => { + const aSpender = a.spenderName as string; + const bSpender = b.spenderName as string; + return order === Sort.descending ? aSpender.localeCompare(bSpender) : bSpender.localeCompare(aSpender); + }); + const sortedAllowancesWithoutSpenderName = allowancesWithoutSpenderName.sort((a, b) => order === Sort.ascending ? a.spenderAddress.localeCompare(b.spenderAddress) : b.spenderAddress.localeCompare(a.spenderAddress)); + + return [ + ...sortedAllowancesWithSpenderName, + ...sortedAllowancesWithoutSpenderName, + ]; + }, + allowancesSortedByAssetType: state => (label: Label, order: Sort, includeCancelled: boolean): ShapedAllowanceRow[] => { + // types are Collectible (ERC721/ERC1155) or Token (ERC20) + const erc20Allowances = state.__erc_20_allowances[label] ?? []; + const nonErc20Allowances = useAllowancesStore().nonErc20Allowances(label); + + const tokensSorted = erc20Allowances.sort((a, b) => { + const normalizedAAllowance = Number(formatUnits(a.allowance, a.tokenDecimals)); + const normalizedBAllowance = Number(formatUnits(b.allowance, b.tokenDecimals)); + + return normalizedAAllowance - normalizedBAllowance; + }).filter(row => filterCancelledAllowances(includeCancelled, row)); + + const allowedAllowancesSorted = nonErc20Allowances + .filter(allowance => allowance.allowed) + .sort((a, b) => sortAllowanceRowsByCollection(a, b, Sort.ascending)); + + const notAllowedAllowancesSorted = includeCancelled ? nonErc20Allowances + .filter(allowance => !allowance.allowed) + .sort((a, b) => sortAllowanceRowsByCollection(a, b, Sort.ascending)) : []; + + const collectiblesSorted = [ + ...allowedAllowancesSorted, + ...notAllowedAllowancesSorted, + ]; + + return order === Sort.ascending ? [...collectiblesSorted, ...tokensSorted] : [...tokensSorted, ...collectiblesSorted]; + }, + allowancesSortedByLastUpdated: () => (label: Label, order: Sort, includeCancelled: boolean): ShapedAllowanceRow[] => useAllowancesStore().allowances(label) + .sort((a, b) => order === Sort.ascending ? a.lastUpdated - b.lastUpdated : b.lastUpdated - a.lastUpdated) + .filter(row => filterCancelledAllowances(includeCancelled, row)), + getAllowance: () => (label: Label, spenderAddress: string, tokenAddress: string, tokenId?: string): ShapedAllowanceRow | undefined => { + const allowanceStore = useAllowancesStore(); + if (tokenId) { + return allowanceStore.singleErc721Allowances(label).find(allowance => + allowance.spenderAddress.toLowerCase() === spenderAddress.toLowerCase() && + allowance.collectionAddress.toLowerCase() === tokenAddress.toLowerCase() && + allowance.tokenId.toLowerCase() === tokenId.toLowerCase(), + ); + } + + return allowanceStore.allowances(label).find((allowance) => { + const spenderAddressMatches = allowance.spenderAddress.toLowerCase() === spenderAddress.toLowerCase(); + if (isErc20AllowanceRow(allowance)) { + return spenderAddressMatches && allowance.tokenAddress.toLowerCase() === tokenAddress.toLowerCase(); + } + + return spenderAddressMatches && allowance.collectionAddress.toLowerCase() === tokenAddress.toLowerCase(); + }); + }, + }, + actions: { + trace: createTraceFunction(store_name), + init: () => { + const allowancesStore = useAllowancesStore(); + useFeedbackStore().setDebug(store_name, isTracingAll()); + + getAntelope().events.onAccountChanged.pipe( + filter(({ label, account }) => !!label && !!account), + ).subscribe({ + next: ({ label, account }) => { + if (label === CURRENT_CONTEXT && account?.account) { + allowancesStore.fetchAllowancesForAccount(account?.account); + } + }, + }); + }, + + // actions + async fetchAllowancesForAccount(account: string): Promise { + this.trace('fetchAllowancesForAccount', account); + useFeedbackStore().setLoading('fetchAllowancesForAccount'); + + const chainSettings = useChainStore().currentChain.settings as EVMChainSettings; + + const erc20AllowancesPromise = chainSettings.fetchErc20Allowances(account, { limit: ALLOWANCES_LIMIT }); + const erc721AllowancesPromise = chainSettings.fetchErc721Allowances(account, { limit: ALLOWANCES_LIMIT }); + const erc1155AllowancesPromise = chainSettings.fetchErc1155Allowances(account, { limit: ALLOWANCES_LIMIT }); + + let allowancesResults: IndexerAllowanceResponse[]; + + try { + allowancesResults = await Promise.all([erc20AllowancesPromise, erc721AllowancesPromise, erc1155AllowancesPromise]); + } catch (e) { + console.error('Error fetching allowances', e); + useFeedbackStore().unsetLoading('fetchAllowancesForAccount'); + throw new AntelopeError('antelope.allowances.error_fetching_allowances'); + } + + const erc20AllowancesData = (allowancesResults[0] as IndexerAllowanceResponseErc20)?.results ?? []; + const erc721AllowancesData = (allowancesResults[1] as IndexerAllowanceResponseErc721)?.results ?? []; + const erc1155AllowancesData = (allowancesResults[2] as IndexerAllowanceResponseErc1155)?.results ?? []; + + const shapedErc20AllowanceRowPromises = Promise.allSettled(erc20AllowancesData.map(allowanceData => this.shapeErc20AllowanceRow(allowanceData))); + const shapedErc721AllowanceRowPromises = Promise.allSettled(erc721AllowancesData.map(allowanceData => this.shapeErc721AllowanceRow(allowanceData))); + const shapedErc1155AllowanceRowPromises = Promise.allSettled(erc1155AllowancesData.map(allowanceData => this.shapeErc1155AllowanceRow(allowanceData))); + + const [settledErc20Results, settledErc721Results, settledErc1155Results] = await Promise.allSettled([ + shapedErc20AllowanceRowPromises, + shapedErc721AllowanceRowPromises, + shapedErc1155AllowanceRowPromises, + ]); + + if (settledErc20Results.status === 'fulfilled') { + const shapedErc20Rows: ShapedAllowanceRowERC20[] = []; + + settledErc20Results.value.forEach((result) => { + if (result.status === 'fulfilled') { + result.value && shapedErc20Rows.push(result.value); + } else { + console.error('Error shaping ERC20 allowance row', result.reason); + } + }); + + this.setErc20Allowances(CURRENT_CONTEXT, shapedErc20Rows); + } else { + console.error('Error shaping ERC20 allowance rows', settledErc20Results.reason); + } + + if (settledErc721Results.status === 'fulfilled') { + const shapedErc721Rows: (ShapedAllowanceRowSingleERC721 | ShapedAllowanceRowNftCollection)[] = []; + + settledErc721Results.value.forEach((result) => { + if (result.status === 'fulfilled') { + result.value && shapedErc721Rows.push(result.value); + } else { + console.error('Error shaping ERC721 allowance row', result.reason); + } + }); + + this.setErc721Allowances(CURRENT_CONTEXT, shapedErc721Rows); + } else { + console.error('Error shaping ERC721 allowance rows', settledErc721Results.reason); + } + + if (settledErc1155Results.status === 'fulfilled') { + const shapedErc1155Rows: ShapedAllowanceRowNftCollection[] = []; + + settledErc1155Results.value.forEach((result) => { + if (result.status === 'fulfilled') { + result.value && shapedErc1155Rows.push(result.value); + } else { + console.error('Error shaping ERC1155 allowance row', result.reason); + } + }); + + this.setErc1155Allowances(CURRENT_CONTEXT, shapedErc1155Rows); + } else { + console.error('Error shaping ERC1155 allowance rows', settledErc1155Results.reason); + } + + useFeedbackStore().unsetLoading('fetchAllowancesForAccount'); + + return Promise.resolve(); + }, + async updateErc20Allowance( + owner: string, + spender: string, + tokenContractAddress: string, + allowance: BigNumber, + ): Promise { + this.trace('updateErc20Allowance', spender, tokenContractAddress, allowance); + useFeedbackStore().setLoading('updateErc20Allowance'); + + try { + const authenticator = useAccountStore().getEVMAuthenticator(CURRENT_CONTEXT); + + const tx = await authenticator.updateErc20Allowance(spender, tokenContractAddress, allowance) as TransactionResponse; + const account = useAccountStore().loggedAccount as EvmAccountModel; + + const returnTx = this.subscribeForTransactionReceipt(account, tx); + + returnTx.then((r) => { + r.wait().finally(() => { + useFeedbackStore().unsetLoading('updateErc20Allowance'); + }); + }); + + return returnTx; + } catch(error) { + const trxError = getAntelope().config.transactionError('antelope.evm.error_updating_allowance', error); + getAntelope().config.transactionErrorHandler(trxError, 'updateErc20Allowance'); + useFeedbackStore().unsetLoading('updateErc20Allowance'); + throw trxError; + } + }, + async updateSingleErc721Allowance( + owner: string, + operator: string, + nftContractAddress: string, + tokenId: string, + allowed: boolean, + ): Promise { + this.trace('updateSingleErc721Allowance', operator, nftContractAddress, allowed); + useFeedbackStore().setLoading('updateSingleErc721Allowance'); + + try { + // note: there can only be one operator for a single ERC721 token ID + // to revoke an allowance, the approve method is called with an operator address of '0x0000...0000' + const newOperator = allowed ? operator : ZERO_ADDRESS; + const authenticator = useAccountStore().getEVMAuthenticator(CURRENT_CONTEXT); + + const tx = await authenticator.updateSingleErc721Allowance(newOperator, nftContractAddress, tokenId) as TransactionResponse; + + const account = useAccountStore().loggedAccount as EvmAccountModel; + + const returnTx = this.subscribeForTransactionReceipt(account, tx); + + returnTx.then((r) => { + r.wait().finally(() => { + useFeedbackStore().unsetLoading('updateSingleErc721Allowance'); + }); + }); + + return returnTx; + } catch (error) { + const trxError = getAntelope().config.transactionError('antelope.evm.error_updating_allowance', error); + getAntelope().config.transactionErrorHandler(trxError, 'updateSingleErc721Allowance'); + useFeedbackStore().unsetLoading('updateSingleErc721Allowance'); + throw trxError; + } + }, + // this method is used for both ERC721 and ERC1155 collections + async updateNftCollectionAllowance( + owner: string, + operator: string, + nftContractAddress: string, + allowed: boolean, + ): Promise { + this.trace('updateNftCollectionAllowance', operator, nftContractAddress, allowed); + useFeedbackStore().setLoading('updateNftCollectionAllowance'); + + try { + const authenticator = useAccountStore().getEVMAuthenticator(CURRENT_CONTEXT); + const tx = await authenticator.updateNftCollectionAllowance(operator, nftContractAddress, allowed) as TransactionResponse; + + const account = useAccountStore().loggedAccount as EvmAccountModel; + + const returnTx = this.subscribeForTransactionReceipt(account, tx); + + returnTx.then((r) => { + r.wait().finally(() => { + useFeedbackStore().unsetLoading('updateNftCollectionAllowance'); + }); + }); + + return returnTx; + } catch (error) { + const trxError = getAntelope().config.transactionError('antelope.evm.error_updating_allowance', error); + getAntelope().config.transactionErrorHandler(trxError, 'updateNftCollectionAllowance'); + useFeedbackStore().unsetLoading('updateNftCollectionAllowance'); + throw trxError; + } + }, + batchRevokeAllowances( + allowanceIdentifiers: string[], + owner: string, + revokeCompletedHandler: (tx: TransactionResponse | null, remaining: number) => void, + ): { + promise: Promise, + cancelToken: { isCancelled: boolean, cancel: () => void }, + } { + this.trace('batchRevokeAllowances', allowanceIdentifiers, owner); + useFeedbackStore().setLoading('batchRevokeAllowances'); + + // allowanceIdentifiers are keyed like: `${row.spenderAddress}-${tokenAddress/collectionAddress}${ isSingleErc721 ? `-${tokenId}` : ''}` + const allowanceIdentifiersAreValid = allowanceIdentifiers.every((allowanceIdentifier) => { + const [spenderAddress, tokenAddress] = allowanceIdentifier.split('-'); + + return spenderAddress && tokenAddress; + }); + + if (!allowanceIdentifiersAreValid) { + useFeedbackStore().unsetLoading('batchRevokeAllowances'); + throw new Error('Invalid allowance identifiers'); + } + + const cancelToken = { + isCancelled: false, + cancel() { + this.isCancelled = true; + }, + }; + + // A helper function to execute tasks in succession + async function revokeAllowancesSequentially(identifiers: string[]) { + for (const [index, allowanceIdentifier] of identifiers.entries()) { + if (cancelToken.isCancelled) { + useFeedbackStore().unsetLoading('batchRevokeAllowances'); + throw new Error('Operation cancelled by user'); + } + + const [spenderAddress, tokenAddress, tokenId] = allowanceIdentifier.split('-'); + const allowanceInfo = useAllowancesStore().getAllowance(CURRENT_CONTEXT, spenderAddress, tokenAddress, tokenId || undefined); + + if (!allowanceInfo) { + useFeedbackStore().unsetLoading('batchRevokeAllowances'); + throw new Error('Allowance not found'); + } + + const isErc20Allowance = isErc20AllowanceRow(allowanceInfo); + const isSingleErc721Allowance = isErc721SingleAllowanceRow(allowanceInfo); + const isCollectionAllowance = isNftCollectionAllowanceRow(allowanceInfo); + + const isAlreadyRevoked = + (isErc20Allowance && allowanceInfo.allowance.eq(0)) || + ((isSingleErc721Allowance || isCollectionAllowance) && !allowanceInfo.allowed); + + // if the allowance is already revoked, skip it + if (isAlreadyRevoked) { + revokeCompletedHandler(null, identifiers.length - (index + 1)); + continue; + } + + let tx: TransactionResponse | WriteContractResult; + + try { + if (isErc20Allowance) { + tx = await useAllowancesStore().updateErc20Allowance( + owner, + allowanceInfo.spenderAddress, + allowanceInfo.tokenAddress, + BigNumber.from(0), + ); + } else if (isSingleErc721Allowance) { + tx = await useAllowancesStore().updateSingleErc721Allowance( + owner, + allowanceInfo.spenderAddress, + allowanceInfo.collectionAddress, + allowanceInfo.tokenId, + false, + ); + } else { + tx = await useAllowancesStore().updateNftCollectionAllowance( + owner, + allowanceInfo.spenderAddress, + allowanceInfo.collectionAddress, + false, + ); + } + + const { newResponse } = await subscribeForTransactionReceipt(useAccountStore().loggedAccount as AccountModel, tx); + await newResponse.wait(); + + revokeCompletedHandler(tx, identifiers.length - (index + 1)); + } catch (error) { + useFeedbackStore().unsetLoading('batchRevokeAllowances'); + console.error('Error cancelling allowance', error); + throw error; + } + } + + useFeedbackStore().unsetLoading('batchRevokeAllowances'); + + return Promise.resolve(); + } + + // Return the cancel token and the promise representing the task completion + return { + cancelToken, + promise: revokeAllowancesSequentially(allowanceIdentifiers), + }; + }, + + // commits + setErc20Allowances(label: Label, allowances: ShapedAllowanceRowERC20[]) { + this.trace('setErc20Allowances', allowances); + this.__erc_20_allowances[label] = allowances; + }, + setErc721Allowances(label: Label, allowances: (ShapedAllowanceRowNftCollection | ShapedAllowanceRowSingleERC721)[]) { + this.trace('setErc721Allowances', allowances); + this.__erc_721_allowances[label] = allowances; + }, + setErc1155Allowances(label: Label, allowances: ShapedAllowanceRowNftCollection[]) { + this.trace('setErc1155Allowances', allowances); + this.__erc_1155_allowances[label] = allowances; + }, + + // utils + clearAllowances() { + this.trace('clearAllowances'); + this.__erc_20_allowances = {}; + this.__erc_721_allowances = {}; + this.__erc_1155_allowances = {}; + }, + async shapeErc20AllowanceRow(data: IndexerErc20AllowanceResult): Promise { + try { + const spenderContract = await useContractStore().getContract(CURRENT_CONTEXT, data.spender); + const tokenInfo = useTokensStore().__tokens[CURRENT_CONTEXT].find(token => token.address.toLowerCase() === data.contract.toLowerCase()); + + const tokenContract = await useContractStore().getContract(CURRENT_CONTEXT, data.contract); + const tokenContractInstance = await tokenContract?.getContractInstance(); + const maxSupply = await tokenContractInstance?.totalSupply() as BigNumber | undefined; + const balance = await tokenContractInstance?.balanceOf(data.owner) as BigNumber | undefined; + + if (!balance || !tokenInfo || !maxSupply) { + return null; + } + + return { + lastUpdated: data.updated, + spenderAddress: data.spender, + spenderName: spenderContract?.name, + tokenName: tokenInfo.name, + tokenAddress: data.contract, + allowance: BigNumber.from(data.amount), + balance, + tokenDecimals: tokenInfo.decimals, + tokenMaxSupply: maxSupply, + tokenSymbol: tokenInfo.symbol, + tokenPrice: Number(tokenInfo.price.str), + tokenLogo: tokenInfo.logo, + }; + } catch (e) { + console.error('Error shaping ERC20 allowance row', e); + return null; + } + }, + async shapeErc721AllowanceRow(data: IndexerErc721AllowanceResult): Promise { + // if the operator is the zero address, it means the allowance has been revoked; + // we should hide it from the UI rather than showing it with operator '0x0000...0000' + if (data.operator === ZERO_ADDRESS) { + return null; + } + + try { + const operatorContract = await useContractStore().getContract(CURRENT_CONTEXT, data.operator); + + const commonAttributes = { + lastUpdated: data.updated, + spenderAddress: data.operator, + spenderName: operatorContract?.name, + allowed: data.approved, + }; + + if (data.single) { + const tokenId = String(data.tokenId); + const nftDetails = await useNftsStore().fetchNftDetails(CURRENT_CONTEXT, data.contract, tokenId); + + return nftDetails ? { + ...commonAttributes, + tokenId, + tokenName: nftDetails.name, + collectionAddress: nftDetails.contractAddress, + collectionName: nftDetails.contractPrettyName, + } : null; + } + + const collectionInfo = await useContractStore().getContract(CURRENT_CONTEXT, data.contract); + const balance = await (await collectionInfo?.getContractInstance())?.balanceOf(data.owner); + + return collectionInfo ? { + ...commonAttributes, + collectionAddress: collectionInfo.address, + collectionName: collectionInfo.name, + balance, + } : null; + } catch(e) { + console.error('Error shaping ERC721 allowance row', e); + return null; + } + }, + async shapeErc1155AllowanceRow(data: IndexerErc1155AllowanceResult): Promise { + try { + const network = useChainStore().getChain(CURRENT_CONTEXT).settings.getNetwork(); + const nftsStore = useNftsStore(); + + const operatorContract = await useContractStore().getContract(CURRENT_CONTEXT, data.operator); + const collectionInfo = await useContractStore().getContract(CURRENT_CONTEXT, data.contract); + await nftsStore.fetchNftsFromCollection(CURRENT_CONTEXT, data.contract); + const collectionNftIds = (nftsStore.__contracts[network][data.contract.toLowerCase()]?.list ?? []).map(nft => nft.id); + + if (collectionNftIds.length === 0) { + console.error(`Collection ${data.contract} has no NFTs`); + + return null; + } + + const balancePromises = collectionNftIds.map(async (tokenId) => { + const contractInstance = await collectionInfo?.getContractInstance(); + return contractInstance?.balanceOf(data.owner, tokenId) as BigNumber; + }); + + + const balancesOfAllIdsInCollection = await Promise.all(balancePromises); + const balance = balancesOfAllIdsInCollection.reduce((acc, balance) => acc.add(balance ?? 0), BigNumber.from(0)); + + return collectionInfo ? { + lastUpdated: data.updated, + spenderAddress: data.operator, + spenderName: operatorContract?.name, + allowed: data.approved, + collectionAddress: collectionInfo.address, + collectionName: collectionInfo.name, + balance, + } : null; + } catch(e) { + console.error('Error shaping ERC1155 allowance row', e); + return null; + } + }, + async subscribeForTransactionReceipt(account: AccountModel, response: TransactionResponse): Promise { + this.trace('subscribeForTransactionReceipt', account.account, response.hash); + return subscribeForTransactionReceipt(account, response).then(({ newResponse, receipt }) => { + newResponse.wait().then(() => { + this.trace('subscribeForTransactionReceipt', newResponse.hash, 'receipt:', receipt.status, receipt); + setTimeout(() => { + useAllowancesStore().fetchAllowancesForAccount(account.account); + }, 3000); // give the indexer time to update allowance data + }); + return newResponse; + }); + }, + }, +}); + + +const allowancesInitialState: AllowancesState = { + __erc_20_allowances: {}, + __erc_721_allowances: {}, + __erc_1155_allowances: {}, +}; diff --git a/src/antelope/stores/contract.ts b/src/antelope/stores/contract.ts index 19272b119..01d969482 100644 --- a/src/antelope/stores/contract.ts +++ b/src/antelope/stores/contract.ts @@ -69,6 +69,10 @@ export interface ContractStoreState { processing: Record> }, } + // addresses which have been checked and are known not to be contract addresses + __accounts: { + [network: string]: string[], + }, } const store_name = 'contract'; @@ -187,6 +191,13 @@ export const useContractStore = defineStore(store_name, { return this.__contracts[network].cached[addressLower]; } + const isContract = await this.addressIsContract(network, address); + + if (!isContract) { + // address is an account, not a contract + return null; + } + // if we have the metadata, we can create the contract and return it if (typeof this.__contracts[network].metadata[addressLower] !== 'undefined') { const metadata = this.__contracts[network].metadata[addressLower] as EvmContractFactoryData; @@ -258,7 +269,7 @@ export const useContractStore = defineStore(store_name, { if (metadata && creationInfo) { this.trace('fetchContractUsingHyperion', 'returning verified contract', address, metadata, creationInfo); - return resolve(this.createAndStoreVerifiedContract(label, addressLower, metadata, creationInfo, suspectedToken)); + return resolve(await this.createAndStoreVerifiedContract(label, addressLower, metadata, creationInfo, suspectedToken)); } const tokenContract = await this.createAndStoreContractFromTokenList(label, address, suspectedToken, creationInfo); @@ -275,7 +286,7 @@ export const useContractStore = defineStore(store_name, { if (creationInfo) { this.trace('fetchContractUsingHyperion', 'returning empty contract', address, creationInfo); - return resolve(this.createAndStoreEmptyContract(label, addressLower, creationInfo)); + return resolve(await this.createAndStoreEmptyContract(label, addressLower, creationInfo)); } else { // We mark this address as not existing so we don't query it again this.trace('fetchContractUsingHyperion', 'returning null', address); @@ -417,10 +428,10 @@ export const useContractStore = defineStore(store_name, { metadata: EvmContractMetadata, creationInfo: EvmContractCreationInfo, suspectedType: string, - ): Promise { + ): Promise { this.trace('createAndStoreVerifiedContract', label, address, [metadata], [creationInfo], suspectedType); const token = await this.getToken(label, address, suspectedType) ?? undefined; - return this.createAndStoreContract(label, address, { + return await this.createAndStoreContract(label, address, { name: Object.values(metadata.settings?.compilationTarget ?? {})[0], address, abi: metadata.output?.abi, @@ -442,9 +453,9 @@ export const useContractStore = defineStore(store_name, { label: string, address:string, creationInfo: EvmContractCreationInfo | null, - ): Promise { + ): Promise { this.trace('createAndStoreEmptyContract', label, address, [creationInfo]); - return this.createAndStoreContract(label, address, { + return await this.createAndStoreContract(label, address, { name: `0x${address.slice(0, 16)}...`, address, creationInfo, @@ -513,7 +524,7 @@ export const useContractStore = defineStore(store_name, { }, // commits ----- - createAndStoreContract(label: string, address: string, metadata: EvmContractFactoryData): EvmContract { + async createAndStoreContract(label: string, address: string, metadata: EvmContractFactoryData): Promise { const network = useChainStore().getChain(label).settings.getNetwork(); this.trace('createAndStoreContract', label, network, address, [metadata]); if (!address) { @@ -522,6 +533,14 @@ export const useContractStore = defineStore(store_name, { if (!label) { throw new AntelopeError('antelope.contracts.error_label_required'); } + + const isContract = await this.addressIsContract(network, address); + + if (!isContract) { + // address is an account, not a contract + return null; + } + const index = address.toString().toLowerCase(); // If: @@ -557,10 +576,33 @@ export const useContractStore = defineStore(store_name, { const index = address.toString().toLowerCase(); this.__contracts[network].cached[index] = null; }, + + async addressIsContract(network: string, address: string) { + const addressLower = address.toLowerCase(); + if (!this.__accounts[network]) { + this.__accounts[network] = []; + } + + if (this.__accounts[network].includes(addressLower)) { + return false; + } + + const provider = await getAntelope().wallets.getWeb3Provider(); + const code = await provider.getCode(address); + + const isContract = code !== '0x'; + + if (!isContract && !this.__accounts[network].includes(addressLower)) { + this.__accounts[network].push(addressLower); + } + + return isContract; + }, }, }); const contractInitialState: ContractStoreState = { __contracts: {}, __factory: new EvmContractFactory(), + __accounts: {}, }; diff --git a/src/antelope/stores/nfts.ts b/src/antelope/stores/nfts.ts index 149a48a46..402b7175d 100644 --- a/src/antelope/stores/nfts.ts +++ b/src/antelope/stores/nfts.ts @@ -14,6 +14,7 @@ import { IndexerPaginationFilter, TransactionResponse, addressString, + AntelopeError, } from 'src/antelope/types'; import { useFeedbackStore, getAntelope, useChainStore, useEVMStore, CURRENT_CONTEXT } from 'src/antelope'; @@ -35,6 +36,10 @@ export interface NFTsCollection { contract: Address; list: Collectible[]; loading: boolean; + + // this is to prevent the scenario where we fetch a single NFT from a collection, add it to a contract's `list` + // and then in future checks we assume that the entire collection has been fetched (as we have at least one item in the list) + entireCollectionFetched: boolean; } export interface UserNftFilter { @@ -219,7 +224,15 @@ export const useNftsStore = defineStore(store_name, { // If we already have a contract for that network and contract, we search for the NFT in that list first this.__contracts[network] = this.__contracts[network] || {}; - if (this.__contracts[network][contractLower]) { + + if (this.__contracts[network]?.[contractLower]?.loading) { + let waitCount = 0; + while (this.__contracts[network][contractLower].loading && waitCount++ < 600) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + if (this.__contracts[network]?.[contractLower]?.entireCollectionFetched) { const nft = this.__contracts[network][contractLower].list.find( nft => nft.contractAddress.toLowerCase() === contract.toLowerCase() && nft.id === tokenId, ); @@ -231,6 +244,7 @@ export const useNftsStore = defineStore(store_name, { contract: contractLower, list: [], loading: false, + entireCollectionFetched: false, }; } @@ -249,7 +263,7 @@ export const useNftsStore = defineStore(store_name, { } else { if (!chain.settings.isNative()) { // this means we have the indexer down - // we have the contract and the addres so we try to fetch the NFT from the contract + // we have the contract and the address so we try to fetch the NFT from the contract useEVMStore().getNFT( contract, tokenId, @@ -274,6 +288,55 @@ export const useNftsStore = defineStore(store_name, { return promise; }, + async fetchNftsFromCollection(label: Label, contract: string): Promise { + this.trace('fetchNftsFromCollection', label, contract); + const contractLower = contract.toLowerCase(); + const feedbackStore = useFeedbackStore(); + const chain = useChainStore().getChain(label); + const network = chain.settings.getNetwork(); + + if (this.__contracts[network]?.[contractLower]?.loading) { + let waitCount = 0; + while (this.__contracts[network][contractLower].loading && waitCount++ < 600) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + if (this.__contracts[network]?.[contractLower]?.entireCollectionFetched) { + return Promise.resolve(this.__contracts[network][contractLower].list); + } + + if (!this.__contracts[network]) { + this.__contracts[network] = {}; + } + + if (!this.__contracts[network][contractLower]) { + this.__contracts[network][contractLower] = { + contract, + list: [], + loading: true, + entireCollectionFetched: false, + }; + } + + this.__contracts[network][contractLower].loading = true; + + feedbackStore.setLoading('fetchNftsFromCollection'); + try { + const nfts = await chain.settings.getNftsForCollection(contract, { limit: 10000 }); + this.__contracts[network][contractLower].list = nfts; + this.__contracts[network][contractLower].entireCollectionFetched = true; + + return nfts; + } catch { + this.__contracts[network][contractLower].list = []; + throw new AntelopeError('antelope.nfts.error_fetching_collection_nfts'); + } finally { + feedbackStore.unsetLoading('fetchNftsFromCollection'); + this.__contracts[network][contractLower].loading = false; + } + }, + clearUserFilter() { this.setUserFilter({ collection: '', diff --git a/src/antelope/stores/rex.ts b/src/antelope/stores/rex.ts index 8edfe51f8..fe93dd8f5 100644 --- a/src/antelope/stores/rex.ts +++ b/src/antelope/stores/rex.ts @@ -20,7 +20,7 @@ import { CURRENT_CONTEXT, getAntelope, useBalancesStore, useChainStore, useContr import EVMChainSettings from 'src/antelope/chains/EVMChainSettings'; import { WEI_PRECISION } from 'src/antelope/stores/utils'; import { subscribeForTransactionReceipt } from 'src/antelope/stores/utils/trx-utils'; -import { formatUnstakePeriod } from 'src/antelope/stores/utils/date-utils'; +import { prettyTimePeriod } from 'src/antelope/stores/utils/date-utils'; export interface RexModel { @@ -55,7 +55,7 @@ export const useRexStore = defineStore(store_name, { getEvmRexData: state => (label: string) => state.__rexData[label] as EvmRexModel, getNativeRexData: state => (label: string) => state.__rexData[label] as NativeRexModel, getUnstakingPeriodString: state => (label: string) => - formatUnstakePeriod( + prettyTimePeriod( // period for the label network state.__rexData[label]?.period ?? null, // translation function only takes the key name, without the path and adds the prefix diff --git a/src/antelope/stores/utils/abi/erc20.ts b/src/antelope/stores/utils/abi/erc20.ts index 71b3d0ea7..f97efb36b 100644 --- a/src/antelope/stores/utils/abi/erc20.ts +++ b/src/antelope/stores/utils/abi/erc20.ts @@ -223,4 +223,29 @@ export const erc20Abi = [ 'name': 'Approval', 'type': 'event', }, -] as EvmABI; +] as EvmABI; + +export const erc20AbiApprove = [{ + 'inputs': [ + { + 'internalType': 'address', + 'name': 'spender', + 'type': 'address', + }, + { + 'internalType': 'uint256', + 'name': 'amount', + 'type': 'uint256', + }, + ], + 'name': 'approve', + 'outputs': [ + { + 'internalType': 'bool', + 'name': '', + 'type': 'bool', + }, + ], + 'stateMutability': 'nonpayable', + 'type': 'function', +}] as EvmABI; diff --git a/src/antelope/stores/utils/abi/erc721.ts b/src/antelope/stores/utils/abi/erc721.ts index 094bc3300..10a3dfc2c 100644 --- a/src/antelope/stores/utils/abi/erc721.ts +++ b/src/antelope/stores/utils/abi/erc721.ts @@ -354,3 +354,16 @@ export const erc721Abi = [{ 'stateMutability': 'nonpayable', 'type': 'function', }] as EvmABI; + +export const erc721ApproveAbi = [{ + 'inputs': [{ 'internalType': 'address', 'name': 'to', 'type': 'address' }, { + 'internalType': 'uint256', + 'name': 'tokenId', + 'type': 'uint256', + }], + 'name': 'approve', + 'outputs': [], + 'stateMutability': 'nonpayable', + 'type': 'function', +}] as unknown as EvmABI; + diff --git a/src/antelope/stores/utils/abi/setApprovalForAllAbi.ts b/src/antelope/stores/utils/abi/setApprovalForAllAbi.ts new file mode 100644 index 000000000..c719fb132 --- /dev/null +++ b/src/antelope/stores/utils/abi/setApprovalForAllAbi.ts @@ -0,0 +1,13 @@ +import { EvmABI } from '.'; + +export const setApprovalForAllAbi = [{ + 'inputs': [{ 'internalType': 'address', 'name': 'operator', 'type': 'address' }, { + 'internalType': 'bool', + 'name': 'approved', + 'type': 'bool', + }], + 'name': 'setApprovalForAll', + 'outputs': [], + 'stateMutability': 'nonpayable', + 'type': 'function', +}] as unknown as EvmABI; diff --git a/src/antelope/stores/utils/currency-utils.ts b/src/antelope/stores/utils/currency-utils.ts index 5c6998d8e..2cfad06b2 100644 --- a/src/antelope/stores/utils/currency-utils.ts +++ b/src/antelope/stores/utils/currency-utils.ts @@ -183,9 +183,9 @@ export function prettyPrintCurrency( // and also decimals may be more places than maximum JS precision. // As such, decimals must be handled specially for BigNumber amounts. - const amountAsString = formatUnits(amount, tokenDecimals); // amount string, like "1.0" + const amountAsString = tokenDecimals === 0 ? amount.toNumber().toString() : formatUnits(amount, tokenDecimals); // amount string, like "1.0" - const [integerString, decimalString] = amountAsString.split('.'); + const [integerString, decimalString = '0'] = amountAsString.split('.'); const formattedInteger = Intl.NumberFormat( locale, diff --git a/src/antelope/stores/utils/date-utils.ts b/src/antelope/stores/utils/date-utils.ts index b016d750f..de5108331 100644 --- a/src/antelope/stores/utils/date-utils.ts +++ b/src/antelope/stores/utils/date-utils.ts @@ -1,9 +1,16 @@ +import { fromUnixTime, format } from 'date-fns'; + /** * Useful date-related constants */ -export const HOUR_SECONDS = 60 * 60; -export const DAY_SECONDS = 24 * HOUR_SECONDS; +export const MINUTE_SECONDS = 60; +export const HOUR_SECONDS = 60 * MINUTE_SECONDS; +export const DAY_SECONDS = 24 * HOUR_SECONDS; +export const WEEK_SECONDS = 7 * DAY_SECONDS; +export const MONTH_SECONDS = 30 * DAY_SECONDS; +export const YEAR_SECONDS = 365 * DAY_SECONDS; +export const DEFAULT_DATE_FORMAT = 'MMM d, yyyy hh:mm:ss a'; /** * Returns true if the given epochMs is less than the given number of minutes ago @@ -36,7 +43,7 @@ export function dateIsWithinXMinutes(epochMs: number, minutes: number) { * @param {function} $t translation function. Should accept a string (just the keyname without a path) and return a translated string * @returns {string} plain english time period */ -export function formatUnstakePeriod(seconds: number|null, $t: (key:string) => string) { +export function prettyTimePeriod(seconds: number|null, $t: (key: string) => string, round = false) { if (seconds === null) { return '--'; } @@ -45,20 +52,57 @@ export function formatUnstakePeriod(seconds: number|null, $t: (key:string) => st let unit; if (seconds < HOUR_SECONDS) { - quantity = seconds / 60; + quantity = seconds / MINUTE_SECONDS; unit = $t('minutes'); } else if (seconds < DAY_SECONDS) { quantity = seconds / HOUR_SECONDS; unit = $t('hours'); - } else { + } else if (seconds < WEEK_SECONDS) { quantity = seconds / DAY_SECONDS; unit = $t('days'); + } else if (seconds < MONTH_SECONDS) { + quantity = seconds / WEEK_SECONDS; + unit = $t('weeks'); + } else if (seconds < YEAR_SECONDS) { + quantity = seconds / MONTH_SECONDS; + unit = $t('months'); + } else { + quantity = seconds / YEAR_SECONDS; + unit = $t('years'); } - if (!Number.isInteger(quantity)) { - quantity = quantity.toFixed(1); - } + const fractionDigits = round ? 0 : 1; - return `${quantity} ${unit}`; + const formatter = new Intl.NumberFormat(navigator.language, { maximumFractionDigits: fractionDigits }); + const formattedQuantity = formatter.format(quantity); + + return `${formattedQuantity} ${unit}`; } +/** + * Given a Date object, return the pretty-printed timezone offset, e.g. "+05:00" + * + * @param {Date} date + * @return {string} + */ +export function getFormattedUtcOffset(date: Date): string { + const pad = (value: number) => value < 10 ? '0' + value : value; + const sign = (date.getTimezoneOffset() > 0) ? '-' : '+'; + const offset = Math.abs(date.getTimezoneOffset()); + const hours = pad(Math.floor(offset / 60)); + const minutes = pad(offset % 60); + return sign + hours + ':' + minutes; +} + +/** + * Given a unix timestamp, returns string with the date in a given format showing UTC offset optionally. + * @param epoch seconds since epoch + * @param timeFormat a string containing the format of the date to be returned (based on date-fns format) + * @param showUtc whether to show the UTC offset + * @returns {string} the formatted date + */ +export function getFormattedDate(epoch: number, timeFormat = DEFAULT_DATE_FORMAT, showUtc = false): string { + const offset = getFormattedUtcOffset(new Date(epoch)); + const utc = showUtc ? ` (UTC ${offset})` : ''; + return `${format(fromUnixTime(epoch), timeFormat)}${utc}`; +} diff --git a/src/antelope/stores/utils/index.ts b/src/antelope/stores/utils/index.ts index d8cd9fbc3..1f3181470 100644 --- a/src/antelope/stores/utils/index.ts +++ b/src/antelope/stores/utils/index.ts @@ -2,7 +2,6 @@ export * from 'src/antelope/stores/utils/abi/signature'; import { BigNumber, ethers } from 'ethers'; import { formatUnits } from '@ethersproject/units'; import { EvmABIEntry } from 'src/antelope/types'; -import { fromUnixTime, format } from 'date-fns'; import { toStringNumber } from 'src/antelope/stores/utils/currency-utils'; import { prettyPrintCurrency } from 'src/antelope/stores/utils/currency-utils'; import { keccak256, toUtf8Bytes } from 'ethers/lib/utils'; @@ -188,46 +187,6 @@ export function getClientIsApple() { || (navigator.userAgent.includes('Mac') && 'ontouchend' in document); } -/** - * Given a Date object, return the pretty-printed timezone offset, e.g. "+05:00" - * - * @param {Date} date - * @return {string} - */ -export function getFormattedUtcOffset(date: Date): string { - const pad = (value: number) => value < 10 ? '0' + value : value; - const sign = (date.getTimezoneOffset() > 0) ? '-' : '+'; - const offset = Math.abs(date.getTimezoneOffset()); - const hours = pad(Math.floor(offset / 60)); - const minutes = pad(offset % 60); - return sign + hours + ':' + minutes; -} - -/** - * Given a unix timestamp, returns a date in the form of Jan 1, 2023 07:45:22 AM - * - * @param epoch - * - * @return string - */ -export function getLongDate(epoch: number): string { - const offset = getFormattedUtcOffset(new Date(epoch)); - return `${format(fromUnixTime(epoch), 'MMM d, yyyy hh:mm:ss a')} (UTC ${offset})`; -} - - -/** - * Given a unix timestamp, returns string with the date in a given format showing UTC offset optionally. - * @param epoch seconds since epoch - * @param timeFormat a string containing the format of the date to be returned (based on date-fns format) - * @param showUtc whether to show the UTC offset - * @returns {string} the formatted date - */ -export function getFormatedDate(epoch: number, timeFormat = 'MMM d, yyyy hh:mm:ss a', showUtc = false): string { - const offset = getFormattedUtcOffset(new Date(epoch)); - const utc = showUtc ? ` (UTC ${offset})` : ''; - return `${format(fromUnixTime(epoch), timeFormat)}${utc}`; -} /* * Determines whether the amount is too large (more than six characters long) to be displayed in full on mobile devices diff --git a/src/antelope/types/Allowances.ts b/src/antelope/types/Allowances.ts new file mode 100644 index 000000000..1d3c1fd01 --- /dev/null +++ b/src/antelope/types/Allowances.ts @@ -0,0 +1,81 @@ +import { BigNumber } from 'ethers'; + +// this is the largest possible uint256 value, which is used to represent 'infinite' allowances +export const MAX_UINT_256 = BigNumber.from(2).pow(256).sub(1); + +// Any allowance below this amount is considered 'tiny' +export const TINY_ALLOWANCE_THRESHOLD = 0.01; + +// some notes about allowances: +// 1. ERC721 tokens can be approved for a single token (e.g. approve) or for all tokens in a collection (e.g. setApprovalForAll) +// 2. ERC1155 tokens can only be approved for all tokens in a collection (e.g. setApprovalForAll) +// 3. ERC721 and ERC1155 token allowances have no 'amount' - they are either approved or not approved +interface AllowanceRow { + lastUpdated: number; // timestamp of the last time the allowance was updated - ms since epoch + spenderAddress: string; // address for the spender contract + spenderName?: string; // name of the spender contract +} + +export interface ShapedAllowanceRowERC20 extends AllowanceRow { + tokenName: string; // e.g. Telos, Tether, etc. + tokenAddress: string; // address for the token contract + + // allowance amount, expressed in the token's smallest unit, e.g. 6 decimals for USDT or wei for TLOS/ETH + allowance: BigNumber; + + // balance amount expressed in the token's smallest unit, e.g. 6 decimals for USDT or wei for TLOS/ETH + balance: BigNumber; + + tokenDecimals: number; // decimals for the token (e.g. 6 for USDT, 18 for TLOS/ETH) + tokenSymbol: string; // e.g. TLOS, USDT, etc. + tokenMaxSupply: BigNumber; // max supply for the token + tokenPrice: number; // price of the token in USD (0 if not available) + tokenLogo?: string; // path or URI for the token logo (optional) +} + +export interface ShapedAllowanceRowNftCollection extends AllowanceRow { + collectionAddress: string; // address of the collection/contract + collectionName?: string; // name of the collection + allowed: boolean; // whether the user has approved the spender for the entire collection + + // represents the total number of tokens the user owns in the entire collection + // for ERC1155: the sum of each owned tokenId's amount + // for ERC721: the number of owned tokens + balance: BigNumber; +} + +export interface ShapedAllowanceRowSingleERC721 extends AllowanceRow { + tokenId: string; // tokenId for owned NFT + tokenName: string; // name of the NFT + allowed: boolean; // whether the user has approved the spender for the given NFT + collectionAddress: string; // address of the collection/contract + collectionName?: string; // name of the collection +} + +export type ShapedAllowanceRow = ShapedAllowanceRowERC20 | ShapedAllowanceRowNftCollection | ShapedAllowanceRowSingleERC721; +export type ShapedCollectionAllowanceRow = ShapedAllowanceRowNftCollection | ShapedAllowanceRowSingleERC721; + +// type guards for shaped allowance rows +export function isErc20AllowanceRow(row: ShapedAllowanceRow): row is ShapedAllowanceRowERC20 { + return (row as ShapedAllowanceRowERC20).tokenDecimals !== undefined; +} + +export function isErc721SingleAllowanceRow(row: ShapedAllowanceRow): row is ShapedAllowanceRowSingleERC721 { + const { tokenId, tokenName } = row as ShapedAllowanceRowSingleERC721; + + return Boolean(tokenId && tokenName); +} + +export function isNftCollectionAllowanceRow(row: ShapedAllowanceRow): row is ShapedAllowanceRowNftCollection { + return !(isErc20AllowanceRow(row) || isErc721SingleAllowanceRow(row)); +} + +export enum AllowanceTableColumns { + revoke = 'revoke', + asset = 'asset', + value = 'value', + allowance = 'allowance', + spender = 'spender', + type = 'type', + updated = 'updated', +} diff --git a/src/antelope/types/Filters.ts b/src/antelope/types/Filters.ts index b108f15be..deefc0521 100644 --- a/src/antelope/types/Filters.ts +++ b/src/antelope/types/Filters.ts @@ -1,5 +1,9 @@ import { NftTokenInterface } from 'src/antelope/types/NFTClass'; +export enum Sort { + ascending = 'asc', + descending = 'desc', +} export interface HyperionAbiSignatureFilter { type?: string; @@ -66,3 +70,10 @@ export interface IndexerCollectionNftsFilter extends IndexerPaginationFilter { includeTokenIdSupply?: boolean; type?: NftTokenInterface; } + +export interface IndexerAllowanceFilter extends IndexerPaginationFilter { + contract?: string; // contract address + sort?: 'DESC' | 'ASC'; // sort by allowance amount (DESC or ASC) + includeAbi?: boolean; // indicate whether to include abi + includePagination?: boolean; // indicate whether to include pagination +} diff --git a/src/antelope/types/IndexerTypes.ts b/src/antelope/types/IndexerTypes.ts index 2a5db0907..d7ff93509 100644 --- a/src/antelope/types/IndexerTypes.ts +++ b/src/antelope/types/IndexerTypes.ts @@ -5,7 +5,7 @@ export const INVALID_METADATA = '___INVALID_METADATA___'; // string given by ind interface IndexerNftResponse { success: boolean; contracts: { - [address: string]: IndexerNftContract; + [address: string]: IndexerContract; }; } @@ -48,6 +48,7 @@ interface IndexerNftResult { // results from the /contract/{address}/nfts endpoint export interface IndexerCollectionNftResult extends IndexerNftResult { supply?: number; // present only for ERC1155 + owner?: string; // present only for ERC721 } // results from the /account/{address}/nfts endpoint @@ -73,7 +74,7 @@ export interface GenericIndexerNft { owner?: string; // present only for ERC721 } -export interface IndexerNftContract { +export interface IndexerContract { symbol: string; creator: string; address: string; @@ -152,7 +153,7 @@ export interface IndexerHealthResponse { export interface IndexerTokenHoldersResponse { contracts: { - [address: string]: IndexerNftContract; + [address: string]: IndexerContract; }; results: { address: string; // holder address @@ -161,3 +162,51 @@ export interface IndexerTokenHoldersResponse { updated: number; // ms since epoch }[]; } + +// Allowances +interface IndexerAllowanceResult { + owner: string; // address of the token owner; + contract: string; // address of the token contract + updated: number; // timestamp of the last time the allowance was updated - ms since epoch +} + +export interface IndexerErc20AllowanceResult extends IndexerAllowanceResult { + amount: string; // string representation of a number; the amount of tokens the owner has approved for the spender in the token's smallest unit + spender: string; // address of the spender contract +} + +export interface IndexerErc721AllowanceResult extends IndexerAllowanceResult { + single: false; // whether the allowance is for a single token or for the entire collection + approved: boolean; // whether the user has approved the spender + operator: string; // address of the spender contract + + tokenId?: string | number; // only present if single === true +} + +export interface IndexerErc1155AllowanceResult extends IndexerAllowanceResult { + approved: boolean; // whether the user has approved the spender + operator: string; // address of the spender contract +} + +export interface IndexerAllowanceResponseErc20 { + contracts: { + [address: string]: IndexerContract; + } + results: IndexerErc20AllowanceResult[], +} + +export interface IndexerAllowanceResponseErc721 { + contracts: { + [address: string]: IndexerContract; + } + results: IndexerErc721AllowanceResult[], +} + +export interface IndexerAllowanceResponseErc1155 { + contracts: { + [address: string]: IndexerContract; + } + results: IndexerErc1155AllowanceResult[], +} + +export type IndexerAllowanceResponse = IndexerAllowanceResponseErc20 | IndexerAllowanceResponseErc721 | IndexerAllowanceResponseErc1155; diff --git a/src/antelope/types/NFTClass.ts b/src/antelope/types/NFTClass.ts index 575c29f47..48f2235d5 100644 --- a/src/antelope/types/NFTClass.ts +++ b/src/antelope/types/NFTClass.ts @@ -3,7 +3,7 @@ import { GenericIndexerNft, INVALID_METADATA, - IndexerNftContract, + IndexerContract, IndexerNftItemAttribute, IndexerNftMetadata, IndexerTokenHoldersResponse, @@ -190,8 +190,8 @@ export async function constructNft( // NFT classes ------------------ export class NFTContractClass { - indexer: IndexerNftContract; - constructor(source: IndexerNftContract) { + indexer: IndexerContract; + constructor(source: IndexerContract) { this.indexer = source; } diff --git a/src/antelope/types/TokenClass.ts b/src/antelope/types/TokenClass.ts index 54f048872..5766f0b86 100644 --- a/src/antelope/types/TokenClass.ts +++ b/src/antelope/types/TokenClass.ts @@ -330,6 +330,9 @@ export class TokenBalance { get isNative(): boolean { return this.token.isNative; } + get price(): TokenPrice { + return this.token.price; + } toString(): string { return this._balanceStr; diff --git a/src/antelope/types/index.ts b/src/antelope/types/index.ts index 9bd9f7481..05184db34 100644 --- a/src/antelope/types/index.ts +++ b/src/antelope/types/index.ts @@ -1,6 +1,7 @@ // interfaces for antelope export * from 'src/antelope/types/ABIv1'; export * from 'src/antelope/types/Actions'; +export * from 'src/antelope/types/Allowances'; export * from 'src/antelope/types/AntelopeError'; export * from 'src/antelope/types/Api'; export * from 'src/antelope/types/ChainInfo'; diff --git a/src/antelope/wallets/authenticators/EVMAuthenticator.ts b/src/antelope/wallets/authenticators/EVMAuthenticator.ts index f1457fe2a..f76e9c43a 100644 --- a/src/antelope/wallets/authenticators/EVMAuthenticator.ts +++ b/src/antelope/wallets/authenticators/EVMAuthenticator.ts @@ -8,7 +8,8 @@ import { useChainStore } from 'src/antelope/stores/chain'; import { useEVMStore } from 'src/antelope/stores/evm'; import { createTraceFunction, isTracingAll, useFeedbackStore } from 'src/antelope/stores/feedback'; import { usePlatformStore } from 'src/antelope/stores/platform'; -import { AntelopeError, NftTokenInterface, ERC1155_TYPE, ERC721_TYPE, EvmABI, EvmABIEntry, EvmFunctionParam, EvmTransactionResponse, ExceptionError, TokenClass, addressString, erc20Abi, erc721Abi, escrowAbiWithdraw, stlosAbiDeposit, stlosAbiWithdraw, wtlosAbiDeposit, wtlosAbiWithdraw, erc1155Abi } from 'src/antelope/types'; +import { setApprovalForAllAbi } from 'src/antelope/stores/utils/abi/setApprovalForAllAbi'; +import { AntelopeError, NftTokenInterface, ERC1155_TYPE, ERC721_TYPE, EvmABI, EvmABIEntry, EvmFunctionParam, EvmTransactionResponse, ExceptionError, TokenClass, addressString, erc20Abi, erc721Abi, escrowAbiWithdraw, stlosAbiDeposit, stlosAbiWithdraw, wtlosAbiDeposit, wtlosAbiWithdraw, erc1155Abi, erc20AbiApprove, erc721ApproveAbi } from 'src/antelope/types'; export abstract class EVMAuthenticator { @@ -371,6 +372,88 @@ export abstract class EVMAuthenticator { }); } + /** + * This method creates a Transaction to update an ERC20 allowance by calling the approve function + * @param spender address of the spender + * @param tokenContractAddress address of the ERC20 token contract + * @param allowance amount of tokens to allow + * + * @returns transaction response + */ + async updateErc20Allowance( + spender: string, + tokenContractAddress: string, + allowance: BigNumber, + ): Promise { + this.trace('updateErc20Allowance', spender, tokenContractAddress, allowance.toString()); + + return this.signCustomTransaction( + tokenContractAddress, + erc20AbiApprove, + [ + spender, + allowance.toHexString(), + ], + ).catch((error) => { + throw this.handleCatchError(error as never); + }); + } + + /** + * This method creates a Transaction to update an ERC721 allowance by calling the approve function + * @param operator address of the operator + * @param nftContractAddress address of the ERC721 token contract + * @param tokenId id of the token to set allowance for + * + * @returns transaction response + */ + async updateSingleErc721Allowance( + operator: string, + nftContractAddress: string, + tokenId: string, + ): Promise { + this.trace('updateSingleErc721Allowance', operator, nftContractAddress, tokenId); + + return this.signCustomTransaction( + nftContractAddress, + erc721ApproveAbi, + [ + operator, + tokenId, + ], + ).catch((error) => { + throw this.handleCatchError(error as never); + }); + } + + /** + * This method creates a Transaction to update an NFT collection (ERC721 or ERC1155) allowance + * by calling the setApprovalForAll function + * @param operator address of the operator + * @param nftContractAddress address of the ERC721 token contract + * @param allowed boolean to set allowance + * + * @returns transaction response + */ + async updateNftCollectionAllowance( + operator: string, + nftContractAddress: string, + allowed: boolean, + ): Promise { + this.trace('updateNftCollectionAllowance', operator, nftContractAddress, allowed); + + return this.signCustomTransaction( + nftContractAddress, + setApprovalForAllAbi, + [ + operator, + allowed, + ], + ).catch((error) => { + throw this.handleCatchError(error as never); + }); + } + /** * This method creates and throws an AntelopeError with the corresponding message. * It is useful to handle specific error codes that may indicate a particular known error situation. diff --git a/src/assets/icon--allowances.svg b/src/assets/icon--allowances.svg new file mode 100644 index 000000000..cce2ecfb4 --- /dev/null +++ b/src/assets/icon--allowances.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/ConversionRateBadge.vue b/src/components/ConversionRateBadge.vue index 336ad75f0..dd09c6508 100644 --- a/src/components/ConversionRateBadge.vue +++ b/src/components/ConversionRateBadge.vue @@ -1,8 +1,11 @@ - diff --git a/src/components/ExternalLink.vue b/src/components/ExternalLink.vue index e89ac002b..24fb9b7af 100644 --- a/src/components/ExternalLink.vue +++ b/src/components/ExternalLink.vue @@ -25,6 +25,7 @@ const formattedText = computed(() => { +const props = defineProps<{ + label: string; +}>(); + + + + + diff --git a/src/components/ToolTip.vue b/src/components/ToolTip.vue index 272760330..442d5312d 100644 --- a/src/components/ToolTip.vue +++ b/src/components/ToolTip.vue @@ -78,6 +78,7 @@ function setTooltipVisibility(enable: boolean) { width: 24px; justify-content: center; align-items: center; + gap: 4px; &--dynamic-size { width: unset; diff --git a/src/components/evm/AppNav.vue b/src/components/evm/AppNav.vue index 6a8c02506..2d8765378 100644 --- a/src/components/evm/AppNav.vue +++ b/src/components/evm/AppNav.vue @@ -322,6 +322,26 @@ export default defineComponent({ {{ $t('nav.wrap_tlos') }} + +
  • +import { ref } from 'vue'; + +const props = defineProps<{ + header: string; + content: { text: string; bold?: boolean; }[]; + alwaysOpen?: boolean, // if true, the expansion item will always be open with no toggle +}>(); + +// data +const expansionItemModel = ref(false); + +// methods +function handleExpansionItemUpdate() { + if (props.alwaysOpen) { + expansionItemModel.value = true; + } else { + expansionItemModel.value = !expansionItemModel.value; + } +} + + + + + diff --git a/src/components/evm/TableControls.vue b/src/components/evm/TableControls.vue index 625f764bd..7b8cd4c9e 100644 --- a/src/components/evm/TableControls.vue +++ b/src/components/evm/TableControls.vue @@ -79,20 +79,28 @@ function changePageNumber(direction: 'next' | 'prev' | 'first' | 'last') {