diff --git a/jest.init.js b/jest.init.js index 8637fbbf1..4fd5d800d 100644 --- a/jest.init.js +++ b/jest.init.js @@ -4,8 +4,10 @@ import { QBtn } from 'quasar'; config.global.mocks = { $t: str => str, + $warningNotification: jest.fn(), }; + config.global.components = { 'q-btn': QBtn, }; diff --git a/package.json b/package.json index 10710583a..985192876 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telos-web-wallet", - "version": "2.3.3", + "version": "2.4.0", "description": "A Web Wallet for Telos", "productName": "Telos Web Wallet", "private": true, @@ -46,7 +46,6 @@ "quasar": "2", "rxjs": "^7.8.0", "ual-anchor": "^1.1.2", - "ual-ledger": "^0.3.0", "ual-oreid": "^1.0.0", "ual-wombat": "^0.3.3", "universal-authenticator-library": "^0.3.0", diff --git a/src/App.vue b/src/App.vue index 5950ecb07..dc13a9894 100644 --- a/src/App.vue +++ b/src/App.vue @@ -54,23 +54,6 @@ export default defineComponent({ script.defer = true; document.body.appendChild(script); } - - if (isTodayBeforeTelosCloudDown) { - getAntelope().config.notifyRememberInfoHandler( - this.$t('temporal.telos_cloud_discontinued_title'), - [{ - tag: 'p', - class: 'c-notify__message--subtitle', - text: this.$t('temporal.telos_cloud_discontinued_message_title'), - }, { - tag: 'p', - class: '', - text: this.$t('temporal.telos_cloud_discontinued_message_body'), - }], - '', - 'telos-cloud-discontinued', - ); - } }, }); diff --git a/src/antelope/chains/EVMChainSettings.ts b/src/antelope/chains/EVMChainSettings.ts index 5e256bd40..514534afd 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'; @@ -290,7 +294,7 @@ export default abstract class EVMChainSettings implements ChainSettings { abstract getSystemTokens(): TokenClass[]; abstract getIndexerApiEndpoint(): string; abstract hasIndexerSupport(): boolean; - abstract trackAnalyticsEvent(params: Record): void; + abstract trackAnalyticsEvent(eventName: string): void; async getApy(): Promise { const response = await this.api.get('apy/evm'); @@ -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/NativeChainSettings.ts b/src/antelope/chains/NativeChainSettings.ts index d5b05ed24..8c71c31ab 100644 --- a/src/antelope/chains/NativeChainSettings.ts +++ b/src/antelope/chains/NativeChainSettings.ts @@ -149,7 +149,7 @@ export default abstract class NativeChainSettings implements ChainSettings { abstract getMapDisplay(): boolean; abstract getTheme(): Theme; abstract getFiltersSupported(prop: string): boolean; - abstract trackAnalyticsEvent(params: Record): void; + abstract trackAnalyticsEvent(eventName: string): void; /** * Retrieves the list of IDs for the important tokens. diff --git a/src/antelope/chains/chain-constants.ts b/src/antelope/chains/chain-constants.ts index 6822ad20b..53f0cd569 100644 --- a/src/antelope/chains/chain-constants.ts +++ b/src/antelope/chains/chain-constants.ts @@ -3,15 +3,19 @@ import { NETWORK as telosTestnetEvmNetwork } from 'src/antelope/chains/evm/telos export const TELOS_CHAIN_IDS = ['40', '41']; export const TELOS_NETWORK_NAMES = [telosEvmNetwork, telosTestnetEvmNetwork]; -export const TELOS_ANALYTICS_EVENT_IDS = { - loginStarted: 'JXIYBP1S', - loginSuccessful: 'HIP11SFR', - loginSuccessfulMetamask: 'ABGMND23', - loginFailedMetamask: 'ZFGE6TFS', - loginSuccessfulSafepal: 'LKCBKDU2', - loginFailedSafepal: '6PSIWGNV', - loginSuccessfulOreId: 'MSXMDDNK', - loginFailedOreId: 'UFX3JYRJ', - loginFailedWalletConnect: '9V4IV1BV', - loginSuccessfulWalletConnect: '2EG2OR3H', +export const TELOS_ANALYTICS_EVENT_NAMES = { + loginStarted: 'Login Started', + loginSuccessful: 'Login Successful', + loginSuccessfulMetamask: 'Login Successful - Metamask', + loginFailedMetamask: 'Login Failed - Metamask', + loginSuccessfulSafepal: 'Login Successful - Safepal', + loginFailedSafepal: 'Login Failed - Safepal', + loginSuccessfulOreId: 'Login Successful - OreId', + loginFailedOreId: 'Login Failed - OreId', + loginFailedWalletConnect: 'Login Failed - WalletConnect', + loginSuccessfulWalletConnect: 'Login Successful - WalletConnect', + loginSuccessfulBrave: 'Login Successful - Brave', + loginFailedBrave: 'Login Failed - Brave', }; + +export const ZERO_ADDRESS = '0x'.concat('0'.repeat(40)); diff --git a/src/antelope/chains/evm/telos-evm-testnet/index.ts b/src/antelope/chains/evm/telos-evm-testnet/index.ts index 9f0297307..d0d08f93e 100644 --- a/src/antelope/chains/evm/telos-evm-testnet/index.ts +++ b/src/antelope/chains/evm/telos-evm-testnet/index.ts @@ -59,7 +59,7 @@ const NETWORK_EVM_ENDPOINT = 'https://testnet.telos.net'; const INDEXER_ENDPOINT = 'https://api.testnet.teloscan.io'; const CONTRACTS_BUCKET = 'https://verified-evm-contracts-testnet.s3.amazonaws.com'; -declare const fathom: { trackGoal: (eventId: string, value: 0) => void }; +declare const fathom: { trackEvent: (eventName: string) => void }; export default class TelosEVMTestnet extends EVMChainSettings { isTestnet() { @@ -149,7 +149,7 @@ export default class TelosEVMTestnet extends EVMChainSettings { } getBuyMoreOfTokenLink(): string { - return 'https://www.telos.net/#buy-tlos-simplex'; + return 'https://telos.net/ecosystem?category=Exchanges'; } getSystemTokens(): TokenClass[] { @@ -164,13 +164,12 @@ export default class TelosEVMTestnet extends EVMChainSettings { return true; } - trackAnalyticsEvent(params: Record): void { + trackAnalyticsEvent(eventName: string): void { if (typeof fathom === 'undefined') { - console.warn(`Failed to track event with ID ${params.id}: Fathom Analytics not loaded`); + console.warn(`Failed to track event with name '${eventName}': Fathom Analytics not loaded`); return; } - const id = params.id as string; - fathom.trackGoal(id, 0); + fathom.trackEvent(eventName); } } diff --git a/src/antelope/chains/evm/telos-evm/index.ts b/src/antelope/chains/evm/telos-evm/index.ts index 91b1a8533..9e2e6ed3b 100644 --- a/src/antelope/chains/evm/telos-evm/index.ts +++ b/src/antelope/chains/evm/telos-evm/index.ts @@ -59,7 +59,7 @@ const NETWORK_EVM_ENDPOINT = 'https://mainnet.telos.net'; const INDEXER_ENDPOINT = 'https://api.teloscan.io'; const CONTRACTS_BUCKET = 'https://verified-evm-contracts.s3.amazonaws.com'; -declare const fathom: { trackGoal: (eventId: string, value: 0) => void }; +declare const fathom: { trackEvent: (eventName: string) => void }; export default class TelosEVMTestnet extends EVMChainSettings { getNetwork(): string { @@ -145,7 +145,7 @@ export default class TelosEVMTestnet extends EVMChainSettings { } getBuyMoreOfTokenLink(): string { - return 'https://www.telos.net/#buy-tlos-simplex'; + return 'https://telos.net/ecosystem?category=Exchanges'; } getSystemTokens(): TokenClass[] { @@ -160,13 +160,12 @@ export default class TelosEVMTestnet extends EVMChainSettings { return true; } - trackAnalyticsEvent(params: Record): void { + trackAnalyticsEvent(eventName: string): void { if (typeof fathom === 'undefined') { - console.warn(`Failed to track event with ID ${params.id}: Fathom Analytics not loaded`); + console.warn(`Failed to track event with name '${eventName}': Fathom Analytics not loaded`); return; } - const id = params.id as string; - fathom.trackGoal(id, 0); + fathom.trackEvent(eventName); } } diff --git a/src/antelope/config/AntelopeConfig.ts b/src/antelope/config/AntelopeConfig.ts index 87c8cb10e..da03a7f36 100644 --- a/src/antelope/config/AntelopeConfig.ts +++ b/src/antelope/config/AntelopeConfig.ts @@ -50,6 +50,7 @@ export class AntelopeConfig { private __notify_success_copy_handler: () => void = alert; private __notify_failure_message_handler: (message: string, payload?: AntelopeErrorPayload) => void = alert; private __notify_failure_action_handler: (message: string, payload?: AntelopeErrorPayload) => void = alert; + private __notify_warning_action_handler: (message: string, payload?: AntelopeErrorPayload) => void = alert; private __notify_disconnected_handler: () => void = alert; private __notify_neutral_message_handler: (message: string) => (() => void) = () => (() => void 0); private __notify_remember_info_handler: (title: string, message: string | ComplexMessage[], payload: string, key: string) => (() => void) = () => (() => void 0); @@ -116,13 +117,14 @@ export class AntelopeConfig { // Vue.App holder -- private __app: App | null = null; - constructor(public debug: AntelopeDebug) { + constructor( + public debug: AntelopeDebug, + ) { // } init(app: App) { this.__app = app; - this.debug.init(); } get app() { @@ -169,6 +171,10 @@ export class AntelopeConfig { return this.__notify_failure_action_handler; } + get notifyWarningWithAction() { + return this.__notify_warning_action_handler; + } + get notifyDisconnectedHandler() { return this.__notify_disconnected_handler; } @@ -239,6 +245,10 @@ export class AntelopeConfig { this.__notify_failure_action_handler = handler; } + public setNotifyWarningWithAction(handler: (message: string, payload?: AntelopeErrorPayload) => void) { + this.__notify_warning_action_handler = handler; + } + public setNotifyDisconnectedHandler(handler: () => void) { this.__notify_disconnected_handler = handler; } @@ -277,3 +287,4 @@ export class AntelopeConfig { } } + diff --git a/src/antelope/index.ts b/src/antelope/index.ts index 94c82a35e..f0062f65f 100644 --- a/src/antelope/index.ts +++ b/src/antelope/index.ts @@ -6,26 +6,26 @@ import { Store } from 'pinia'; import { AntelopeConfig, AntelopeDebug, chainNetworkNames } from 'src/antelope/config'; import installPinia from 'src/antelope/stores'; -import { AccountModel } from 'src/antelope/stores/account'; import { ChainModel } from 'src/antelope/stores/chain'; -import { - useAccountStore, - useBalancesStore, - useChainStore, - useContractStore, - useEVMStore, - useFeedbackStore, - useHistoryStore, - useNftsStore, - usePlatformStore, - useProfileStore, - useResourcesStore, - useRexStore, - useTokensStore, - useUserStore, -} from 'src/antelope'; import { AntelopeWallets } from 'src/antelope/wallets'; +import { AccountModel } from 'src/antelope/stores/account'; + +import { useFeedbackStore } from 'src/antelope/stores/feedback'; +import { usePlatformStore } from 'src/antelope/stores/platform'; +import { useProfileStore } from 'src/antelope/stores/profile'; +import { useResourcesStore } from 'src/antelope/stores/resources'; +import { useUserStore } from 'src/antelope/stores/user'; +import { useChainStore } from 'src/antelope/stores/chain'; +import { useContractStore } from 'src/antelope/stores/contract'; +import { useEVMStore } from 'src/antelope/stores/evm'; +import { useTokensStore } from 'src/antelope/stores/tokens'; +import { useNftsStore } from 'src/antelope/stores/nfts'; +import { useAccountStore } from 'src/antelope/stores/account'; +import { useAllowancesStore } from 'src/antelope/stores/allowances'; +import { useBalancesStore } from 'src/antelope/stores/balances'; +import { useHistoryStore } from 'src/antelope/stores/history'; +import { useRexStore } from 'src/antelope/stores/rex'; // provide typings for `this.$store` declare module '@vue/runtime-core' { @@ -91,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(), }; } @@ -158,20 +159,28 @@ export const installAntelope = (app: App) => { return antelope; }; -export { useAccountStore } from 'src/antelope/stores/account'; -export { useChainStore } from 'src/antelope/stores/chain'; -export { useUserStore } from 'src/antelope/stores/user'; + + + + + + +export { useFeedbackStore } from 'src/antelope/stores/feedback'; +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 { useUserStore } from 'src/antelope/stores/user'; +export { useChainStore } from 'src/antelope/stores/chain'; 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 { useTokensStore } from 'src/antelope/stores/tokens'; export { useNftsStore } from 'src/antelope/stores/nfts'; +export { useAccountStore } from 'src/antelope/stores/account'; +export { useAllowancesStore } from 'src/antelope/stores/allowances'; +export { useBalancesStore } from 'src/antelope/stores/balances'; +export { useHistoryStore } from 'src/antelope/stores/history'; +export { useRexStore } from 'src/antelope/stores/rex'; + // 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 91f656024..bb0f15768 100644 --- a/src/antelope/stores/account.ts +++ b/src/antelope/stores/account.ts @@ -17,14 +17,6 @@ import { Authenticator, User } from 'universal-authenticator-library'; import { defineStore } from 'pinia'; import { API } from '@greymass/eosio'; import { initFuelUserWrapper } from 'src/api/fuel'; -import { - CURRENT_CONTEXT, - useBalancesStore, - useFeedbackStore, - useHistoryStore, - useNftsStore, -} from 'src/antelope'; -import { getAntelope, useChainStore } from 'src/antelope'; import { createTraceFunction, errorToString } from 'src/antelope/config'; import NativeChainSettings from 'src/antelope/chains/NativeChainSettings'; import { @@ -39,6 +31,18 @@ import { toRaw } from 'vue'; import { getAddress } from 'ethers/lib/utils'; import { OreIdAuthenticator } from 'ual-oreid'; +// dependencies -- +import { + CURRENT_CONTEXT, + getAntelope, + useChainStore, + useAllowancesStore, + useBalancesStore, + useFeedbackStore, + useHistoryStore, + useNftsStore, +} from 'src/antelope'; + export interface LoginNativeActionData { authenticator: Authenticator, @@ -82,7 +86,6 @@ export interface EvmAccountModel extends AccountModel { authenticator: EVMAuthenticator; } - export interface AccountState { // accounts mapped by label __accounts: { [label: Label]: AccountModel }; @@ -211,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..415dfe146 --- /dev/null +++ b/src/antelope/stores/allowances.ts @@ -0,0 +1,684 @@ +import { defineStore } from 'pinia'; +import { filter } from 'rxjs'; +import { formatUnits } from 'ethers/lib/utils'; +import { BigNumber } from 'ethers'; + +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 } from 'src/antelope/config'; +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'; + +// dependencies -- +import { + CURRENT_CONTEXT, + getAntelope, + useAccountStore, + useChainStore, + useContractStore, + useFeedbackStore, + useNftsStore, + useTokensStore, +} from 'src/antelope'; + +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(); + + 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/balances.ts b/src/antelope/stores/balances.ts index a9f5887f7..4431d0cf2 100644 --- a/src/antelope/stores/balances.ts +++ b/src/antelope/stores/balances.ts @@ -24,19 +24,10 @@ import { EvmABI, addressString, AntelopeError, - } from 'src/antelope/types'; import { createTraceFunction } from 'src/antelope/config'; import NativeChainSettings from 'src/antelope/chains/NativeChainSettings'; import EVMChainSettings from 'src/antelope/chains/EVMChainSettings'; -import { - getAntelope, - useAccountStore, - useFeedbackStore, - useChainStore, - CURRENT_CONTEXT, - useContractStore, -} from 'src/antelope'; import { formatWei } from 'src/antelope/stores/utils'; import { BigNumber, ethers } from 'ethers'; import { toRaw } from 'vue'; @@ -53,6 +44,16 @@ import { filter } from 'rxjs'; import { convertCurrency } from 'src/antelope/stores/utils/currency-utils'; import { subscribeForTransactionReceipt } from 'src/antelope/stores/utils/trx-utils'; +// dependencies -- +import { + CURRENT_CONTEXT, + getAntelope, + useAccountStore, + useFeedbackStore, + useChainStore, + useContractStore, +} from 'src/antelope'; + export interface BalancesState { __balances: { [label: Label]: TokenBalance[] }; __wagmiSystemTokenTransferConfig: { [label: Label]: PrepareSendTransactionResult | null }; @@ -72,6 +73,7 @@ export const useBalancesStore = defineStore(store_name, { trace: createTraceFunction(store_name), init: () => { const balanceStore = useBalancesStore(); + console.log('--------------------------------', getAntelope().events.onAccountChanged.pipe); getAntelope().events.onAccountChanged.pipe( filter(({ label, account }) => !!label && !!account), ).subscribe({ diff --git a/src/antelope/stores/chain.ts b/src/antelope/stores/chain.ts index 537ef5d10..9201dbcf8 100644 --- a/src/antelope/stores/chain.ts +++ b/src/antelope/stores/chain.ts @@ -16,12 +16,6 @@ import { defineStore } from 'pinia'; -import { - CURRENT_CONTEXT, - useAccountStore, - useContractStore, - useFeedbackStore, -} from 'src/antelope'; // main native chains import EOS from 'src/antelope/chains/native/eos'; @@ -47,9 +41,17 @@ import { ChainSettings, Label, TokenClass, + stlosAbiPreviewDeposit, + stlosAbiPreviewRedeem, } from 'src/antelope/types'; import { ethers } from 'ethers'; +// dependencies -- +import { + CURRENT_CONTEXT, + useFeedbackStore, +} from 'src/antelope'; + export const settings: { [key: string]: ChainSettings } = { // Native chains @@ -176,62 +178,26 @@ export const useChainStore = defineStore(store_name, { useFeedbackStore().unsetLoading('updateApy'); } }, - async actualUpdateStakedRatio(label: string): Promise { + async updateStakedRatio(label: string): Promise { // first we need the contract instance to be able to execute queries this.trace('actualUpdateStakedRatio', label); useFeedbackStore().setLoading('actualUpdateStakedRatio'); const chain_settings = useChainStore().getChain(label).settings as EVMChainSettings; const sysToken = chain_settings.getSystemToken(); const stkToken = chain_settings.getStakedSystemToken(); - const authenticator = useAccountStore().getEVMAuthenticator(label); - if (!authenticator) { - useFeedbackStore().unsetLoading('actualUpdateStakedRatio'); - this.trace('actualUpdateStakedRatio', label, '-> no authenticator'); - throw new AntelopeError('antelope.chain.error_no_default_authenticator'); - } - const contract = await useContractStore().getContract(label, stkToken.address, stkToken.type); - if (!contract) { - useFeedbackStore().unsetLoading('actualUpdateStakedRatio'); - this.trace('actualUpdateStakedRatio', label, '-> no contract'); - return; - } - const contractInstance = await contract.getContractInstance(); + + const abi = [stlosAbiPreviewDeposit[0], stlosAbiPreviewRedeem[0]]; + const provider = await getAntelope().wallets.getWeb3Provider(); + const contractInstance = new ethers.Contract(stkToken.address, abi, provider); // Now we preview a deposit of 1 SYS to get the ratio const oneSys = ethers.utils.parseUnits('1.0', sysToken.decimals); - const stakedRatio = await contractInstance.previewDeposit(oneSys); + const stakedRatio = await contractInstance.previewDeposit(oneSys.toString()); const unstakedRatio:ethers.BigNumber = await contractInstance.previewRedeem(oneSys); // Finally we update the store this.setStakedRatio(label, stakedRatio); this.setUnstakedRatio(label, unstakedRatio); useFeedbackStore().unsetLoading('actualUpdateStakedRatio'); }, - async updateStakedRatio(label: string): Promise { - this.trace('updateStakedRatio', label); - const accountModel = useAccountStore().getAccount(label); - try { - if (accountModel && accountModel.account) { - // if the account is already logged, we can update the staked ratio - return this.actualUpdateStakedRatio(label); - } else { - // if the account is not logged, we need to wait for the login and then update the staked ratio - return new Promise((resolve) => { - const sub = getAntelope().events.onAccountChanged.subscribe((result) => { - if (result.label === label) { - sub.unsubscribe(); - if (result.account) { - // we need the user to be logged because the way of getting the staked ratio is by - // executing an action from contract and that internally attempts retrieve the account from the provided signer - resolve(this.actualUpdateStakedRatio(label)); - } - } - }); - }); - } - } catch (error) { - console.error(error); - throw new Error('antelope.chain.error_staked_ratio'); - } - }, async updateGasPrice(label: string): Promise { useFeedbackStore().setLoading('updateGasPrice'); this.trace('updateGasPrice'); diff --git a/src/antelope/stores/contract.ts b/src/antelope/stores/contract.ts index 6b145ff4a..f587a2c72 100644 --- a/src/antelope/stores/contract.ts +++ b/src/antelope/stores/contract.ts @@ -13,12 +13,6 @@ import { defineStore } from 'pinia'; -import { - useAccountStore, - useChainStore, - useEVMStore, - getAntelope, -} from 'src/antelope'; import { AntelopeError, erc1155Abi, @@ -39,21 +33,20 @@ import EvmContract, { Erc20Transfer } from 'src/antelope/stores/utils/contracts/ import EvmContractFactory from 'src/antelope/stores/utils/contracts/EvmContractFactory'; import EVMChainSettings from 'src/antelope/chains/EVMChainSettings'; import { getTopicHash, toChecksumAddress, TRANSFER_SIGNATURES } from 'src/antelope/stores/utils'; -import { EVMAuthenticator } from 'src/antelope/wallets'; import { ethers } from 'ethers'; import { toRaw } from 'vue'; +// dependencies -- +import { + getAntelope, + useChainStore, + useEVMStore, +} from 'src/antelope'; + const LOCAL_SORAGE_CONTRACTS_KEY = 'antelope.contracts'; -const createManager = (authenticator?: EVMAuthenticator):EvmContractManagerI => ({ - getSigner: async () => { - if (!authenticator) { - return null; - } - const provider = await authenticator.web3Provider(); - const account = useAccountStore().getAccount(authenticator.label).account; - return provider.getSigner(account); - }, +const createManager = (signer?: ethers.Signer):EvmContractManagerI => ({ + getSigner: async () => signer ?? null, getWeb3Provider: () => getAntelope().wallets.getWeb3Provider(), getFunctionIface: (hash:string) => toRaw(useEVMStore().getFunctionIface(hash)), getEventIface: (hash:string) => toRaw(useEVMStore().getEventIface(hash)), @@ -68,6 +61,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'; @@ -151,9 +148,10 @@ export const useContractStore = defineStore(store_name, { * @param label identifier for the chain * @param address address of the contract * @param suspectedToken if you know the contract is a token, you can pass the type here to speed up the process + * @param signer if you want to use a specific signer, you can pass it here. Otherwise the contract will only query read-only functions * @returns the contract or null if it doesn't exist */ - async getContract(label: string, address:string, suspectedToken = ''): Promise { + async getContract(label: string, address:string, suspectedToken = '', signer?: ethers.Signer): Promise { this.trace('getContract', label, address, suspectedToken); const chainSettings = useChainStore().getChain(label).settings as EVMChainSettings; const network = chainSettings.getNetwork(); @@ -185,13 +183,20 @@ 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; // we ensure the contract hast the proper list of supported interfaces metadata.supportedInterfaces = metadata.supportedInterfaces || (suspectedToken ? [suspectedToken] : undefined); this.trace('getContract', 'returning cached metadata', address, [metadata]); - return this.createAndStoreContract(label, addressLower, metadata); + return this.createAndStoreContract(label, addressLower, metadata, signer); } // maybe we already starting processing it, return the promise @@ -206,18 +211,18 @@ export const useContractStore = defineStore(store_name, { if (chainSettings.isIndexerHealthy()) { try { // we have a healthy indexer, let's get it from there first - return await this.fetchContractUsingIndexer(label, address, suspectedToken); + return await this.fetchContractUsingIndexer(label, address, suspectedToken, signer); } catch (e) { console.warn('Indexer did not worked, falling back to hyperion'); - return await this.fetchContractUsingHyperion(label, address, suspectedToken); + return await this.fetchContractUsingHyperion(label, address, suspectedToken, signer); } } else { // we don't have a healthy indexer, let's get it from hyperion - return await this.fetchContractUsingHyperion(label, address, suspectedToken); + return await this.fetchContractUsingHyperion(label, address, suspectedToken, signer); } }, - async fetchContractUsingIndexer(label: string, address:string, suspectedToken = ''): Promise { + async fetchContractUsingIndexer(label: string, address:string, suspectedToken = '', signer?: ethers.Signer): Promise { this.trace('fetchContractUsingIndexer', label, address, suspectedToken); const network = useChainStore().getChain(label).settings.getNetwork(); const addressLower = address.toLowerCase(); @@ -236,7 +241,7 @@ export const useContractStore = defineStore(store_name, { console.warn(`Could not retrieve contract ${address}: ${e}`); throw new AntelopeError('antelope.contracts.error_retrieving_contract', { address }); } - const contract = this.createAndStoreContract(label, address, metadata); + const contract = this.createAndStoreContract(label, address, metadata, signer); resolve(contract); }); @@ -244,7 +249,7 @@ export const useContractStore = defineStore(store_name, { return this.__contracts[network].processing[addressLower]; }, - async fetchContractUsingHyperion(label: string, address:string, suspectedToken = ''): Promise { + async fetchContractUsingHyperion(label: string, address:string, suspectedToken = '', signer?: ethers.Signer): Promise { this.trace('fetchContractUsingHyperion', label, address, suspectedToken); const addressLower = address.toLowerCase(); const network = useChainStore().getChain(label).settings.getNetwork(); @@ -256,16 +261,16 @@ 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, signer)); } - const tokenContract = await this.createAndStoreContractFromTokenList(label, address, suspectedToken, creationInfo); + const tokenContract = await this.createAndStoreContractFromTokenList(label, address, suspectedToken, creationInfo, signer); if (tokenContract) { this.trace('fetchContractUsingHyperion', 'returning contract from token list', address, tokenContract); return resolve(tokenContract); } - const suspectedTokenContract = await this.createAndStoreContractFromSuspectedType(label, address, suspectedToken, creationInfo); + const suspectedTokenContract = await this.createAndStoreContractFromSuspectedType(label, address, suspectedToken, creationInfo, signer); if (suspectedTokenContract) { this.trace('fetchContractUsingHyperion', 'returning contract from suspected type', address, suspectedTokenContract); return resolve(suspectedTokenContract); @@ -273,7 +278,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, signer)); } else { // We mark this address as not existing so we don't query it again this.trace('fetchContractUsingHyperion', 'returning null', address); @@ -407,6 +412,7 @@ export const useContractStore = defineStore(store_name, { * @param metadata verified metadata of the contract * @param creationInfo creation info of the contract * @param suspectedType type of the contract. It can be 'erc20', 'erc721' or 'erc1155' + * @param signer signer to use for the contract if any * @returns the contract */ async createAndStoreVerifiedContract( @@ -415,10 +421,11 @@ export const useContractStore = defineStore(store_name, { metadata: EvmContractMetadata, creationInfo: EvmContractCreationInfo, suspectedType: string, - ): Promise { + signer?: ethers.Signer, + ): 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, @@ -426,7 +433,8 @@ export const useContractStore = defineStore(store_name, { creationInfo, verified: true, supportedInterfaces: [token?.type ?? 'none'], - } as EvmContractFactoryData); + } as EvmContractFactoryData, + signer); }, /** @@ -434,20 +442,23 @@ export const useContractStore = defineStore(store_name, { * @param label identifies the chain * @param address address of the contract * @param creationInfo creation info of the contract + * @param signer signer to use for the contract if any * @returns the contract */ async createAndStoreEmptyContract( label: string, address:string, creationInfo: EvmContractCreationInfo | null, - ): Promise { + signer?: ethers.Signer, + ): 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, supportedInterfaces: undefined, - } as EvmContractFactoryData); + } as EvmContractFactoryData, + signer); }, /** @@ -457,6 +468,7 @@ export const useContractStore = defineStore(store_name, { * @param address address of the contract * @param suspectedType type of the contract. It can be 'erc20', 'erc721' or 'erc1155' * @param creationInfo creation info of the contract + * @param signer signer to use for the contract if any * @returns the contract or null if the address is not in the token list */ async createAndStoreContractFromTokenList( @@ -464,6 +476,7 @@ export const useContractStore = defineStore(store_name, { address:string, suspectedType:string, creationInfo:EvmContractCreationInfo | null, + signer?: ethers.Signer, ): Promise { const token = await this.getToken(label, address, suspectedType); if (token) { @@ -475,7 +488,8 @@ export const useContractStore = defineStore(store_name, { abi, token, supportedInterfaces: [token.type], - } as EvmContractFactoryData); + } as EvmContractFactoryData, + signer); } else { return null; } @@ -488,6 +502,7 @@ export const useContractStore = defineStore(store_name, { * @param address address of the contract * @param suspectedType type of the contract. It can be 'erc20', 'erc721' or 'erc1155' * @param creationInfo creation info of the contract + * @param signer signer to use for the contract if any * @returns the contract or null if the type is not supported */ async createAndStoreContractFromSuspectedType( @@ -495,6 +510,7 @@ export const useContractStore = defineStore(store_name, { address:string, suspectedType:string, creationInfo:EvmContractCreationInfo | null, + signer?: ethers.Signer, ): Promise { const abi = this.getTokenABI(suspectedType); if (abi) { @@ -504,14 +520,15 @@ export const useContractStore = defineStore(store_name, { creationInfo, abi, supportedInterfaces: [suspectedType], - } as EvmContractFactoryData); + } as EvmContractFactoryData, + signer); } else { return null; } }, // commits ----- - createAndStoreContract(label: string, address: string, metadata: EvmContractFactoryData): EvmContract { + async createAndStoreContract(label: string, address: string, metadata: EvmContractFactoryData, signer?: ethers.Signer): Promise { const network = useChainStore().getChain(label).settings.getNetwork(); this.trace('createAndStoreContract', label, network, address, [metadata]); if (!address) { @@ -520,6 +537,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: @@ -532,7 +557,7 @@ export const useContractStore = defineStore(store_name, { || (metadata.abi ?? []).length > 0 && (metadata.abi ?? []).length > (this.__contracts[network].cached[index]?.abi?.length ?? 0) ) { // This manager provides the signer and the web3 provider - metadata.manager = createManager(useAccountStore().getAuthenticator(label) as EVMAuthenticator); + metadata.manager = createManager(signer); // we create the contract using the factory const contract = this.__factory.buildContract(metadata); @@ -555,10 +580,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/evm.ts b/src/antelope/stores/evm.ts index 9b74ca284..f8239a228 100644 --- a/src/antelope/stores/evm.ts +++ b/src/antelope/stores/evm.ts @@ -22,17 +22,16 @@ import { supportsInterfaceAbi, ERC721_TYPE, Collectible, - EthereumProvider, } from 'src/antelope/types'; import { toRaw } from 'vue'; +import { EVMAuthenticator, InjectedProviderAuth } from 'src/antelope/wallets'; +import { createTraceFunction } from 'src/antelope/config'; + +// dependencies -- import { - getAntelope, - useAccountStore, useChainStore, useFeedbackStore, } from 'src/antelope'; -import { EVMAuthenticator, InjectedProviderAuth } from 'src/antelope/wallets'; -import { createTraceFunction } from 'src/antelope/config'; const onEvmReady = new BehaviorSubject(false); @@ -62,92 +61,6 @@ export const useEVMStore = defineStore(store_name, { trace: createTraceFunction(store_name), // actions --- - async initInjectedProvider(authenticator: InjectedProviderAuth): Promise { - this.trace('initInjectedProvider', authenticator.getName(), [authenticator.getProvider()]); - const provider: EthereumProvider | null = authenticator.getProvider(); - const evm = useEVMStore(); - const ant = getAntelope(); - - if (provider && !provider.__initialized) { - this.trace('initInjectedProvider', authenticator.getName(), 'initializing provider'); - // ensure this provider actually has the correct methods - // Check consistency of the provider - const methods = ['request', 'on']; - const candidate = provider as unknown as Record; - for (const method of methods) { - if (typeof candidate[method] !== 'function') { - console.warn(`MetamaskAuth.getProvider: method ${method} not found`); - throw new AntelopeError('antelope.evm.error_invalid_provider'); - } - } - - // this handler activates only when the user comes back from switching to the wrong network on the wallet - // It checks if the user is on the correct network and if not, it shows a notification with a button to switch - const checkNetworkHandler = async () => { - window.removeEventListener('focus', checkNetworkHandler); - if (useAccountStore().loggedAccount) { - const authenticator = useAccountStore().loggedAccount.authenticator as EVMAuthenticator; - if (await authenticator.isConnectedToCorrectChain()) { - evm.trace('checkNetworkHandler', 'correct network'); - } else { - const networkName = useChainStore().loggedChain.settings.getDisplay(); - const errorMessage = ant.config.localizationHandler('evm_wallet.incorrect_network', { networkName }); - ant.config.notifyFailureWithAction(errorMessage, { - label: ant.config.localizationHandler('evm_wallet.switch'), - handler: () => { - authenticator.ensureCorrectChain(); - }, - }); - } - } - }; - - provider.on('chainChanged', (value) => { - const newNetwork = value as string; - evm.trace('provider.chainChanged', newNetwork); - window.removeEventListener('focus', checkNetworkHandler); - if (useAccountStore().loggedAccount) { - window.addEventListener('focus', checkNetworkHandler); - } - }); - - provider.on('accountsChanged', async (value) => { - const accounts = value as string[]; - const network = useChainStore().currentChain.settings.getNetwork(); - evm.trace('provider.accountsChanged', ...accounts); - - if (accounts.length > 0) { - // If we are here one of two possible things had happened: - // 1. The user has just logged in to the wallet - // 2. The user has switched the account in the wallet - - // if we are in case 1, then we are in the middle of the login process and we don't need to do anything - // We can tell because the account store has no logged account - - // But if we are in case 2 and have a logged account, we need to re-login the account using the same authenticator - // overwriting the previous logged account, which in turn will trigger all account data to be reloaded - if (useAccountStore().loggedAccount) { - // if the user is already authenticated we try to re login the account using the same authenticator - const authenticator = useAccountStore().loggedAccount.authenticator as EVMAuthenticator; - if (!authenticator) { - console.error('Inconsistency: logged account authenticator is null', authenticator); - } else { - useAccountStore().loginEVM({ authenticator, network }); - } - } - } else { - // the user has disconnected the all the accounts from the wallet so we logout - useAccountStore().logout(); - } - }); - - // This initialized property is not part of the standard provider, it's just a flag to know if we already initialized the provider - provider.__initialized = true; - evm.addInjectedProvider(authenticator); - } - authenticator.onReady.next(true); - }, - async isProviderOnTheCorrectChain(provider: ethers.providers.Web3Provider, correctChainId: string): Promise { const { chainId } = await provider.getNetwork(); const response = Number(chainId).toString() === correctChainId; diff --git a/src/antelope/stores/history.ts b/src/antelope/stores/history.ts index fec057f2f..7b85417b5 100644 --- a/src/antelope/stores/history.ts +++ b/src/antelope/stores/history.ts @@ -14,9 +14,6 @@ import { defineStore } from 'pinia'; -import { - useFeedbackStore, -} from 'src/antelope/stores/feedback'; import { Label, EvmTransaction, @@ -35,19 +32,22 @@ import EVMChainSettings from 'src/antelope/chains/EVMChainSettings'; import { useChainStore } from 'src/antelope/stores/chain'; import { toRaw } from 'vue'; import { BigNumber } from 'ethers'; +import { formatUnits } from 'ethers/lib/utils'; +import { getGasInTlos, WEI_PRECISION } from 'src/antelope/stores/utils'; +import { convertCurrency } from 'src/antelope/stores/utils/currency-utils'; +import { dateIsWithinXMinutes } from 'src/antelope/stores/utils/date-utils'; +import { createTraceFunction } from 'src/antelope/config'; + +// dependencies -- import { CURRENT_CONTEXT, getAntelope, useContractStore, useNftsStore, + useFeedbackStore, useTokensStore, useUserStore, -} from '..'; -import { formatUnits } from 'ethers/lib/utils'; -import { getGasInTlos, WEI_PRECISION } from 'src/antelope/stores/utils'; -import { convertCurrency } from 'src/antelope/stores/utils/currency-utils'; -import { dateIsWithinXMinutes } from 'src/antelope/stores/utils/date-utils'; -import { createTraceFunction } from 'src/antelope/config'; +} from 'src/antelope'; export const transfers_filter_limit = 10000; diff --git a/src/antelope/stores/nfts.ts b/src/antelope/stores/nfts.ts index f61454c3d..a894d5919 100644 --- a/src/antelope/stores/nfts.ts +++ b/src/antelope/stores/nfts.ts @@ -14,9 +14,8 @@ import { IndexerPaginationFilter, TransactionResponse, addressString, + AntelopeError, } from 'src/antelope/types'; - -import { useFeedbackStore, getAntelope, useChainStore, useEVMStore, CURRENT_CONTEXT } from 'src/antelope'; import { toRaw } from 'vue'; import { EvmAccountModel, useAccountStore } from 'src/antelope/stores/account'; import EVMChainSettings from 'src/antelope/chains/EVMChainSettings'; @@ -24,6 +23,15 @@ import { createTraceFunction, errorToString } from 'src/antelope/config'; import { truncateAddress } from 'src/antelope/stores/utils/text-utils'; import { subscribeForTransactionReceipt } from 'src/antelope/stores/utils/trx-utils'; +// dependencies -- +import { + CURRENT_CONTEXT, + getAntelope, + useFeedbackStore, + useChainStore, + useEVMStore, +} from 'src/antelope'; + export interface NFTsInventory { owner: Address; list: Collectible[]; @@ -34,6 +42,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 { @@ -217,7 +229,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, ); @@ -229,6 +249,7 @@ export const useNftsStore = defineStore(store_name, { contract: contractLower, list: [], loading: false, + entireCollectionFetched: false, }; } @@ -247,7 +268,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, @@ -272,6 +293,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 db370ef21..e79ce986a 100644 --- a/src/antelope/stores/rex.ts +++ b/src/antelope/stores/rex.ts @@ -8,19 +8,24 @@ import { ethers } from 'ethers'; import { defineStore } from 'pinia'; import { filter } from 'rxjs'; -import { - useFeedbackStore, -} from 'src/antelope/stores/feedback'; import { AntelopeError, EvmRexDeposit, Label, TransactionResponse } from 'src/antelope/types'; import { toRaw } from 'vue'; import { AccountModel, useAccountStore } from 'src/antelope/stores/account'; -import { CURRENT_CONTEXT, getAntelope, useBalancesStore, useChainStore, useContractStore } from 'src/antelope'; 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 { createTraceFunction } from 'src/antelope/config'; +import { prettyTimePeriod } from 'src/antelope/stores/utils/date-utils'; +// dependencies -- +import { + CURRENT_CONTEXT, + getAntelope, + useFeedbackStore, + useBalancesStore, + useChainStore, + useContractStore, +} from 'src/antelope'; export interface RexModel { withdrawable: ethers.BigNumber; @@ -54,7 +59,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/tokens.ts b/src/antelope/stores/tokens.ts index 274c2f929..1e1984c3b 100644 --- a/src/antelope/stores/tokens.ts +++ b/src/antelope/stores/tokens.ts @@ -9,7 +9,6 @@ import { TokenClass, TokenPrice, } from 'src/antelope/types'; -import { getAntelope, useFeedbackStore, useChainStore, CURRENT_CONTEXT } from 'src/antelope'; import { toRaw } from 'vue'; import { createTraceFunction, errorToString } from 'src/antelope/config'; import { filter } from 'rxjs'; @@ -18,6 +17,15 @@ import { dateIsWithinXMinutes } from 'src/antelope/stores/utils/date-utils'; import { getTokenPriceDataFromIndexer } from 'src/api/price'; import EVMChainSettings from 'src/antelope/chains/EVMChainSettings'; +// dependencies -- +import { + CURRENT_CONTEXT, + getAntelope, + useFeedbackStore, + useChainStore, +} from 'src/antelope'; + + export interface TokensState { __tokens: { [label: Label]: TokenClass[] }; __prices: { diff --git a/src/antelope/stores/user.ts b/src/antelope/stores/user.ts index e66e63327..ee211d834 100644 --- a/src/antelope/stores/user.ts +++ b/src/antelope/stores/user.ts @@ -15,7 +15,12 @@ import { defineStore } from 'pinia'; import { createTraceFunction, errorToString } from 'src/antelope/config'; import { AccountModel } from 'src/antelope/stores/account'; -import { getAntelope, useFeedbackStore } from 'src/antelope'; + +// dependencies -- +import { + getAntelope, + useFeedbackStore, +} from 'src/antelope'; export type AccountList = Array; 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/abi/stlosAbi.ts b/src/antelope/stores/utils/abi/stlosAbi.ts index 092d44eda..9a6bb33b0 100644 --- a/src/antelope/stores/utils/abi/stlosAbi.ts +++ b/src/antelope/stores/utils/abi/stlosAbi.ts @@ -47,3 +47,52 @@ export const stlosAbiWithdraw: EvmABI = [ type: 'function', }, ]; + + + +export const stlosAbiPreviewRedeem: EvmABI = [ + { + inputs: [ + { + indexed: false, + internalType: 'uint256', + name: 'shares', + type: 'uint256', + }, + ], + name: 'previewRedeem', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, +]; + +export const stlosAbiPreviewDeposit: EvmABI = [ + { + inputs: [ + { + indexed: false, + internalType: 'uint256', + name: 'assets', + type: 'uint256', + }, + ], + name: 'previewDeposit', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, +]; + 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/ChainSettings.ts b/src/antelope/types/ChainSettings.ts index 36a081469..62c33e7fb 100644 --- a/src/antelope/types/ChainSettings.ts +++ b/src/antelope/types/ChainSettings.ts @@ -26,6 +26,6 @@ export interface ChainSettings { getSystemTokens(): TokenClass[]; getNftsForAccount(address: string, filter: IndexerAccountNftsFilter): Promise; getNftsForCollection(contract: string, filter: IndexerCollectionNftsFilter): Promise; - trackAnalyticsEvent(params: Record): void; + trackAnalyticsEvent(eventName: string): void; getApy(): Promise; } 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 3840be83a..a208ae070 100644 --- a/src/antelope/wallets/authenticators/EVMAuthenticator.ts +++ b/src/antelope/wallets/authenticators/EVMAuthenticator.ts @@ -8,7 +8,8 @@ import { AntelopeDebugTraceType, createTraceFunction } from 'src/antelope/config import { useChainStore } from 'src/antelope/stores/chain'; import { useEVMStore } from 'src/antelope/stores/evm'; 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 { @@ -254,7 +255,7 @@ export abstract class EVMAuthenticator { return this.signCustomTransaction(contractAddress, [transferAbi[0]], [from, to, tokenId]); }else if (type === ERC1155_TYPE) { const transferAbi = erc1155Abi.filter((abi: EvmABIEntry) => abi.name === 'safeTransferFrom'); - return this.signCustomTransaction(contractAddress, [transferAbi[0]], [from, to, tokenId, quantity, '0x0']); + return this.signCustomTransaction(contractAddress, [transferAbi[0]], [from, to, tokenId, quantity, '0x']); } } else { throw new AntelopeError('antelope.balances.error_token_contract_not_found', { address: contractAddress }); @@ -370,6 +371,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/antelope/wallets/authenticators/InjectedProviderAuth.ts b/src/antelope/wallets/authenticators/InjectedProviderAuth.ts index 4439ec142..a893f27ae 100644 --- a/src/antelope/wallets/authenticators/InjectedProviderAuth.ts +++ b/src/antelope/wallets/authenticators/InjectedProviderAuth.ts @@ -2,7 +2,7 @@ import { BigNumber, ethers } from 'ethers'; import { BehaviorSubject, filter, map } from 'rxjs'; -import { useEVMStore, useFeedbackStore } from 'src/antelope'; +import { getAntelope, useAccountStore, useChainStore, useEVMStore, useFeedbackStore } from 'src/antelope'; import { AntelopeError, EthereumProvider, @@ -12,8 +12,8 @@ import { addressString, } from 'src/antelope/types'; import { EVMAuthenticator } from 'src/antelope/wallets'; -import { TELOS_NETWORK_NAMES, TELOS_ANALYTICS_EVENT_IDS } from 'src/antelope/chains/chain-constants'; -import { MetamaskAuthName, SafePalAuthName } from 'src/antelope/wallets'; +import { TELOS_NETWORK_NAMES, TELOS_ANALYTICS_EVENT_NAMES } from 'src/antelope/chains/chain-constants'; +import { BraveAuthName, MetamaskAuthName, SafePalAuthName } from 'src/antelope/wallets'; export abstract class InjectedProviderAuth extends EVMAuthenticator { onReady = new BehaviorSubject(false); @@ -21,9 +21,95 @@ export abstract class InjectedProviderAuth extends EVMAuthenticator { // this is just a dummy label to identify the authenticator base class constructor(label: string) { super(label); - useEVMStore().initInjectedProvider(this); + this.initInjectedProvider(this); } + async initInjectedProvider(authenticator: InjectedProviderAuth): Promise { + this.trace('initInjectedProvider', authenticator.getName(), [authenticator.getProvider()]); + const provider: EthereumProvider | null = authenticator.getProvider(); + const ant = getAntelope(); + + if (provider && !provider.__initialized) { + this.trace('initInjectedProvider', authenticator.getName(), 'initializing provider'); + // ensure this provider actually has the correct methods + // Check consistency of the provider + const methods = ['request', 'on']; + const candidate = provider as unknown as Record; + for (const method of methods) { + if (typeof candidate[method] !== 'function') { + console.warn(`MetamaskAuth.getProvider: method ${method} not found`); + throw new AntelopeError('antelope.evm.error_invalid_provider'); + } + } + + // this handler activates only when the user comes back from switching to the wrong network on the wallet + // It checks if the user is on the correct network and if not, it shows a notification with a button to switch + const checkNetworkHandler = async () => { + window.removeEventListener('focus', checkNetworkHandler); + if (useAccountStore().loggedAccount) { + const authenticator = useAccountStore().loggedAccount.authenticator as EVMAuthenticator; + if (await authenticator.isConnectedToCorrectChain()) { + this.trace('checkNetworkHandler', 'correct network'); + } else { + const networkName = useChainStore().loggedChain.settings.getDisplay(); + const errorMessage = ant.config.localizationHandler('evm_wallet.incorrect_network', { networkName }); + ant.config.notifyFailureWithAction(errorMessage, { + label: ant.config.localizationHandler('evm_wallet.switch'), + handler: () => { + authenticator.ensureCorrectChain(); + }, + }); + } + } + }; + + provider.on('chainChanged', (value) => { + const newNetwork = value as string; + this.trace('provider.chainChanged', newNetwork); + window.removeEventListener('focus', checkNetworkHandler); + if (useAccountStore().loggedAccount) { + window.addEventListener('focus', checkNetworkHandler); + } + }); + + provider.on('accountsChanged', async (value) => { + const accounts = value as string[]; + const network = useChainStore().currentChain.settings.getNetwork(); + this.trace('provider.accountsChanged', ...accounts); + + if (accounts.length > 0) { + // If we are here one of two possible things had happened: + // 1. The user has just logged in to the wallet + // 2. The user has switched the account in the wallet + + // if we are in case 1, then we are in the middle of the login process and we don't need to do anything + // We can tell because the account store has no logged account + + // But if we are in case 2 and have a logged account, we need to re-login the account using the same authenticator + // overwriting the previous logged account, which in turn will trigger all account data to be reloaded + if (useAccountStore().loggedAccount) { + // if the user is already authenticated we try to re login the account using the same authenticator + const authenticator = useAccountStore().loggedAccount.authenticator as EVMAuthenticator; + if (!authenticator) { + console.error('Inconsistency: logged account authenticator is null', authenticator); + } else { + useAccountStore().loginEVM({ authenticator, network }); + } + } + } else { + // the user has disconnected the all the accounts from the wallet so we logout + useAccountStore().logout(); + } + }); + + // This initialized property is not part of the standard provider, it's just a flag to know if we already initialized the provider + provider.__initialized = true; + useEVMStore().addInjectedProvider(authenticator); + } + authenticator.onReady.next(true); + } + + async login(network: string): Promise { const chainSettings = this.getChainSettings(); const authName = this.getName(); @@ -32,31 +118,27 @@ export abstract class InjectedProviderAuth extends EVMAuthenticator { useFeedbackStore().setLoading(`${this.getName()}.login`); this.trace('login', 'trackAnalyticsEvent -> login started'); - chainSettings.trackAnalyticsEvent( - { id: TELOS_ANALYTICS_EVENT_IDS.loginStarted }, - ); + chainSettings.trackAnalyticsEvent(TELOS_ANALYTICS_EVENT_NAMES.loginStarted); const response = await super.login(network).then((res) => { if (TELOS_NETWORK_NAMES.includes(network)) { - let successfulLoginEventId = ''; + let successfulLoginEventName = ''; if (authName === MetamaskAuthName) { - successfulLoginEventId = TELOS_ANALYTICS_EVENT_IDS.loginSuccessfulMetamask; + successfulLoginEventName = TELOS_ANALYTICS_EVENT_NAMES.loginSuccessfulMetamask; } else if (authName === SafePalAuthName) { - successfulLoginEventId = TELOS_ANALYTICS_EVENT_IDS.loginSuccessfulSafepal; + successfulLoginEventName = TELOS_ANALYTICS_EVENT_NAMES.loginSuccessfulSafepal; + } else if (authName === BraveAuthName) { + successfulLoginEventName = TELOS_ANALYTICS_EVENT_NAMES.loginSuccessfulBrave; } - if (successfulLoginEventId) { - this.trace('login', 'trackAnalyticsEvent -> login succeeded', authName, successfulLoginEventId); - chainSettings.trackAnalyticsEvent( - { id: successfulLoginEventId }, - ); + if (successfulLoginEventName) { + this.trace('login', 'trackAnalyticsEvent -> login succeeded', authName, successfulLoginEventName); + chainSettings.trackAnalyticsEvent(successfulLoginEventName); } - this.trace('login', 'trackAnalyticsEvent -> generic login succeeded', TELOS_ANALYTICS_EVENT_IDS.loginSuccessful); - chainSettings.trackAnalyticsEvent( - { id: TELOS_ANALYTICS_EVENT_IDS.loginSuccessful }, - ); + this.trace('login', 'trackAnalyticsEvent -> generic login succeeded', authName, TELOS_ANALYTICS_EVENT_NAMES.loginSuccessful); + chainSettings.trackAnalyticsEvent(TELOS_ANALYTICS_EVENT_NAMES.loginSuccessful); } return res; @@ -69,16 +151,16 @@ export abstract class InjectedProviderAuth extends EVMAuthenticator { let failedLoginEventId = ''; if (authName === MetamaskAuthName) { - failedLoginEventId = TELOS_ANALYTICS_EVENT_IDS.loginFailedMetamask; + failedLoginEventId = TELOS_ANALYTICS_EVENT_NAMES.loginFailedMetamask; } else if (authName === SafePalAuthName) { - failedLoginEventId = TELOS_ANALYTICS_EVENT_IDS.loginFailedSafepal; + failedLoginEventId = TELOS_ANALYTICS_EVENT_NAMES.loginFailedSafepal; + } else if (authName === BraveAuthName) { + failedLoginEventId = TELOS_ANALYTICS_EVENT_NAMES.loginFailedBrave; } if (failedLoginEventId) { this.trace('login', 'trackAnalyticsEvent -> login failed', authName, failedLoginEventId); - chainSettings.trackAnalyticsEvent( - { id: failedLoginEventId }, - ); + chainSettings.trackAnalyticsEvent(failedLoginEventId); } } }).finally(() => { diff --git a/src/antelope/wallets/authenticators/OreIdAuth.ts b/src/antelope/wallets/authenticators/OreIdAuth.ts index 15dff2034..d09552d52 100644 --- a/src/antelope/wallets/authenticators/OreIdAuth.ts +++ b/src/antelope/wallets/authenticators/OreIdAuth.ts @@ -11,10 +11,10 @@ import { addressString, EvmTransactionResponse, } from 'src/antelope/types'; -import { useFeedbackStore } from 'src/antelope/stores/feedback'; +import { useFeedbackStore } from 'src/antelope'; import { useChainStore } from 'src/antelope/stores/chain'; import { RpcEndpoint } from 'universal-authenticator-library'; -import { TELOS_ANALYTICS_EVENT_IDS } from 'src/antelope/chains/chain-constants'; +import { TELOS_ANALYTICS_EVENT_NAMES } from 'src/antelope/chains/chain-constants'; const name = 'OreId'; export const OreIdAuthName = name; @@ -64,14 +64,10 @@ export class OreIdAuth extends EVMAuthenticator { this.trace('login', network); const chainSettings = this.getChainSettings(); const trackSuccessfulLogin = () => { - this.trace('login', 'trackAnalyticsEvent -> generic login succeeded', TELOS_ANALYTICS_EVENT_IDS.loginSuccessful); - chainSettings.trackAnalyticsEvent( - { id: TELOS_ANALYTICS_EVENT_IDS.loginSuccessful }, - ); - this.trace('login', 'trackAnalyticsEvent -> login succeeded', this.getName(), TELOS_ANALYTICS_EVENT_IDS.loginSuccessfulOreId); - chainSettings.trackAnalyticsEvent( - { id: TELOS_ANALYTICS_EVENT_IDS.loginSuccessfulOreId }, - ); + this.trace('login', 'trackAnalyticsEvent -> generic login succeeded', TELOS_ANALYTICS_EVENT_NAMES.loginSuccessful); + chainSettings.trackAnalyticsEvent(TELOS_ANALYTICS_EVENT_NAMES.loginSuccessful); + this.trace('login', 'trackAnalyticsEvent -> login succeeded', this.getName(), TELOS_ANALYTICS_EVENT_NAMES.loginSuccessfulOreId); + chainSettings.trackAnalyticsEvent(TELOS_ANALYTICS_EVENT_NAMES.loginSuccessfulOreId); }; useFeedbackStore().setLoading(`${this.getName()}.login`); @@ -93,18 +89,14 @@ export class OreIdAuth extends EVMAuthenticator { this.trace('login', 'userChainAccount', this.userChainAccount); // track the login start for auto-login procceess this.trace('login', 'trackAnalyticsEvent -> login started'); - chainSettings.trackAnalyticsEvent( - { id: TELOS_ANALYTICS_EVENT_IDS.loginStarted }, - ); + chainSettings.trackAnalyticsEvent(TELOS_ANALYTICS_EVENT_NAMES.loginStarted); // then track the successful login trackSuccessfulLogin(); return chainAccount; } this.trace('login', 'trackAnalyticsEvent -> login started'); - chainSettings.trackAnalyticsEvent( - { id: TELOS_ANALYTICS_EVENT_IDS.loginStarted }, - ); + chainSettings.trackAnalyticsEvent(TELOS_ANALYTICS_EVENT_NAMES.loginStarted); // launch the login flow await oreId.popup.auth({ provider: this.provider as AuthProvider }); @@ -118,10 +110,8 @@ export class OreIdAuth extends EVMAuthenticator { const appName = this.options.appName; const networkName = useChainStore().getNetworkSettings(network).getDisplay(); - this.trace('login', 'trackAnalyticsEvent -> login failed', this.getName(), TELOS_ANALYTICS_EVENT_IDS.loginFailedOreId); - chainSettings.trackAnalyticsEvent( - { id: TELOS_ANALYTICS_EVENT_IDS.loginFailedOreId }, - ); + this.trace('login', 'trackAnalyticsEvent -> login failed', this.getName(), TELOS_ANALYTICS_EVENT_NAMES.loginFailedOreId); + chainSettings.trackAnalyticsEvent(TELOS_ANALYTICS_EVENT_NAMES.loginFailedOreId); throw new AntelopeError('antelope.wallets.error_oreid_no_chain_account', { networkName, diff --git a/src/antelope/wallets/authenticators/WalletConnectAuth.ts b/src/antelope/wallets/authenticators/WalletConnectAuth.ts index 5010b55c5..a346afced 100644 --- a/src/antelope/wallets/authenticators/WalletConnectAuth.ts +++ b/src/antelope/wallets/authenticators/WalletConnectAuth.ts @@ -17,11 +17,13 @@ import { } from '@web3modal/ethereum'; import { Web3Modal, Web3ModalConfig } from '@web3modal/html'; import { BigNumber, ethers } from 'ethers'; -import { TELOS_ANALYTICS_EVENT_IDS } from 'src/antelope/chains/chain-constants'; +import { TELOS_ANALYTICS_EVENT_NAMES } from 'src/antelope/chains/chain-constants'; import { useChainStore } from 'src/antelope/stores/chain'; -import { useContractStore } from 'src/antelope/stores/contract'; -import { useFeedbackStore } from 'src/antelope/stores/feedback'; -import { usePlatformStore } from 'src/antelope/stores/platform'; +import { + useContractStore, + useFeedbackStore, + usePlatformStore, +} from 'src/antelope'; import { AntelopeError, EvmABI, @@ -106,19 +108,15 @@ export class WalletConnectAuth extends EVMAuthenticator { 'login', 'trackAnalyticsEvent -> login successful', 'WalletConnect', - TELOS_ANALYTICS_EVENT_IDS.loginSuccessfulWalletConnect, - ); - chainSettings.trackAnalyticsEvent( - { id: TELOS_ANALYTICS_EVENT_IDS.loginSuccessfulWalletConnect }, + TELOS_ANALYTICS_EVENT_NAMES.loginSuccessfulWalletConnect, ); + chainSettings.trackAnalyticsEvent(TELOS_ANALYTICS_EVENT_NAMES.loginSuccessfulWalletConnect); this.trace( 'login', 'trackAnalyticsEvent -> generic login successful', - TELOS_ANALYTICS_EVENT_IDS.loginSuccessful, - ); - chainSettings.trackAnalyticsEvent( - { id: TELOS_ANALYTICS_EVENT_IDS.loginSuccessful }, + TELOS_ANALYTICS_EVENT_NAMES.loginSuccessful, ); + chainSettings.trackAnalyticsEvent(TELOS_ANALYTICS_EVENT_NAMES.loginSuccessful); return address; } catch (e) { @@ -128,12 +126,10 @@ export class WalletConnectAuth extends EVMAuthenticator { 'walletConnectLogin', 'trackAnalyticsEvent -> login failed', 'WalletConnect', - TELOS_ANALYTICS_EVENT_IDS.loginFailedWalletConnect, + TELOS_ANALYTICS_EVENT_NAMES.loginFailedWalletConnect, ); const chainSettings = this.getChainSettings(); - chainSettings.trackAnalyticsEvent( - { id: TELOS_ANALYTICS_EVENT_IDS.loginFailedWalletConnect }, - ); + chainSettings.trackAnalyticsEvent(TELOS_ANALYTICS_EVENT_NAMES.loginFailedWalletConnect); throw new AntelopeError('antelope.evm.error_login'); } finally { useFeedbackStore().unsetLoading(`${this.getName()}.login`); @@ -152,11 +148,9 @@ export class WalletConnectAuth extends EVMAuthenticator { 'login', 'trackAnalyticsEvent -> login started', 'WalletConnect', - TELOS_ANALYTICS_EVENT_IDS.loginStarted, - ); - chainSettings.trackAnalyticsEvent( - { id: TELOS_ANALYTICS_EVENT_IDS.loginStarted }, + TELOS_ANALYTICS_EVENT_NAMES.loginStarted, ); + chainSettings.trackAnalyticsEvent(TELOS_ANALYTICS_EVENT_NAMES.loginStarted); return this.walletConnectLogin(network); } else { return new Promise(async (resolve) => { @@ -170,11 +164,9 @@ export class WalletConnectAuth extends EVMAuthenticator { 'login', 'trackAnalyticsEvent -> login started', 'WalletConnect', - TELOS_ANALYTICS_EVENT_IDS.loginStarted, - ); - chainSettings.trackAnalyticsEvent( - { id: TELOS_ANALYTICS_EVENT_IDS.loginStarted }, + TELOS_ANALYTICS_EVENT_NAMES.loginStarted, ); + chainSettings.trackAnalyticsEvent(TELOS_ANALYTICS_EVENT_NAMES.loginStarted); } if (newState.open === false) { @@ -185,11 +177,9 @@ export class WalletConnectAuth extends EVMAuthenticator { 'login', 'trackAnalyticsEvent -> login failed', 'WalletConnect', - TELOS_ANALYTICS_EVENT_IDS.loginFailedWalletConnect, - ); - chainSettings.trackAnalyticsEvent( - { id: TELOS_ANALYTICS_EVENT_IDS.loginFailedWalletConnect }, + TELOS_ANALYTICS_EVENT_NAMES.loginFailedWalletConnect, ); + chainSettings.trackAnalyticsEvent(TELOS_ANALYTICS_EVENT_NAMES.loginFailedWalletConnect); } // this prevents multiple subscribers from being attached to the web3Modal diff --git a/src/antelope/wallets/index.ts b/src/antelope/wallets/index.ts index d09b2caf6..3f42a76ec 100644 --- a/src/antelope/wallets/index.ts +++ b/src/antelope/wallets/index.ts @@ -4,4 +4,5 @@ export * from 'src/antelope/wallets/authenticators/MetamaskAuth'; export * from 'src/antelope/wallets/authenticators/OreIdAuth'; export * from 'src/antelope/wallets/authenticators/WalletConnectAuth'; export * from 'src/antelope/wallets/authenticators/SafePalAuth'; +export * from 'src/antelope/wallets/authenticators/BraveAuth'; export * from 'src/antelope/wallets/AntelopeWallets'; 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/boot/antelope.ts b/src/boot/antelope.ts index 635ae7fe0..7c9fe61ed 100644 --- a/src/boot/antelope.ts +++ b/src/boot/antelope.ts @@ -34,6 +34,7 @@ export default boot(({ app }) => { ant.config.setNotifySuccessCopyHandler(app.config.globalProperties.$notifySuccessCopy); ant.config.setNotifyFailureMessage(app.config.globalProperties.$notifyFailure); ant.config.setNotifyFailureWithAction(app.config.globalProperties.$notifyFailureWithAction); + ant.config.setNotifyWarningWithAction(app.config.globalProperties.$notifyWarningWithAction); ant.config.setNotifyDisconnectedHandler(app.config.globalProperties.$notifyDisconnected); ant.config.setNotifyNeutralMessageHandler(app.config.globalProperties.$notifyNeutralMessage); ant.config.setNotifyRememberInfoHandler(app.config.globalProperties.$notifyRememberInfo); diff --git a/src/boot/errorHandling.js b/src/boot/errorHandling.js index 79ae3957b..c2c714c92 100644 --- a/src/boot/errorHandling.js +++ b/src/boot/errorHandling.js @@ -67,6 +67,7 @@ const crossIcon = require('src/assets/icon--cross.svg'); const infoIcon = require('src/assets/icon--info.svg'); const checkIcon = require('src/assets/icon--check.svg'); const discoIcon = require('src/assets/icon--disconnected.svg'); +const warningIcon = require('src/assets/icon--warning.svg'); const html = `
@@ -257,6 +258,16 @@ const notifyFailureWithAction = function(message, payload) { ); }; +const notifyWarningWithAction = function(message, payload) { + return notifyMessage.bind(this)( + 'error', + warningIcon, + this.$t('notification.warning_title').toUpperCase(), + message, + new NotificationAction(payload), + ); +}; + const notifyDisconnected = function() { return notifyMessage.bind(this)( 'error', @@ -331,6 +342,7 @@ export default boot(({ app, store }) => { app.config.globalProperties.$notifySuccessCopy = notifySuccessCopy.bind(store); app.config.globalProperties.$notifyFailure = notifyFailure.bind(store); app.config.globalProperties.$notifyFailureWithAction = notifyFailureWithAction.bind(store); + app.config.globalProperties.$notifyWarningWithAction = notifyWarningWithAction.bind(store); app.config.globalProperties.$notifyDisconnected = notifyDisconnected.bind(store); app.config.globalProperties.$notifyNeutralMessage = notifyNeutralMessage.bind(store); app.config.globalProperties.$notifyRememberInfo = notifyRememberInfo.bind(store); @@ -339,6 +351,7 @@ export default boot(({ app, store }) => { store['$notifySuccessCopy'] = app.config.globalProperties.$notifySuccessCopy; store['$notifyFailure'] = app.config.globalProperties.$notifyFailure; store['$notifyFailureWithAction'] = app.config.globalProperties.$notifyFailureWithAction; + store['$notifyWarningWithAction'] = app.config.globalProperties.$notifyWarningWithAction; store['$notifyDisconnected'] = app.config.globalProperties.$notifyDisconnected; store['$notifyNeutralMessage'] = app.config.globalProperties.$notifyNeutralMessage; store['$notifyRememberInfo'] = app.config.globalProperties.$notifyRememberInfo; diff --git a/src/boot/ual.js b/src/boot/ual.js index 7271a10c6..3f988b301 100644 --- a/src/boot/ual.js +++ b/src/boot/ual.js @@ -26,7 +26,7 @@ export default boot(async ({ app, store }) => { if (localStorage.getItem('autoLogin') === 'cleos') { accountName = localStorage.getItem('account'); } else { - await new Promise((resolve) => { + await new Promise((resolve, reject) => { Dialog.create({ class: 'cleos-auth-dialog', color: 'primary', @@ -43,11 +43,13 @@ export default boot(async ({ app, store }) => { accountName = data !== '' ? data : 'eosio'; }) .onCancel(() => { - throw 'Cancelled!'; + reject('cancelled'); }) .onDismiss(() => { resolve(true); }); + }).catch((e) => { + throw e; }); if (store.state.account.justViewer) { permission = 'active'; @@ -73,7 +75,7 @@ export default boot(async ({ app, store }) => { permission = data; }) .onCancel(() => { - throw 'Cancelled!'; + throw 'cancelled'; }) .onDismiss(() => { resolve(true); @@ -133,7 +135,7 @@ export default boot(async ({ app, store }) => { }); }) .onCancel(() => { - throw 'Cancelled!'; + throw 'cancelled'; }) .onDismiss(() => { resolve(true); @@ -142,18 +144,13 @@ export default boot(async ({ app, store }) => { } const authenticators = [ - new Wombat([chain], { appName: process.env.APP_NAME }), new Anchor([chain], { appName: process.env.APP_NAME }), + new Wombat([chain], { appName: process.env.APP_NAME }), new CleosAuthenticator([chain], { appName: process.env.APP_NAME, loginHandler, signHandler, }), - new OreIdAuthenticator([chain], { - appId: process.env.OREID_APP_ID_NATIVE, - plugins: { popup: WebPopup() }, - }, - AuthProvider.Google), ]; const ual = new UAL([chain], 'ual', authenticators); 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..64dc2e8bb 100644 --- a/src/components/evm/AppNav.vue +++ b/src/components/evm/AppNav.vue @@ -147,6 +147,10 @@ export default defineComponent({ const network = this.loggedAccount.network; window.open(chainStore.getEcosystemUrl(network), '_blank'); }, + goToExplorer() { + const network = this.loggedAccount.network; + window.open(chainStore.getExplorerUrl(network), '_blank'); + }, }, }); @@ -252,25 +256,6 @@ export default defineComponent({ /> {{ $t('nav.wallet') }} -
  • + + +
  • +