From bb6fd5c017b80996c5c494fbe4f8b504466ae9f3 Mon Sep 17 00:00:00 2001 From: Ezra Sowden-Guzman Date: Fri, 8 Dec 2023 15:52:52 -0500 Subject: [PATCH 01/34] add single erc20 allowance update capability --- src/antelope/stores/allowances.ts | 38 ++++++++++++++ src/i18n/en-us/index.js | 4 +- .../evm/allowances/EditAllowanceModal.vue | 49 +++++++++++++++++-- 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/src/antelope/stores/allowances.ts b/src/antelope/stores/allowances.ts index 281752c6..9076e5c3 100644 --- a/src/antelope/stores/allowances.ts +++ b/src/antelope/stores/allowances.ts @@ -27,6 +27,7 @@ import { ShapedAllowanceRowSingleERC721, ShapedCollectionAllowanceRow, Sort, + TransactionResponse, isErc20AllowanceRow, isErc721SingleAllowanceRow, isIndexerAllowanceResponseErc1155, @@ -292,6 +293,43 @@ export const useAllowancesStore = defineStore(store_name, { 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 tokenContract = await useContractStore().getContract(CURRENT_CONTEXT, tokenContractAddress); + const tokenContractInstance = await tokenContract?.getContractInstance(); + + if (!tokenContractInstance) { + // eztodo antelope error + console.error('Error getting token contract instance'); + throw 'eztodo'; + } + + const tx = await tokenContractInstance.approve(spender, allowance); + + tx.wait().then(() => { + setTimeout(() => { + this.fetchAllowancesForAccount(owner).then(() => { + useFeedbackStore().unsetLoading('updateErc20Allowance'); + }); + }, 3000); // give the indexer time to update allowance data + }); + + return tx; + } catch(error) { + const trxError = getAntelope().config.transactionError('antelope.evm.error_updating_allowance', error); + getAntelope().config.transactionErrorHandler(trxError, 'updateErc20Allowance'); + useFeedbackStore().unsetLoading('updateErc20Allowance'); + throw trxError; + } + }, // commits setErc20Allowances(label: Label, allowances: ShapedAllowanceRowERC20[]) { diff --git a/src/i18n/en-us/index.js b/src/i18n/en-us/index.js index cbd86770..10b35d26 100644 --- a/src/i18n/en-us/index.js +++ b/src/i18n/en-us/index.js @@ -341,6 +341,7 @@ export default { neutral_message_wrapping: 'Wrapping {quantity} {symbol}', neutral_message_unwrapping: 'Unwrapping {quantity} {symbol}', neutral_message_withdrawing: 'Withdrawing {quantity} {symbol}', + neutral_message_updating_erc20_allowance: 'Updating {symbol} allowance for {spender}', dont_show_message_again: 'Don\'t show me this message again', }, resources: { @@ -619,7 +620,8 @@ export default { error_unstakes_failed: 'An unknown error occurred when unstaking tokens', error_withdraw_failed: 'An unknown error occurred when withdrawing tokens', error_fetching_token_price: 'An unknown error occurred when fetching token price data', - error_transfer_nft: 'An error occured while transferring collectible', + error_transfer_nft: 'An error occurred while transferring collectible', + error_updating_allowance: 'An error occurred while updating allowance', }, history: { error_fetching_transactions: 'Unexpected error fetching transactions. Please refresh the page to try again.', diff --git a/src/pages/evm/allowances/EditAllowanceModal.vue b/src/pages/evm/allowances/EditAllowanceModal.vue index 1ab2f2bb..776c403b 100644 --- a/src/pages/evm/allowances/EditAllowanceModal.vue +++ b/src/pages/evm/allowances/EditAllowanceModal.vue @@ -1,5 +1,5 @@ From 6b9ec4c39e58fbdab26b39933e4143788d51ba8d Mon Sep 17 00:00:00 2001 From: Ezra Sowden-Guzman Date: Mon, 11 Dec 2023 19:04:35 -0500 Subject: [PATCH 05/34] cleanup, fix pagination --- src/pages/evm/allowances/AllowancesTable.vue | 1 + .../evm/allowances/EditAllowanceModal.vue | 98 +++++++------------ 2 files changed, 39 insertions(+), 60 deletions(-) diff --git a/src/pages/evm/allowances/AllowancesTable.vue b/src/pages/evm/allowances/AllowancesTable.vue index b6ee9c4a..fb03018b 100644 --- a/src/pages/evm/allowances/AllowancesTable.vue +++ b/src/pages/evm/allowances/AllowancesTable.vue @@ -96,6 +96,7 @@ watch(tableRows, (newRows) => { revokeAllCheckboxChecked.value = false; updateAllRevokeCheckboxes(); + pagination.value.rowsNumber = props.rows.length; }, { immediate: true }); // methods diff --git a/src/pages/evm/allowances/EditAllowanceModal.vue b/src/pages/evm/allowances/EditAllowanceModal.vue index 279455fc..94560f30 100644 --- a/src/pages/evm/allowances/EditAllowanceModal.vue +++ b/src/pages/evm/allowances/EditAllowanceModal.vue @@ -26,6 +26,7 @@ import ExternalLink from 'src/components/ExternalLink.vue'; import ToolTip from 'src/components/ToolTip.vue'; import CurrencyInput from 'components/evm/inputs/CurrencyInput.vue'; import { truncateAddress } from 'src/antelope/stores/utils/text-utils'; +import { TransactionResponse } from 'src/antelope/types/EvmTransaction'; enum Erc20AllowanceAmountOptions { none = 'none', @@ -159,36 +160,26 @@ onBeforeMount(() => { }); async function handleSubmit() { + let tx: TransactionResponse; + let neutralMessageText: string; + if (rowIsErc20Row.value) { - const tx = await useAllowancesStore().updateErc20Allowance( + tx = await useAllowancesStore().updateErc20Allowance( userAddress.value, props.row.spenderAddress, rowAsErc20Row.value.tokenAddress, newErc20AllowanceAmount.value, ); - const dismiss = ant.config.notifyNeutralMessageHandler( - $t( - 'notification.neutral_message_updating_erc20_allowance', - { - symbol: rowAsErc20Row.value.tokenSymbol, - spender: props.row.spenderName || truncateAddress(props.row.spenderAddress), - }, - ), + neutralMessageText = $t( + 'notification.neutral_message_updating_erc20_allowance', + { + symbol: rowAsErc20Row.value.tokenSymbol, + spender: props.row.spenderName || truncateAddress(props.row.spenderAddress), + }, ); - - tx?.wait().then(() => { - ant.config.notifySuccessfulTrxHandler( - `${explorerUrl}/tx/${tx.hash}`, - ); - emit('close'); - }).catch((err) => { - console.error(err); - }).finally(() => { - dismiss(); - }); } else if (rowIsSingleErc721Row.value) { - const tx = await useAllowancesStore().updateSingleErc721Allowance( + tx = await useAllowancesStore().updateSingleErc721Allowance( userAddress.value, props.row.spenderAddress, rowAsSingleErc721Row.value.collectionAddress, @@ -198,28 +189,15 @@ async function handleSubmit() { const tokenText = `${rowAsSingleErc721Row.value.collectionName || truncateAddress(rowAsSingleErc721Row.value.collectionAddress)} #${rowAsSingleErc721Row.value.tokenId}`; - const dismiss = ant.config.notifyNeutralMessageHandler( - $t( - 'notification.neutral_message_updating_nft_allowance', - { - tokenText, - operator: props.row.spenderName || truncateAddress(props.row.spenderAddress), - }, - ), + neutralMessageText = $t( + 'notification.neutral_message_updating_nft_allowance', + { + tokenText, + operator: props.row.spenderName || truncateAddress(props.row.spenderAddress), + }, ); - - tx?.wait().then(() => { - ant.config.notifySuccessfulTrxHandler( - `${explorerUrl}/tx/${tx.hash}`, - ); - emit('close'); - }).catch((err) => { - console.error(err); - }).finally(() => { - dismiss(); - }); } else { - const tx = await useAllowancesStore().updateNftCollectionAllowance( + tx = await useAllowancesStore().updateNftCollectionAllowance( userAddress.value, props.row.spenderAddress, rowAsNftRow.value.collectionAddress, @@ -228,27 +206,27 @@ async function handleSubmit() { const tokenText = rowAsNftRow.value.collectionName || truncateAddress(rowAsNftRow.value.collectionAddress); - const dismiss = ant.config.notifyNeutralMessageHandler( - $t( - 'notification.neutral_message_updating_nft_allowance', - { - tokenText, - operator: props.row.spenderName || truncateAddress(props.row.spenderAddress), - }, - ), + neutralMessageText = $t( + 'notification.neutral_message_updating_nft_allowance', + { + tokenText, + operator: props.row.spenderName || truncateAddress(props.row.spenderAddress), + }, ); - - tx?.wait().then(() => { - ant.config.notifySuccessfulTrxHandler( - `${explorerUrl}/tx/${tx.hash}`, - ); - emit('close'); - }).catch((err) => { - console.error(err); - }).finally(() => { - dismiss(); - }); } + + const dismiss = ant.config.notifyNeutralMessageHandler(neutralMessageText); + + tx.wait().then(() => { + ant.config.notifySuccessfulTrxHandler( + `${explorerUrl}/tx/${tx.hash}`, + ); + emit('close'); + }).catch((err) => { + console.error(err); + }).finally(() => { + dismiss(); + }); } From 748a4f7263e4084023e687d70fb27d1ab3bbc683 Mon Sep 17 00:00:00 2001 From: Ezra Sowden-Guzman Date: Mon, 11 Dec 2023 19:30:21 -0500 Subject: [PATCH 06/34] integrate revoke button with table rows --- src/pages/evm/allowances/AllowancesPage.vue | 28 +++++++++++++++++-- .../evm/allowances/AllowancesPageControls.vue | 2 +- src/pages/evm/allowances/AllowancesTable.vue | 6 +++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/pages/evm/allowances/AllowancesPage.vue b/src/pages/evm/allowances/AllowancesPage.vue index 8b8387c4..8d7ac9d4 100644 --- a/src/pages/evm/allowances/AllowancesPage.vue +++ b/src/pages/evm/allowances/AllowancesPage.vue @@ -44,6 +44,7 @@ const sort = ref<{ descending: boolean; sortBy: AllowanceTableColumns }>({ }); const searchText = ref(''); const timeout = ref | null>(null); +const selectedRows = ref>({}); // computed const userAddress = computed(() => useAccountStore().currentAccount.account); @@ -123,6 +124,14 @@ const shapedAllowanceRows = computed(() => { return allowances; }); +const enableRevokeButton = computed(() => { + const selectedRowsKeys = Object.keys(selectedRows.value); + const selectedRowsValues = Object.values(selectedRows.value); + debugger; + + return selectedRowsKeys.length > 0 && selectedRowsValues.some(isSelected => isSelected); +}); + // watchers watch(userAddress, (address) => { if (address) { @@ -161,6 +170,15 @@ function handleSortChanged(newSort: { descending: boolean, sortBy: AllowanceTabl sortBy: newSort.sortBy, }; } + +function handleSelectedRowsChange(newSelectedRows: Record) { + // rows are keyed like: `${row.spenderAddress}-${tokenAddress/collectionAddress}` + selectedRows.value = newSelectedRows; +} + +function handleRevokeSelectedClicked() { + console.log('revoke selected clicked'); +} diff --git a/src/pages/evm/allowances/AllowancesPageControls.vue b/src/pages/evm/allowances/AllowancesPageControls.vue index 22e6cb5e..fa1cdacf 100644 --- a/src/pages/evm/allowances/AllowancesPageControls.vue +++ b/src/pages/evm/allowances/AllowancesPageControls.vue @@ -22,7 +22,7 @@ const includeCancelledLabel = computed( // methods function handleRevokeSelected() { - console.log('Revoke selected'); + emit('revoke-selected'); } diff --git a/src/pages/evm/allowances/AllowancesTable.vue b/src/pages/evm/allowances/AllowancesTable.vue index fb03018b..9dd05cca 100644 --- a/src/pages/evm/allowances/AllowancesTable.vue +++ b/src/pages/evm/allowances/AllowancesTable.vue @@ -13,7 +13,7 @@ const props = defineProps<{ rows: ShapedAllowanceRow[]; }>(); -const emit = defineEmits(['sortChanged']); +const emit = defineEmits(['sortChanged', 'selectedRowsChanged']); const { t: $t } = useI18n(); const { fiatLocale, fiatCurrency } = useUserStore(); @@ -99,6 +99,10 @@ watch(tableRows, (newRows) => { pagination.value.rowsNumber = props.rows.length; }, { immediate: true }); +watch(revokeCheckboxesModel, () => { + emit('selectedRowsChanged', revokeCheckboxesModel.value); +}, { deep: true }); + // methods function getAriaLabelForTh(columnName: AllowanceTableColumns) { const { descending, sortBy } = pagination.value; From e4dd0b8e083561093178d0d5e9240319bfe3c2c5 Mon Sep 17 00:00:00 2001 From: Ezra Sowden-Guzman Date: Tue, 12 Dec 2023 17:51:39 -0500 Subject: [PATCH 07/34] WIP add batch revoke --- src/antelope/stores/allowances.ts | 106 +++++++++++++++++++ src/i18n/en-us/index.js | 1 - src/pages/evm/allowances/AllowancesPage.vue | 73 ++++++++++--- src/pages/evm/allowances/AllowancesTable.vue | 11 +- 4 files changed, 175 insertions(+), 16 deletions(-) diff --git a/src/antelope/stores/allowances.ts b/src/antelope/stores/allowances.ts index 1e6f86f4..77efe96e 100644 --- a/src/antelope/stores/allowances.ts +++ b/src/antelope/stores/allowances.ts @@ -68,6 +68,7 @@ export const useAllowancesStore = defineStore(store_name, { .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; @@ -185,6 +186,25 @@ export const useAllowancesStore = defineStore(store_name, { 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 === spenderAddress && + allowance.collectionAddress === tokenAddress && + allowance.tokenId === tokenId, + ); + } + + return allowanceStore.allowances(label).find((allowance) => { + const spenderAddressMatches = allowance.spenderAddress === spenderAddress; + if (isErc20AllowanceRow(allowance)) { + return spenderAddressMatches && allowance.tokenAddress === tokenAddress; + } + + return spenderAddressMatches && allowance.collectionAddress === tokenAddress; + }); + }, }, actions: { trace: createTraceFunction(store_name), @@ -410,6 +430,92 @@ export const useAllowancesStore = defineStore(store_name, { throw trxError; } }, + batchRevokeAllowances( + allowanceIdentifiers: string[], + owner: string, + revokeCompletedHandler: (completed: number, remaining: number) => void, + ): { + promise: Promise, + cancelToken: { isCancelled: boolean, cancel: () => void }, + } { + // 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) { + 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[]) { + // eztodo proper error handling + + for (const [index, allowanceIdentifier] of identifiers.entries()) { + if (cancelToken.isCancelled) { + throw new Error('Operation cancelled'); + } + + const [spenderAddress, tokenAddress, tokenId] = allowanceIdentifier.split('-'); + const allowanceInfo = useAllowancesStore().getAllowance(CURRENT_CONTEXT, spenderAddress, tokenAddress, tokenId || undefined); + + if (!allowanceInfo) { + throw new Error('Allowance not found'); + } + + const isErc20Allowance = isErc20AllowanceRow(allowanceInfo); + const isSingleErc721Allowance = isErc721SingleAllowanceRow(allowanceInfo); + + try { + if (isErc20Allowance) { + await useAllowancesStore().updateErc20Allowance( + owner, + allowanceInfo.spenderAddress, + allowanceInfo.tokenAddress, + BigNumber.from(0), + ); + } else if (isSingleErc721Allowance) { + await useAllowancesStore().updateSingleErc721Allowance( + owner, + allowanceInfo.spenderAddress, + allowanceInfo.collectionAddress, + allowanceInfo.tokenId, + false, + ); + } else { + await useAllowancesStore().updateNftCollectionAllowance( + owner, + allowanceInfo.spenderAddress, + allowanceInfo.collectionAddress, + false, + ); + } + + revokeCompletedHandler(index + 1, identifiers.length - (index + 1)); + } catch (error) { + console.error('Error cancelling allowance', error); + throw error; + } + } + + 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[]) { diff --git a/src/i18n/en-us/index.js b/src/i18n/en-us/index.js index 18b732b7..0d344a53 100644 --- a/src/i18n/en-us/index.js +++ b/src/i18n/en-us/index.js @@ -166,7 +166,6 @@ export default { owners: 'Owners', quantity: 'Quantity', required_field: 'This field is required', - revoke: 'Revoke', rows_per_page: 'Rows per page', search: 'Search', sign_out: 'Disconnect', diff --git a/src/pages/evm/allowances/AllowancesPage.vue b/src/pages/evm/allowances/AllowancesPage.vue index 8d7ac9d4..b2d98f3c 100644 --- a/src/pages/evm/allowances/AllowancesPage.vue +++ b/src/pages/evm/allowances/AllowancesPage.vue @@ -1,7 +1,6 @@ @@ -185,7 +210,7 @@ function handleRevokeSelectedClicked() { @@ -218,6 +243,28 @@ function handleRevokeSelectedClicked() { @selected-rows-changed="handleSelectedRowsChange" /> + + + + + +
+ Revoke in progress +
+
+ Please wait while we revoke the selected allowances. You will need to approve the transactions in your wallet as they come up. +
+
+ + + + +
+
diff --git a/src/pages/evm/allowances/AllowancesTable.vue b/src/pages/evm/allowances/AllowancesTable.vue index 9dd05cca..57d50855 100644 --- a/src/pages/evm/allowances/AllowancesTable.vue +++ b/src/pages/evm/allowances/AllowancesTable.vue @@ -2,7 +2,7 @@ import { computed, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; -import { AllowanceTableColumns, ShapedAllowanceRow, isErc20AllowanceRow } from 'src/antelope/types/Allowances'; +import { AllowanceTableColumns, ShapedAllowanceRow, isErc20AllowanceRow, isErc721SingleAllowanceRow } from 'src/antelope/types/Allowances'; import { useUserStore } from 'src/antelope'; import { getCurrencySymbol } from 'src/antelope/stores/utils/currency-utils'; @@ -169,9 +169,16 @@ function getCheckboxModelForRow(row: ShapedAllowanceRow) { function toggleRevokeChecked(row: ShapedAllowanceRow) { const rowIsErc20 = isErc20AllowanceRow(row); + const rowIsSingleErc721Row = isErc721SingleAllowanceRow(row); + const tokenAddress = rowIsErc20 ? row.tokenAddress : row.collectionAddress; - const key = `${row.spenderAddress}-${tokenAddress}`; + let key = `${row.spenderAddress}-${tokenAddress}`; + + if (rowIsSingleErc721Row) { + key += `-${row.tokenId}`; + } + // rows are keyed like: `${row.spenderAddress}-${tokenAddress/collectionAddress}${ isSingleErc721 ? `-${tokenId}` : ''}` revokeCheckboxesModel.value[key] = !revokeCheckboxesModel.value[key]; if (!revokeCheckboxesModel.value[key]) { From 74a5003154925f073167df48ea2ec3388a8d9d77 Mon Sep 17 00:00:00 2001 From: Ezra Sowden-Guzman Date: Tue, 12 Dec 2023 18:32:02 -0500 Subject: [PATCH 08/34] fix allowance response handling when there are no erc721 results --- src/antelope/chains/EVMChainSettings.ts | 3 +++ src/antelope/stores/allowances.ts | 29 ++++++++++--------------- src/antelope/types/IndexerTypes.ts | 12 ---------- src/i18n/en-us/index.js | 3 +++ 4 files changed, 18 insertions(+), 29 deletions(-) diff --git a/src/antelope/chains/EVMChainSettings.ts b/src/antelope/chains/EVMChainSettings.ts index cd32f37e..369bad3c 100644 --- a/src/antelope/chains/EVMChainSettings.ts +++ b/src/antelope/chains/EVMChainSettings.ts @@ -728,6 +728,7 @@ export default abstract class EVMChainSettings implements ChainSettings { const params = { ...filter, type: 'erc20', + all: true, }; const response = await this.indexer.get(`v1/account/${account}/approvals`, { params }); return response.data as IndexerAllowanceResponseErc20; @@ -737,6 +738,7 @@ export default abstract class EVMChainSettings implements ChainSettings { const params = { ...filter, type: 'erc721', + all: true, }; const response = await this.indexer.get(`v1/account/${account}/approvals`, { params }); return response.data as IndexerAllowanceResponseErc721; @@ -746,6 +748,7 @@ export default abstract class EVMChainSettings implements ChainSettings { 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/stores/allowances.ts b/src/antelope/stores/allowances.ts index 77efe96e..0e0f7cd6 100644 --- a/src/antelope/stores/allowances.ts +++ b/src/antelope/stores/allowances.ts @@ -13,6 +13,7 @@ import { useTokensStore, } from 'src/antelope'; import { + AntelopeError, IndexerAllowanceResponse, IndexerAllowanceResponseErc1155, IndexerAllowanceResponseErc20, @@ -30,9 +31,6 @@ import { TransactionResponse, isErc20AllowanceRow, isErc721SingleAllowanceRow, - isIndexerAllowanceResponseErc1155, - isIndexerAllowanceResponseErc20, - isIndexerAllowanceResponseErc721, } from 'src/antelope/types'; import { createTraceFunction, isTracingAll } from 'src/antelope/stores/feedback'; import EVMChainSettings from 'src/antelope/chains/EVMChainSettings'; @@ -233,23 +231,20 @@ export const useAllowancesStore = defineStore(store_name, { const erc20AllowancesPromise = chainSettings.fetchErc20Allowances(account, { limit: ALLOWANCES_LIMIT }); const erc721AllowancesPromise = chainSettings.fetchErc721Allowances(account, { limit: ALLOWANCES_LIMIT }); const erc1155AllowancesPromise = chainSettings.fetchErc1155Allowances(account, { limit: ALLOWANCES_LIMIT }); - const settledAllowancePromises = await Promise.allSettled([erc20AllowancesPromise, erc721AllowancesPromise, erc1155AllowancesPromise]); - const fulfilledPromises: PromiseFulfilledResult[] = []; - const rejectedPromises: PromiseRejectedResult[] = []; + let allowancesResults: IndexerAllowanceResponse[]; - settledAllowancePromises.forEach((promise) => { - if (promise.status === 'fulfilled') { - fulfilledPromises.push(promise as PromiseFulfilledResult); - } else { - rejectedPromises.push(promise as PromiseRejectedResult); - console.error('Error fetching allowances', promise.reason); - } - }); + 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 = (fulfilledPromises.find(({ value }) => isIndexerAllowanceResponseErc20(value))?.value as IndexerAllowanceResponseErc20 | undefined)?.results ?? []; - const erc721AllowancesData = (fulfilledPromises.find(({ value }) => isIndexerAllowanceResponseErc721(value))?.value as IndexerAllowanceResponseErc721 | undefined)?.results ?? []; - const erc1155AllowancesData = (fulfilledPromises.find(({ value }) => isIndexerAllowanceResponseErc1155(value))?.value as IndexerAllowanceResponseErc1155 | undefined)?.results ?? []; + 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))); diff --git a/src/antelope/types/IndexerTypes.ts b/src/antelope/types/IndexerTypes.ts index 69de9252..b093c317 100644 --- a/src/antelope/types/IndexerTypes.ts +++ b/src/antelope/types/IndexerTypes.ts @@ -209,15 +209,3 @@ export interface IndexerAllowanceResponseErc1155 { } export type IndexerAllowanceResponse = IndexerAllowanceResponseErc20 | IndexerAllowanceResponseErc721 | IndexerAllowanceResponseErc1155; - -export function isIndexerAllowanceResponseErc20(response: IndexerAllowanceResponse): response is IndexerAllowanceResponseErc20 { - return (response as IndexerAllowanceResponseErc20).results.some(result => result.spender); -} - -export function isIndexerAllowanceResponseErc721(response: IndexerAllowanceResponse): response is IndexerAllowanceResponseErc721 { - return (response as IndexerAllowanceResponseErc721).results.some(result => result.hasOwnProperty('single') || result.hasOwnProperty('tokenId')); -} - -export function isIndexerAllowanceResponseErc1155(response: IndexerAllowanceResponse): response is IndexerAllowanceResponseErc1155 { - return !isIndexerAllowanceResponseErc20(response) && !isIndexerAllowanceResponseErc721(response); -} diff --git a/src/i18n/en-us/index.js b/src/i18n/en-us/index.js index 3bad5943..dccf797f 100644 --- a/src/i18n/en-us/index.js +++ b/src/i18n/en-us/index.js @@ -667,6 +667,9 @@ export default { nfts: { error_fetching_collection_nfts: 'An unknown error occurred while fetching collection NFTs', }, + allowances: { + error_fetching_allowances: 'An unknown error occurred while fetching allowances', + }, words: { seconds: 'seconds', minutes: 'minutes', From f8ab43e6cbbf900d17b686b8cb74d4efb5cf7453 Mon Sep 17 00:00:00 2001 From: Ezra Sowden-Guzman Date: Tue, 12 Dec 2023 19:06:43 -0500 Subject: [PATCH 09/34] UX enhancements --- src/antelope/stores/allowances.ts | 8 +++++++ src/pages/evm/allowances/AllowancesPage.vue | 26 +++++++++++++++------ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/antelope/stores/allowances.ts b/src/antelope/stores/allowances.ts index 0e0f7cd6..95d92860 100644 --- a/src/antelope/stores/allowances.ts +++ b/src/antelope/stores/allowances.ts @@ -433,6 +433,9 @@ export const useAllowancesStore = defineStore(store_name, { 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('-'); @@ -441,6 +444,7 @@ export const useAllowancesStore = defineStore(store_name, { }); if (!allowanceIdentifiersAreValid) { + useFeedbackStore().unsetLoading('batchRevokeAllowances'); throw new Error('Invalid allowance identifiers'); } @@ -457,6 +461,7 @@ export const useAllowancesStore = defineStore(store_name, { for (const [index, allowanceIdentifier] of identifiers.entries()) { if (cancelToken.isCancelled) { + useFeedbackStore().unsetLoading('batchRevokeAllowances'); throw new Error('Operation cancelled'); } @@ -464,6 +469,7 @@ export const useAllowancesStore = defineStore(store_name, { const allowanceInfo = useAllowancesStore().getAllowance(CURRENT_CONTEXT, spenderAddress, tokenAddress, tokenId || undefined); if (!allowanceInfo) { + useFeedbackStore().unsetLoading('batchRevokeAllowances'); throw new Error('Allowance not found'); } @@ -497,11 +503,13 @@ export const useAllowancesStore = defineStore(store_name, { revokeCompletedHandler(index + 1, identifiers.length - (index + 1)); } catch (error) { + useFeedbackStore().unsetLoading('batchRevokeAllowances'); console.error('Error cancelling allowance', error); throw error; } } + useFeedbackStore().unsetLoading('batchRevokeAllowances'); return Promise.resolve(); } diff --git a/src/pages/evm/allowances/AllowancesPage.vue b/src/pages/evm/allowances/AllowancesPage.vue index b2d98f3c..6bc344dc 100644 --- a/src/pages/evm/allowances/AllowancesPage.vue +++ b/src/pages/evm/allowances/AllowancesPage.vue @@ -46,6 +46,8 @@ const timeout = ref | null>(null); const selectedRows = ref([]); // rows are keyed like: `${row.spenderAddress}-${tokenAddress/collectionAddress}${ isSingleErc721 ? `-${tokenId}` : ''}` const showRevokeInProgressModal = ref(false); const cancelBatchRevoke = ref<(() => void) | null>(null); +const cancelBatchRevokeButtonLoading = ref(false); +const batchRevokeAllowancesRemaining = ref(0); // computed const userAddress = computed(() => useAccountStore().currentAccount.account); @@ -166,8 +168,6 @@ function handleSortChanged(newSort: { descending: boolean, sortBy: AllowanceTabl }; } -// eztodo make approvals on new team account - function handleSelectedRowsChange(newSelectedRows: Record) { const selectedRowsTemp: string[] = []; @@ -181,11 +181,14 @@ function handleSelectedRowsChange(newSelectedRows: Record) { function handleRevokeSelectedClicked() { showRevokeInProgressModal.value = true; + batchRevokeAllowancesRemaining.value = selectedRows.value.length; function handleRevokeCompleted(completed: number, remaining: number) { console.log('completed:', completed); console.log('remaining:', remaining); - // eztodo use this for text in the modal like "X of Y allowances revoked" + console.log('\n\n'); + + batchRevokeAllowancesRemaining.value = remaining; } const { @@ -198,10 +201,19 @@ function handleRevokeSelectedClicked() { showRevokeInProgressModal.value = false; }; + // eztodo close modal when user cancels from metamask + promise.finally(() => { - showRevokeInProgressModal.value = false; - cancelBatchRevoke.value = null; - // eztodo reset revoking counters + cancelBatchRevokeButtonLoading.value = true; + + setTimeout(() => { + useAllowancesStore().fetchAllowancesForAccount(userAddress.value).then(() => { + cancelBatchRevokeButtonLoading.value = false; + showRevokeInProgressModal.value = false; + cancelBatchRevoke.value = null; + batchRevokeAllowancesRemaining.value = 0; + }); + }, 3000); // give the indexer a chance to catch up }); } @@ -249,7 +261,7 @@ function handleRevokeSelectedClicked() {
- Revoke in progress + Revoking {{ selectedRows.length }} allowances ({{ batchRevokeAllowancesRemaining }} remaining)
Please wait while we revoke the selected allowances. You will need to approve the transactions in your wallet as they come up. From 4779a9eb4d022162548261f7a31e853032a8b117 Mon Sep 17 00:00:00 2001 From: Ezra Sowden-Guzman Date: Thu, 14 Dec 2023 15:07:57 -0500 Subject: [PATCH 10/34] cleanup --- src/pages/evm/allowances/AllowancesPage.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/evm/allowances/AllowancesPage.vue b/src/pages/evm/allowances/AllowancesPage.vue index 6bc344dc..49c36776 100644 --- a/src/pages/evm/allowances/AllowancesPage.vue +++ b/src/pages/evm/allowances/AllowancesPage.vue @@ -207,7 +207,7 @@ function handleRevokeSelectedClicked() { cancelBatchRevokeButtonLoading.value = true; setTimeout(() => { - useAllowancesStore().fetchAllowancesForAccount(userAddress.value).then(() => { + useAllowancesStore().fetchAllowancesForAccount(userAddress.value).finally(() => { cancelBatchRevokeButtonLoading.value = false; showRevokeInProgressModal.value = false; cancelBatchRevoke.value = null; From 794c6f6ffc350f0f62420eb8455b4a1fabf8d12c Mon Sep 17 00:00:00 2001 From: Ezra Sowden-Guzman Date: Thu, 14 Dec 2023 16:17:48 -0500 Subject: [PATCH 11/34] fix single erc721 support --- src/antelope/stores/allowances.ts | 2 +- src/i18n/en-us/index.js | 2 +- src/pages/evm/allowances/AllowancesPage.vue | 2 ++ src/pages/evm/allowances/EditAllowanceModal.vue | 9 ++++++--- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/antelope/stores/allowances.ts b/src/antelope/stores/allowances.ts index 95d92860..242f0daf 100644 --- a/src/antelope/stores/allowances.ts +++ b/src/antelope/stores/allowances.ts @@ -586,7 +586,7 @@ export const useAllowancesStore = defineStore(store_name, { }; if (data.single) { - const tokenId = data.tokenId as string; + const tokenId = String(data.tokenId); const nftDetails = await useNftsStore().fetchNftDetails(CURRENT_CONTEXT, data.contract, tokenId); return nftDetails ? { diff --git a/src/i18n/en-us/index.js b/src/i18n/en-us/index.js index 8480fceb..4e86dd3b 100644 --- a/src/i18n/en-us/index.js +++ b/src/i18n/en-us/index.js @@ -317,7 +317,7 @@ export default { edit_modal_description: 'Define new token allowance for spender', entire_collection: 'Entire Collection', token_amount_input_label: 'Token Amount', - erc_721_single_allowance_blurb: 'Note: there may only be up to one approved spender (or \'operator\') for this type of collectible (ERC-721). Approving a new spender will revoke the previous approval.', + erc_721_single_allowance_blurb: 'Note: there may only be up to one approved spender for this type of collectible (ERC-721). Making a change here will revoke the previous approval.', }, notification:{ success_title_trx: 'Success', diff --git a/src/pages/evm/allowances/AllowancesPage.vue b/src/pages/evm/allowances/AllowancesPage.vue index 49c36776..cea2c4dc 100644 --- a/src/pages/evm/allowances/AllowancesPage.vue +++ b/src/pages/evm/allowances/AllowancesPage.vue @@ -202,6 +202,8 @@ function handleRevokeSelectedClicked() { }; // eztodo close modal when user cancels from metamask + // eztodo handle row already cancelled + // eztodo set cancel button loading while waiting for tx? promise.finally(() => { cancelBatchRevokeButtonLoading.value = true; diff --git a/src/pages/evm/allowances/EditAllowanceModal.vue b/src/pages/evm/allowances/EditAllowanceModal.vue index 124857db..a1fe8d8f 100644 --- a/src/pages/evm/allowances/EditAllowanceModal.vue +++ b/src/pages/evm/allowances/EditAllowanceModal.vue @@ -221,10 +221,11 @@ async function handleSubmit() { ant.config.notifySuccessfulTrxHandler( `${explorerUrl}/tx/${tx.hash}`, ); - emit('close'); }).catch((err) => { + ant.config.notifyErrorHandler(err); console.error(err); }).finally(() => { + emit('close'); dismiss(); }); } @@ -244,9 +245,11 @@ async function handleSubmit() {
{{ tokenText }}
- +
{{ $t('global.allowance') }}
From be2519a3b0f010aaf1936afb6f7da5a7173949ed Mon Sep 17 00:00:00 2001 From: Ezra Sowden-Guzman Date: Thu, 14 Dec 2023 16:20:22 -0500 Subject: [PATCH 12/34] type safety --- src/antelope/types/IndexerTypes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/antelope/types/IndexerTypes.ts b/src/antelope/types/IndexerTypes.ts index b093c317..0bae3b13 100644 --- a/src/antelope/types/IndexerTypes.ts +++ b/src/antelope/types/IndexerTypes.ts @@ -179,7 +179,7 @@ export interface IndexerErc721AllowanceResult extends IndexerAllowanceResult { approved: boolean; // whether the user has approved the spender operator: string; // address of the spender contract - tokenId?: string; // only present if single === true + tokenId?: string | number; // only present if single === true } export interface IndexerErc1155AllowanceResult extends IndexerAllowanceResult { From e3e0b712773c490ccd6885fe118406fb2fa15b49 Mon Sep 17 00:00:00 2001 From: Ezra Sowden-Guzman Date: Thu, 14 Dec 2023 16:54:34 -0500 Subject: [PATCH 13/34] fix single erc721 handling --- src/antelope/chains/chain-constants.ts | 2 ++ src/antelope/stores/allowances.ts | 11 ++++++++-- src/pages/evm/allowances/AllowancesTable.vue | 21 +++++++++++++++++--- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/antelope/chains/chain-constants.ts b/src/antelope/chains/chain-constants.ts index 6822ad20..f77fabd8 100644 --- a/src/antelope/chains/chain-constants.ts +++ b/src/antelope/chains/chain-constants.ts @@ -15,3 +15,5 @@ export const TELOS_ANALYTICS_EVENT_IDS = { loginFailedWalletConnect: '9V4IV1BV', loginSuccessfulWalletConnect: '2EG2OR3H', }; + +export const ZERO_ADDRESS = '0x'.concat('0'.repeat(40)); diff --git a/src/antelope/stores/allowances.ts b/src/antelope/stores/allowances.ts index 242f0daf..2dc9f341 100644 --- a/src/antelope/stores/allowances.ts +++ b/src/antelope/stores/allowances.ts @@ -34,6 +34,7 @@ import { } from 'src/antelope/types'; import { createTraceFunction, isTracingAll } from 'src/antelope/stores/feedback'; import EVMChainSettings from 'src/antelope/chains/EVMChainSettings'; +import { ZERO_ADDRESS } from 'src/antelope/chains/chain-constants'; const store_name = 'allowances'; @@ -366,8 +367,8 @@ export const useAllowancesStore = defineStore(store_name, { } // 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 '0x0' - const newOperator = allowed ? operator : '0x0'; + // to revoke an allowance, the approve method is called with an operator address of '0x0000...0000' + const newOperator = allowed ? operator : ZERO_ADDRESS; const tx = await nftContractInstance.approve(newOperator, tokenId); @@ -575,6 +576,12 @@ export const useAllowancesStore = defineStore(store_name, { } }, 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 spenderContract = await useContractStore().getContract(CURRENT_CONTEXT, data.operator); diff --git a/src/pages/evm/allowances/AllowancesTable.vue b/src/pages/evm/allowances/AllowancesTable.vue index 57d50855..db0d941d 100644 --- a/src/pages/evm/allowances/AllowancesTable.vue +++ b/src/pages/evm/allowances/AllowancesTable.vue @@ -89,8 +89,11 @@ const tableRows = computed(() => { watch(tableRows, (newRows) => { revokeCheckboxesModel.value = newRows.reduce((acc, row) => { const rowIsErc20 = isErc20AllowanceRow(row); + const rowIsSingleErc721 = isErc721SingleAllowanceRow(row); const tokenAddress = rowIsErc20 ? row.tokenAddress : row.collectionAddress; - acc[`${row.spenderAddress}-${tokenAddress}`] = false; + const tokenId = rowIsSingleErc721 ? `-${row.tokenId}` : ''; + + acc[`${row.spenderAddress}-${tokenAddress}${tokenId}`] = false; return acc; }, {} as Record); @@ -137,6 +140,15 @@ function getAriaLabelForTh(columnName: AllowanceTableColumns) { $t('global.sort_by_ascending', { column: columnDescription }); } +function getKeyForRow(row: ShapedAllowanceRow) { + const rowIsErc20 = isErc20AllowanceRow(row); + const rowIsSingleErc721 = isErc721SingleAllowanceRow(row); + const tokenAddress = rowIsErc20 ? row.tokenAddress : row.collectionAddress; + const tokenId = rowIsSingleErc721 ? `-${row.tokenId}` : ''; + + return `${row.spenderAddress}-${tokenAddress}${tokenId}`; +} + // normally, we would use the q-table's @request event to handle sorting. However, the table was failing to react to changes // in the sort object. This is a workaround. function handleThClick(columnName: AllowanceTableColumns) { @@ -163,8 +175,11 @@ function updateAllRevokeCheckboxes() { function getCheckboxModelForRow(row: ShapedAllowanceRow) { const rowIsErc20 = isErc20AllowanceRow(row); + const rowIsSingleErc721 = isErc721SingleAllowanceRow(row); const tokenAddress = rowIsErc20 ? row.tokenAddress : row.collectionAddress; - return revokeCheckboxesModel.value[`${row.spenderAddress}-${tokenAddress}`]; + const tokenId = rowIsSingleErc721 ? `-${row.tokenId}` : ''; + + return revokeCheckboxesModel.value[`${row.spenderAddress}-${tokenAddress}${tokenId}`]; } function toggleRevokeChecked(row: ShapedAllowanceRow) { @@ -241,7 +256,7 @@ function toggleRevokeChecked(row: ShapedAllowanceRow) {