From 90223e49ae01a3b485121c2d79ab1d5dc6d33608 Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Wed, 20 Apr 2022 19:36:50 -0400 Subject: [PATCH 01/20] Create skeleton for NFT dashboard page Reorganize pages in App.tsx by name --- src/app/App.tsx | 30 +++++++------- src/app/rootSaga.ts | 11 +++++ src/components/icons/nft.svg | 1 + src/components/layout/NavButtonRow.tsx | 7 ++++ src/features/nft/NftDashboardScreen.tsx | 53 +++++++++++++++++++++++++ src/features/nft/NftDetailsScreen.tsx | 53 +++++++++++++++++++++++++ src/features/nft/fetchNfts.ts | 14 +++++++ 7 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 src/components/icons/nft.svg create mode 100644 src/features/nft/NftDashboardScreen.tsx create mode 100644 src/features/nft/NftDetailsScreen.tsx create mode 100644 src/features/nft/fetchNfts.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 02185368..93834b07 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -18,6 +18,7 @@ import { HomeNavigator } from 'src/features/home/HomeNavigator' import { HomeScreen } from 'src/features/home/HomeScreen' import { LockConfirmationScreen } from 'src/features/lock/LockConfirmationScreen' import { LockFormScreen } from 'src/features/lock/LockFormScreen' +import { NftDashboardScreen } from 'src/features/nft/NftDashboardScreen' import { ImportAccountScreen } from 'src/features/onboarding/import/ImportAccountScreen' import { ImportChoiceScreen } from 'src/features/onboarding/import/ImportChoiceScreen' import { LedgerImportScreen } from 'src/features/onboarding/import/LedgerImportScreen' @@ -77,20 +78,6 @@ export const App = () => { }> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> } /> }> } /> @@ -101,7 +88,22 @@ export const App = () => { } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> + } /> + } /> + } /> + } /> + } /> }> diff --git a/src/app/rootSaga.ts b/src/app/rootSaga.ts index 8e7486b0..a0d54efd 100644 --- a/src/app/rootSaga.ts +++ b/src/app/rootSaga.ts @@ -50,6 +50,12 @@ import { lockTokenSaga, lockTokenSagaName, } from 'src/features/lock/lockToken' +import { + fetchNftsActions, + fetchNftsReducer, + fetchNftsSaga, + fetchNftsSagaName, +} from 'src/features/nft/fetchNfts' import { changePasswordActions, changePasswordReducer, @@ -217,6 +223,11 @@ export const monitoredSagas: { reducer: governanceVoteReducer, actions: governanceVoteActions, }, + [fetchNftsSagaName]: { + saga: fetchNftsSaga, + reducer: fetchNftsReducer, + actions: fetchNftsActions, + }, [editAccountSagaName]: { saga: editAccountSaga, reducer: editAccountReducer, diff --git a/src/components/icons/nft.svg b/src/components/icons/nft.svg new file mode 100644 index 00000000..a1c3c7fb --- /dev/null +++ b/src/components/icons/nft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/layout/NavButtonRow.tsx b/src/components/layout/NavButtonRow.tsx index 594c04d9..17c20577 100644 --- a/src/components/layout/NavButtonRow.tsx +++ b/src/components/layout/NavButtonRow.tsx @@ -7,6 +7,7 @@ import CoinStackIcon from 'src/components/icons/coin_stack.svg' import CubeIcon from 'src/components/icons/cube.svg' import LockIcon from 'src/components/icons/lock_small.svg' import WalletConnectIcon from 'src/components/icons/logos/wallet_connect.svg' +import NftIcon from 'src/components/icons/nft.svg' import SendIcon from 'src/components/icons/send_payment.svg' import ExchangeIcon from 'src/components/icons/swap.svg' import VoteIcon from 'src/components/icons/vote_small.svg' @@ -101,6 +102,11 @@ export function NavButtonRow({ mobile, disabled }: Props) { } } + const onNftClick = () => { + hideDropdown() + navigate('/nft') + } + const onConnectClick = () => { hideDropdown() showWalletConnectModal() @@ -187,6 +193,7 @@ export function NavButtonRow({ mobile, disabled }: Props) { description="Vote for proposals" onClick={onGovernClick} /> + { + dispatch(fetchNftsActions.trigger()) + }, []) + + const status = useSagaStatus( + fetchNftsSagaName, + 'Error Nfts Balances', + 'Something went wrong when looking for your NFTs, sorry! Please try again later.' + ) + + const { showModalWithContent, closeModal } = useModal() + + const onClickAdd = () => { + //TODO + showModalWithContent({ head: 'Add New NFT', content: }) + } + + return ( + +

Non-Fungible Tokens

+ + + + Add missing NFT + +
+ ) +} + +const style: Stylesheet = { + h1: { + ...Font.h2Green, + marginBottom: '1.5em', + }, +} diff --git a/src/features/nft/NftDetailsScreen.tsx b/src/features/nft/NftDetailsScreen.tsx new file mode 100644 index 00000000..6d723d3d --- /dev/null +++ b/src/features/nft/NftDetailsScreen.tsx @@ -0,0 +1,53 @@ +import { useEffect } from 'react' +import { useAppDispatch } from 'src/app/hooks' +import { DashedBorderButton } from 'src/components/buttons/DashedBorderButton' +import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' +import { useModal } from 'src/components/modal/useModal' +import { fetchNftsActions, fetchNftsSagaName } from 'src/features/nft/fetchNfts' +import { AddTokenModal } from 'src/features/tokens/AddTokenModal' +import { Font } from 'src/styles/fonts' +import { Stylesheet } from 'src/styles/types' +import { SagaStatus } from 'src/utils/saga' +import { useSagaStatus } from 'src/utils/useSagaStatus' + +export function NftDetailsScreen() { + const dispatch = useAppDispatch() + + useEffect(() => { + dispatch(fetchNftsActions.trigger()) + }, []) + + const status = useSagaStatus( + fetchNftsSagaName, + 'Error Nfts Balances', + 'Something went wrong when looking for your NFTs, sorry! Please try again later.' + ) + + const { showModalWithContent, closeModal } = useModal() + + const onClickAdd = () => { + //TODO + showModalWithContent({ head: 'Add New NFT', content: }) + } + + return ( + +

Non-Fungible Tokens

+ + + + Add missing NFT + +
+ ) +} + +const style: Stylesheet = { + h1: { + ...Font.h2Green, + marginBottom: '1.5em', + }, +} diff --git a/src/features/nft/fetchNfts.ts b/src/features/nft/fetchNfts.ts new file mode 100644 index 00000000..5b402708 --- /dev/null +++ b/src/features/nft/fetchNfts.ts @@ -0,0 +1,14 @@ +import { appSelect } from 'src/app/appSelect' +import { createMonitoredSaga } from 'src/utils/saga' + +function* fetchNfts() { + const address = yield* appSelect((state) => state.wallet.address) + if (!address) throw new Error('Cannot fetch Nfts before address is set') +} + +export const { + name: fetchNftsSagaName, + wrappedSaga: fetchNftsSaga, + reducer: fetchNftsReducer, + actions: fetchNftsActions, +} = createMonitoredSaga(fetchNfts, 'fetchNfts') From b9c2786525b2e4d1ac91e98e8d275684002ca079 Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Thu, 21 Apr 2022 14:03:54 -0400 Subject: [PATCH 02/20] Set up skeleton for remaining NFT screens and send saga --- src/app/App.tsx | 10 ++- src/app/rootSaga.ts | 11 ++++ src/features/nft/NftDashboardScreen.tsx | 8 ++- src/features/nft/NftDetailsScreen.tsx | 53 +++++++-------- src/features/nft/NftSendConfirmScreen.tsx | 79 +++++++++++++++++++++++ src/features/nft/NftSendFormScreen.tsx | 46 +++++++++++++ src/features/nft/sendNft.ts | 19 ++++++ 7 files changed, 193 insertions(+), 33 deletions(-) create mode 100644 src/features/nft/NftSendConfirmScreen.tsx create mode 100644 src/features/nft/NftSendFormScreen.tsx create mode 100644 src/features/nft/sendNft.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 93834b07..36fbc073 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -19,6 +19,9 @@ import { HomeScreen } from 'src/features/home/HomeScreen' import { LockConfirmationScreen } from 'src/features/lock/LockConfirmationScreen' import { LockFormScreen } from 'src/features/lock/LockFormScreen' import { NftDashboardScreen } from 'src/features/nft/NftDashboardScreen' +import { NftDetailsScreen } from 'src/features/nft/NftDetailsScreen' +import { NftSendConfirmScreen } from 'src/features/nft/NftSendConfirmScreen' +import { NftSendFormScreen } from 'src/features/nft/NftSendFormScreen' import { ImportAccountScreen } from 'src/features/onboarding/import/ImportAccountScreen' import { ImportChoiceScreen } from 'src/features/onboarding/import/ImportChoiceScreen' import { LedgerImportScreen } from 'src/features/onboarding/import/LedgerImportScreen' @@ -95,7 +98,12 @@ export const App = () => { } /> } /> } /> - } /> + + } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/src/app/rootSaga.ts b/src/app/rootSaga.ts index a0d54efd..ebcf1bda 100644 --- a/src/app/rootSaga.ts +++ b/src/app/rootSaga.ts @@ -56,6 +56,12 @@ import { fetchNftsSaga, fetchNftsSagaName, } from 'src/features/nft/fetchNfts' +import { + sendNftActions, + sendNftReducer, + sendNftSaga, + sendNftSagaName, +} from 'src/features/nft/sendNft' import { changePasswordActions, changePasswordReducer, @@ -228,6 +234,11 @@ export const monitoredSagas: { reducer: fetchNftsReducer, actions: fetchNftsActions, }, + [sendNftSagaName]: { + saga: sendNftSaga, + reducer: sendNftReducer, + actions: sendNftActions, + }, [editAccountSagaName]: { saga: editAccountSaga, reducer: editAccountReducer, diff --git a/src/features/nft/NftDashboardScreen.tsx b/src/features/nft/NftDashboardScreen.tsx index 29c0238f..cbee8cf9 100644 --- a/src/features/nft/NftDashboardScreen.tsx +++ b/src/features/nft/NftDashboardScreen.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' import { useAppDispatch } from 'src/app/hooks' import { DashedBorderButton } from 'src/components/buttons/DashedBorderButton' import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' @@ -11,6 +12,7 @@ import { SagaStatus } from 'src/utils/saga' import { useSagaStatus } from 'src/utils/useSagaStatus' export function NftDashboardScreen() { + const navigate = useNavigate() const dispatch = useAppDispatch() useEffect(() => { @@ -23,6 +25,10 @@ export function NftDashboardScreen() { 'Something went wrong when looking for your NFTs, sorry! Please try again later.' ) + const onClickNft = (address: Address, id: string) => { + navigate('/nft/details', { state: { address, id } }) + } + const { showModalWithContent, closeModal } = useModal() const onClickAdd = () => { @@ -32,7 +38,7 @@ export function NftDashboardScreen() { return ( -

Non-Fungible Tokens

+

Your Non-Fungible Tokens (NFTs)

() useEffect(() => { - dispatch(fetchNftsActions.trigger()) - }, []) - - const status = useSagaStatus( - fetchNftsSagaName, - 'Error Nfts Balances', - 'Something went wrong when looking for your NFTs, sorry! Please try again later.' - ) - - const { showModalWithContent, closeModal } = useModal() - - const onClickAdd = () => { - //TODO - showModalWithContent({ head: 'Add New NFT', content: }) + // Make sure we belong on this screen + if (!locationState?.address || !locationState?.id) { + navigate('/nft') + return + } + }, [locationState]) + + if (!locationState?.address || !locationState?.id) return null + const { address, id } = locationState + + const onClickSend = () => { + navigate('/nft/send', { state: { address, id } }) } return ( -

Non-Fungible Tokens

- - - + Add missing NFT - +

Your Non-Fungible Tokens (NFTs)

) } diff --git a/src/features/nft/NftSendConfirmScreen.tsx b/src/features/nft/NftSendConfirmScreen.tsx new file mode 100644 index 00000000..d2357e55 --- /dev/null +++ b/src/features/nft/NftSendConfirmScreen.tsx @@ -0,0 +1,79 @@ +import { BigNumber } from 'ethers' +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAppDispatch } from 'src/app/hooks' +import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' +import { estimateFeeActions } from 'src/features/fees/estimateFee' +import { useFee } from 'src/features/fees/utils' +import { useFlowTransaction } from 'src/features/txFlow/hooks' +import { txFlowCanceled } from 'src/features/txFlow/txFlowSlice' +import { TxFlowType } from 'src/features/txFlow/types' +import { useTxFlowStatusModals } from 'src/features/txFlow/useTxFlowStatusModals' +import { Font } from 'src/styles/fonts' +import { Stylesheet } from 'src/styles/types' +import { logger } from 'src/utils/logger' + +export function NftSendConfirmScreen() { + const dispatch = useAppDispatch() + const navigate = useNavigate() + + const tx = useFlowTransaction() + + useEffect(() => { + // Make sure we belong on this screen + if (tx?.type !== TxFlowType.NftSend) { + navigate('/nft') + return + } + + // There are no gas pre-computes for nft transfers, need to get real tx to estimate + const txRequestP = createNftTransferTx(token, recipient, BigNumber.from(amountInWei)) + txRequestP + .then((txRequest) => + dispatch( + estimateFeeActions.trigger({ txs: [{ type, tx: txRequest }], forceGasEstimation: true }) + ) + ) + .catch((e) => logger.error('Error computing token transfer gas', e)) + }, [tx]) + + if (tx?.type !== TxFlowType.NftSend) return null + const params = tx.params + + const { amount, total, feeAmount, feeCurrency, feeEstimates } = useFee(params.amountInWei) + + const onGoBack = () => { + dispatch(sendNftActions.reset()) + dispatch(txFlowCanceled()) + navigate(-1) + } + + const onSend = () => { + if (!tx || !feeEstimates) return + dispatch(sendNftActions.trigger({ ...params, feeEstimate: feeEstimates[0] })) + } + + const { isWorking } = useTxFlowStatusModals({ + sagaName: sendNftSagaName, + signaturesNeeded: 1, + loadingTitle: 'Sending Payment...', + successTitle: 'Payment Sent!', + successMsg: 'Your payment has been sent successfully', + errorTitle: 'Payment Failed', + errorMsg: 'Your payment could not be processed', + reqSignatureWarningLabel: params.comment ? 'payments with comments' : undefined, + }) + + return ( + +

{`Send ${name}`}

+
+ ) +} + +const style: Stylesheet = { + h1: { + ...Font.h2Green, + marginBottom: '1.5em', + }, +} diff --git a/src/features/nft/NftSendFormScreen.tsx b/src/features/nft/NftSendFormScreen.tsx new file mode 100644 index 00000000..245b608a --- /dev/null +++ b/src/features/nft/NftSendFormScreen.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAppDispatch } from 'src/app/hooks' +import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' +import { Font } from 'src/styles/fonts' +import { Stylesheet } from 'src/styles/types' +import { useLocationState } from 'src/utils/useLocationState' + +interface LocationState { + address: Address + id: string +} + +export function NftSendFormScreen() { + const dispatch = useAppDispatch() + const navigate = useNavigate() + const locationState = useLocationState() + + useEffect(() => { + // Make sure we belong on this screen + if (!locationState?.address || !locationState?.id) { + navigate('/nft') + return + } + }, [locationState]) + + if (!locationState?.address || !locationState?.id) return null + const { address, id } = locationState + + const onClickContinue = () => { + navigate('/nft/confirm') + } + + return ( + +

{`Send ${name}`}

+
+ ) +} + +const style: Stylesheet = { + h1: { + ...Font.h2Green, + marginBottom: '1.5em', + }, +} diff --git a/src/features/nft/sendNft.ts b/src/features/nft/sendNft.ts new file mode 100644 index 00000000..bf9b4787 --- /dev/null +++ b/src/features/nft/sendNft.ts @@ -0,0 +1,19 @@ +import { appSelect } from 'src/app/appSelect' +import { createMonitoredSaga } from 'src/utils/saga' + +interface sendNftParams { + contractAddress: Address + id: string +} + +function* sendNft({ contractAddress, id }: sendNftParams) { + const address = yield* appSelect((state) => state.wallet.address) + if (!address) throw new Error('Cannot send Nfts before address is set') +} + +export const { + name: sendNftSagaName, + wrappedSaga: sendNftSaga, + reducer: sendNftReducer, + actions: sendNftActions, +} = createMonitoredSaga(sendNft, 'sendNft') From a7e94dfc9e503cda33ae23f7f960e436a991d3c9 Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Thu, 21 Apr 2022 18:37:03 -0400 Subject: [PATCH 03/20] Create list of popular NFTs create nftSlice --- src/app/rootReducer.ts | 2 + src/features/nft/NftDashboardScreen.tsx | 25 ++++-- src/features/nft/consts.ts | 106 ++++++++++++++++++++++++ src/features/nft/nftSlice.ts | 26 ++++++ src/features/nft/types.ts | 12 +++ 5 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 src/features/nft/consts.ts create mode 100644 src/features/nft/nftSlice.ts create mode 100644 src/features/nft/types.ts diff --git a/src/app/rootReducer.ts b/src/app/rootReducer.ts index 6249d983..5ca2d505 100644 --- a/src/app/rootReducer.ts +++ b/src/app/rootReducer.ts @@ -7,6 +7,7 @@ import { feedReducer } from 'src/features/feed/feedSlice' import { feeReducer } from 'src/features/fees/feeSlice' import { governanceReducer } from 'src/features/governance/governanceSlice' import { lockReducer } from 'src/features/lock/lockSlice' +import { nftReducer } from 'src/features/nft/nftSlice' import { persistedSettingsReducer } from 'src/features/settings/settingsSlice' import { persistedTokenPriceReducer } from 'src/features/tokenPrice/tokenPriceSlice' import { persistedTokensReducer } from 'src/features/tokens/tokensSlice' @@ -27,6 +28,7 @@ export const rootReducer = combineReducers({ tokenPrice: persistedTokenPriceReducer, validators: persistedValidatorsReducer, governance: governanceReducer, + nft: nftReducer, settings: persistedSettingsReducer, walletConnect: walletConnectReducer, txFlow: txFlowReducer, diff --git a/src/features/nft/NftDashboardScreen.tsx b/src/features/nft/NftDashboardScreen.tsx index cbee8cf9..b9661141 100644 --- a/src/features/nft/NftDashboardScreen.tsx +++ b/src/features/nft/NftDashboardScreen.tsx @@ -1,9 +1,10 @@ import { useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { useAppDispatch } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { DashedBorderButton } from 'src/components/buttons/DashedBorderButton' import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' import { useModal } from 'src/components/modal/useModal' +import { Spinner } from 'src/components/Spinner' import { fetchNftsActions, fetchNftsSagaName } from 'src/features/nft/fetchNfts' import { AddTokenModal } from 'src/features/tokens/AddTokenModal' import { Font } from 'src/styles/fonts' @@ -21,9 +22,12 @@ export function NftDashboardScreen() { const status = useSagaStatus( fetchNftsSagaName, - 'Error Nfts Balances', + 'Error Finding Nfts', 'Something went wrong when looking for your NFTs, sorry! Please try again later.' ) + const isLoading = status === SagaStatus.Started + + const owned = useAppSelector((state) => state.nft.owned) const onClickNft = (address: Address, id: string) => { navigate('/nft/details', { state: { address, id } }) @@ -39,12 +43,13 @@ export function NftDashboardScreen() { return (

Your Non-Fungible Tokens (NFTs)

+ {isLoading && ( +
+ +
+ )} - + + Add missing NFT
@@ -56,4 +61,10 @@ const style: Stylesheet = { ...Font.h2Green, marginBottom: '1.5em', }, + spinner: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + opacity: 0.8, + }, } diff --git a/src/features/nft/consts.ts b/src/features/nft/consts.ts new file mode 100644 index 00000000..c36f2e79 --- /dev/null +++ b/src/features/nft/consts.ts @@ -0,0 +1,106 @@ +import { NftProject } from 'src/features/nft/types' + +export const POPULAR_NFT_PROJECTS: NftProject[] = [ + { + name: 'Celo Apes Kingdom', + symbol: 'CAK', + uri: 'https://www.celoapes.club', + contract: '0x1eCD77075F7504bA849d47DCe4cdC9695f1FE942', + }, + { + name: 'Celo Espresso', + symbol: 'cESPRESSO', + uri: 'https://celo-espresso.cafe', + contract: '0x7DD354dB71fbFa060070BC0a05d24F87d24A31B7', + }, + { + name: 'Celo Paints', + symbol: 'CPAINT', + uri: 'https://celopaints.art', + contract: '0x660C6442F01c75fE1e389A607A4a7662342f2FD2', + }, + { + name: 'Celo Punks', + symbol: 'CPUNK', + uri: 'https://celopunks.club', + contract: '0x9f46B8290A6D41B28dA037aDE0C3eBe24a5D1160', + }, + { + name: 'Celostrials', + symbol: 'NFET', + uri: 'https://celostrials.com', + contract: '0xAc80c3c8b122DB4DcC3C351ca93aC7E0927C605d', + }, + { + name: 'Celo Shapes', + symbol: 'CSHAPE', + uri: 'https://celoshapes.art', + contract: '0x501F7Ea7B1aA25fF7D2feB3a2e96979ba754204B', + }, + { + name: 'CeloToadz', + symbol: 'CTOADZ', + uri: 'https://www.celotoadz.com', + contract: '0x6Fc1C8d59FdC261c55273f9b8e64B7E88C45E208', + }, + { + name: 'ChinChilla Gang', + symbol: 'GANG', + uri: 'https://www.chinchillagang.com', + contract: '0xc8DF51073CD581902b4fb50131d31f29343131F0', + }, + { + name: 'Daopolis', + symbol: 'DAOS', + uri: 'https://cybertime.finance', + contract: '0xc4ea80deCA2415105746639eC16cB0cF8378996A', + }, + { + name: 'DimsOfCelo', + symbol: 'DIMCELO', + uri: 'https://dimsofcelo.art', + contract: '0x3456eeBb93BDF66Af90115326A55988aa04C7A0B', + }, + { + name: 'Knoxer', + symbol: 'KNX_NFT', + uri: 'https://www.knoxdao.xyz', + contract: '0x1F25F8Df9E33033668d6F04DAE0bDE4854E9F1A5', + }, + { + name: 'MooPunks', + symbol: 'MPUNK', + uri: 'https://moola.market', + contract: '0x517bCe2DdBc21b9A8771Dfd3Db40404BDEF1272D', + }, + { + name: 'MetaCelo Game', + symbol: 'cMETA', + uri: 'https://metacelo.io', + contract: '0xF3608F846cA73147F08FdE8D57f45E27CeEA4d61', + }, + { + name: 'Navikatz Warrior', + symbol: 'NKW', + uri: 'https://navikatz.com', + contract: '0xfA83588c92a353fba568D7C25A32E599FEa35763', + }, + { + name: 'Nomstronauts', + symbol: 'Nomstronaut', + uri: 'https://nom.space', + contract: '0x8237f38694211F25b4c872F147F027044466Fa80', + }, + { + name: 'Nomspace Domains', + symbol: 'Nomspace', + uri: 'https://nom.space', + contract: '0x046D19c5E5E8938D54FB02DCC396ACf7F275490A', + }, + { + name: 'Womxn of Celo', + symbol: 'WMXN', + uri: 'https://www.womxnofcelo.com', + contract: '0x50826Faa5b20250250E09067e8dDb1AFa2bdf910', + }, +] diff --git a/src/features/nft/nftSlice.ts b/src/features/nft/nftSlice.ts new file mode 100644 index 00000000..3088f70a --- /dev/null +++ b/src/features/nft/nftSlice.ts @@ -0,0 +1,26 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +interface NftState { + owned: Record + lastUpdated: number | null +} + +export const nftInitialState: NftState = { + owned: {}, + lastUpdated: null, +} + +const nftSlice = createSlice({ + name: 'nft', + initialState: nftInitialState, + reducers: { + updateOwnedNfts: (state, action: PayloadAction>) => { + state.owned = action.payload + state.lastUpdated = Date.now() + }, + resetNfts: () => nftInitialState, + }, +}) + +export const { updateOwnedNfts, resetNfts } = nftSlice.actions +export const nftReducer = nftSlice.reducer diff --git a/src/features/nft/types.ts b/src/features/nft/types.ts new file mode 100644 index 00000000..fdeedf23 --- /dev/null +++ b/src/features/nft/types.ts @@ -0,0 +1,12 @@ +// TODO remove? +export interface Nft { + contract: Address + index: number +} + +export interface NftProject { + name: string + symbol: string + uri: string + contract: Address +} From b61e146ea0acc50f43b93111545ae797fd06bafa Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Fri, 22 Apr 2022 17:29:50 -0400 Subject: [PATCH 04/20] Implement basic fetching of ERC-721s --- src/app/rootReducer.ts | 4 +- src/blockchain/ABIs/erc721.ts | 3 ++ src/blockchain/contracts.ts | 13 +++++- src/features/balances/fetchBalances.ts | 4 +- src/features/nft/consts.ts | 30 +++++++------- src/features/nft/fetchNfts.ts | 57 +++++++++++++++++++++++++- src/features/nft/nftSlice.ts | 25 +++++++++-- src/features/nft/types.ts | 11 +++-- src/features/send/sendToken.ts | 4 +- src/features/tokens/addToken.ts | 4 +- 10 files changed, 121 insertions(+), 34 deletions(-) create mode 100644 src/blockchain/ABIs/erc721.ts diff --git a/src/app/rootReducer.ts b/src/app/rootReducer.ts index 5ca2d505..d689f96c 100644 --- a/src/app/rootReducer.ts +++ b/src/app/rootReducer.ts @@ -7,7 +7,7 @@ import { feedReducer } from 'src/features/feed/feedSlice' import { feeReducer } from 'src/features/fees/feeSlice' import { governanceReducer } from 'src/features/governance/governanceSlice' import { lockReducer } from 'src/features/lock/lockSlice' -import { nftReducer } from 'src/features/nft/nftSlice' +import { persistedNftReducer } from 'src/features/nft/nftSlice' import { persistedSettingsReducer } from 'src/features/settings/settingsSlice' import { persistedTokenPriceReducer } from 'src/features/tokenPrice/tokenPriceSlice' import { persistedTokensReducer } from 'src/features/tokens/tokensSlice' @@ -28,7 +28,7 @@ export const rootReducer = combineReducers({ tokenPrice: persistedTokenPriceReducer, validators: persistedValidatorsReducer, governance: governanceReducer, - nft: nftReducer, + nft: persistedNftReducer, settings: persistedSettingsReducer, walletConnect: walletConnectReducer, txFlow: txFlowReducer, diff --git a/src/blockchain/ABIs/erc721.ts b/src/blockchain/ABIs/erc721.ts new file mode 100644 index 00000000..c91062d6 --- /dev/null +++ b/src/blockchain/ABIs/erc721.ts @@ -0,0 +1,3 @@ +export const ABI = JSON.parse( + '[{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"approved","type":"address"},{"indexed":true,"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"operator","type":"address"},{"indexed":false,"internalType":"bool","name":"approved","type":"bool"}],"name":"ApprovalForAll","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":true,"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"Transfer","type":"event"},{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"approve","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"balance","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"getApproved","outputs":[{"internalType":"address","name":"operator","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"operator","type":"address"}],"name":"isApprovedForAll","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"ownerOf","outputs":[{"internalType":"address","name":"owner","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"safeTransferFrom","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"},{"internalType":"bytes","name":"data","type":"bytes"}],"name":"safeTransferFrom","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"operator","type":"address"},{"internalType":"bool","name":"_approved","type":"bool"}],"name":"setApprovalForAll","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes4","name":"interfaceId","type":"bytes4"}],"name":"supportsInterface","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"from","type":"address"},{"internalType":"address","name":"to","type":"address"},{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"transferFrom","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"name","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"symbol","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"tokenId","type":"uint256"}],"name":"tokenURI","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"uint256","name":"index","type":"uint256"}],"name":"tokenOfOwnerByIndex","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"totalSupply","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"index","type":"uint256"}],"name":"tokenByIndex","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}]' +) diff --git a/src/blockchain/contracts.ts b/src/blockchain/contracts.ts index d7499e1d..ca9c7d11 100644 --- a/src/blockchain/contracts.ts +++ b/src/blockchain/contracts.ts @@ -2,6 +2,7 @@ import { Contract } from 'ethers' import { ABI as AccountsAbi } from 'src/blockchain/ABIs/accounts' import { ABI as ElectionAbi } from 'src/blockchain/ABIs/election' import { ABI as Erc20Abi } from 'src/blockchain/ABIs/erc20' +import { ABI as Erc721Abi } from 'src/blockchain/ABIs/erc721' import { ABI as EscrowAbi } from 'src/blockchain/ABIs/escrow' import { ABI as ExchangeAbi } from 'src/blockchain/ABIs/exchange' import { ABI as GoldTokenAbi } from 'src/blockchain/ABIs/goldToken' @@ -28,12 +29,20 @@ export function getContract(c: CeloContract) { return contract } +export function getErc20Contract(tokenAddress: Address) { + return getTokenContract(tokenAddress, Erc20Abi) +} + +export function getErc721Contract(tokenAddress: Address) { + return getTokenContract(tokenAddress, Erc721Abi) +} + // Search for token contract by address -export function getTokenContract(tokenAddress: Address) { +export function getTokenContract(tokenAddress: Address, abi: string) { const cachedContract = tokenContractCache[tokenAddress] if (cachedContract) return cachedContract const signer = getSigner().signer - const contract = new Contract(tokenAddress, Erc20Abi, signer) + const contract = new Contract(tokenAddress, abi, signer) tokenContractCache[tokenAddress] = contract return contract } diff --git a/src/features/balances/fetchBalances.ts b/src/features/balances/fetchBalances.ts index e1fb28da..f2284b1d 100644 --- a/src/features/balances/fetchBalances.ts +++ b/src/features/balances/fetchBalances.ts @@ -1,6 +1,6 @@ import { BigNumber, BigNumberish, Contract } from 'ethers' import { appSelect } from 'src/app/appSelect' -import { getContractByAddress, getTokenContract } from 'src/blockchain/contracts' +import { getContractByAddress, getErc20Contract } from 'src/blockchain/contracts' import { getProvider } from 'src/blockchain/provider' import { config } from 'src/config' import { BALANCE_STALE_TIME } from 'src/consts' @@ -95,7 +95,7 @@ async function fetchTokenBalance(address: Address, tokenAddress: Address) { if (isNativeTokenAddress(tokenAddress)) { contract = getContractByAddress(tokenAddress) } else { - contract = getTokenContract(tokenAddress) + contract = getErc20Contract(tokenAddress) } if (!contract) throw new Error(`No contract found for token: ${tokenAddress}`) const balance: BigNumberish = await contract.balanceOf(address) diff --git a/src/features/nft/consts.ts b/src/features/nft/consts.ts index c36f2e79..77e4c0fd 100644 --- a/src/features/nft/consts.ts +++ b/src/features/nft/consts.ts @@ -1,18 +1,19 @@ -import { NftProject } from 'src/features/nft/types' +import { NftContract } from 'src/features/nft/types' -export const POPULAR_NFT_PROJECTS: NftProject[] = [ +export const POPULAR_NFT_CONTRACTS: NftContract[] = [ { name: 'Celo Apes Kingdom', symbol: 'CAK', uri: 'https://www.celoapes.club', contract: '0x1eCD77075F7504bA849d47DCe4cdC9695f1FE942', }, - { - name: 'Celo Espresso', - symbol: 'cESPRESSO', - uri: 'https://celo-espresso.cafe', - contract: '0x7DD354dB71fbFa060070BC0a05d24F87d24A31B7', - }, + // Disabled: does not fully implement extended erc-721 + // { + // name: 'Celo Espresso', + // symbol: 'cESPRESSO', + // uri: 'https://celo-espresso.cafe', + // contract: '0x7DD354dB71fbFa060070BC0a05d24F87d24A31B7', + // }, { name: 'Celo Paints', symbol: 'CPAINT', @@ -91,12 +92,13 @@ export const POPULAR_NFT_PROJECTS: NftProject[] = [ uri: 'https://nom.space', contract: '0x8237f38694211F25b4c872F147F027044466Fa80', }, - { - name: 'Nomspace Domains', - symbol: 'Nomspace', - uri: 'https://nom.space', - contract: '0x046D19c5E5E8938D54FB02DCC396ACf7F275490A', - }, + // Disabled: does not fully implement extended erc-721 + // { + // name: 'Nomspace Domains', + // symbol: 'Nomspace', + // uri: 'https://nom.space', + // contract: '0xdf204de57532242700D988422996e9cED7Aba4Cb', + // }, { name: 'Womxn of Celo', symbol: 'WMXN', diff --git a/src/features/nft/fetchNfts.ts b/src/features/nft/fetchNfts.ts index 5b402708..f5fd38e8 100644 --- a/src/features/nft/fetchNfts.ts +++ b/src/features/nft/fetchNfts.ts @@ -1,9 +1,64 @@ +import { BigNumber } from 'ethers' import { appSelect } from 'src/app/appSelect' +import { getErc721Contract } from 'src/blockchain/contracts' +import { POPULAR_NFT_CONTRACTS } from 'src/features/nft/consts' +import { updateOwnedNfts } from 'src/features/nft/nftSlice' +import { Nft } from 'src/features/nft/types' +import { logger } from 'src/utils/logger' import { createMonitoredSaga } from 'src/utils/saga' +import { call, put } from 'typed-redux-saga' function* fetchNfts() { const address = yield* appSelect((state) => state.wallet.address) - if (!address) throw new Error('Cannot fetch Nfts before address is set') + if (!address) throw new Error('Cannot fetch NFTs before address is set') + + const customContracts = yield* appSelect((state) => state.nft.customContracts) + const customContractsAddrs = customContracts.map((p) => p.contract) + const popularContractAddrs = POPULAR_NFT_CONTRACTS.map((p) => p.contract) + const allContracts = new Set
([...customContractsAddrs, ...popularContractAddrs]) + + const owned = yield* call( + fetchNftsForContracts, + //TODO + '0xDE33e71fAECdEad20e6A8af8f362d2236CbA005f', + Array.from(allContracts) + ) + yield* put(updateOwnedNfts(owned)) +} + +async function fetchNftsForContracts(account: Address, contracts: Address[]) { + const owned: Record = {} + // TODO consider batching to speed this up + for (const contractAddr of contracts) { + try { + const contract = getErc721Contract(contractAddr) + const numOwned = await contract.balanceOf(account) + if (!numOwned || numOwned <= 0) continue + const tokens: Nft[] = [] + for (let i = 0; i < numOwned; i++) { + const tokenId: BigNumber = await contract.tokenOfOwnerByIndex(account, i) + if (!tokenId || tokenId.lt(0)) { + logger.error('Invalid token id from contract:', contractAddr, tokenId) + continue + } + const tokenUri: string = await contract.tokenURI(tokenId) + if (!tokenUri) { + logger.error('Invalid token uri from contract:', contractAddr, tokenUri) + continue + } + tokens.push({ + tokenId: tokenId.toNumber(), + tokenUri, + }) + } + if (tokens.length) { + owned[contractAddr] = tokens + } + } catch (error) { + logger.error('Failed to fetch NFTs for contract:', contractAddr, error) + } + } + return owned } export const { diff --git a/src/features/nft/nftSlice.ts b/src/features/nft/nftSlice.ts index 3088f70a..aec8d3c3 100644 --- a/src/features/nft/nftSlice.ts +++ b/src/features/nft/nftSlice.ts @@ -1,26 +1,45 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { persistReducer } from 'redux-persist' +import storage from 'redux-persist/lib/storage' +import { Nft, NftContract } from 'src/features/nft/types' interface NftState { - owned: Record + owned: Record lastUpdated: number | null + customContracts: NftContract[] } export const nftInitialState: NftState = { owned: {}, lastUpdated: null, + customContracts: [], } const nftSlice = createSlice({ name: 'nft', initialState: nftInitialState, reducers: { - updateOwnedNfts: (state, action: PayloadAction>) => { + updateOwnedNfts: (state, action: PayloadAction>) => { state.owned = action.payload state.lastUpdated = Date.now() }, + addCustomContract: (state, action: PayloadAction) => { + state.customContracts.push(action.payload) + }, resetNfts: () => nftInitialState, }, }) export const { updateOwnedNfts, resetNfts } = nftSlice.actions -export const nftReducer = nftSlice.reducer +const nftReducer = nftSlice.reducer + +const persistConfig = { + key: 'nft', + storage: storage, + whitelist: ['owned', 'customContracts'], +} + +export const persistedNftReducer = persistReducer>( + persistConfig, + nftReducer +) diff --git a/src/features/nft/types.ts b/src/features/nft/types.ts index fdeedf23..3cb25d62 100644 --- a/src/features/nft/types.ts +++ b/src/features/nft/types.ts @@ -1,12 +1,11 @@ -// TODO remove? export interface Nft { - contract: Address - index: number + tokenId: number + tokenUri: string } -export interface NftProject { +export interface NftContract { + contract: Address name: string symbol: string - uri: string - contract: Address + uri?: string } diff --git a/src/features/send/sendToken.ts b/src/features/send/sendToken.ts index 2d060090..5a000c28 100644 --- a/src/features/send/sendToken.ts +++ b/src/features/send/sendToken.ts @@ -1,6 +1,6 @@ import { BigNumber, providers } from 'ethers' import { appSelect } from 'src/app/appSelect' -import { getContractByAddress, getTokenContract } from 'src/blockchain/contracts' +import { getContractByAddress, getErc20Contract } from 'src/blockchain/contracts' import { isSignerLedger } from 'src/blockchain/signer' import { sendSignedTransaction, signTransaction } from 'src/blockchain/transaction' import { @@ -201,7 +201,7 @@ export function createTransferTx(token: Token, recipient: string, amountInWei: B if (isNativeToken(token)) { contract = getContractByAddress(token.address) } else { - contract = getTokenContract(token.address) + contract = getErc20Contract(token.address) } if (!contract) throw new Error(`No contract found for token ${token.address}`) return contract.populateTransaction.transfer(recipient, amountInWei) diff --git a/src/features/tokens/addToken.ts b/src/features/tokens/addToken.ts index 96bdde5d..443f0ecf 100644 --- a/src/features/tokens/addToken.ts +++ b/src/features/tokens/addToken.ts @@ -1,5 +1,5 @@ import { BigNumber, BigNumberish } from 'ethers' -import { getTokenContract } from 'src/blockchain/contracts' +import { getErc20Contract } from 'src/blockchain/contracts' import { config } from 'src/config' import { fetchBalancesActions } from 'src/features/balances/fetchBalances' import { selectTokens } from 'src/features/tokens/hooks' @@ -59,7 +59,7 @@ export function* addTokensByAddress(addresses: Set) { } async function getTokenInfo(tokenAddress: Address): Promise { - const contract = getTokenContract(tokenAddress) + const contract = getErc20Contract(tokenAddress) // Note this assumes the existence of decimals, symbols, and name methods, // which are technically optional. May revisit later const symbolP: Promise = contract.symbol() From 6e49d8359a1e6dadd4c3f291e4206cbb85e5fa91 Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Sat, 23 Apr 2022 12:47:05 -0400 Subject: [PATCH 05/20] Implement some of nft dashboard screen ui --- src/components/icons/Circle.tsx | 2 +- src/components/icons/KebabMenu.tsx | 21 ++++ src/features/nft/NftDashboardScreen.tsx | 137 +++++++++++++++++++++--- src/features/nft/NftDetailsScreen.tsx | 14 +-- src/features/nft/NftSendFormScreen.tsx | 10 +- src/features/nft/fetchNfts.ts | 1 + src/features/nft/nftSlice.ts | 2 +- src/features/nft/types.ts | 1 + src/styles/fonts.ts | 6 ++ 9 files changed, 167 insertions(+), 27 deletions(-) create mode 100644 src/components/icons/KebabMenu.tsx diff --git a/src/components/icons/Circle.tsx b/src/components/icons/Circle.tsx index 6e1d9f79..9de4141e 100644 --- a/src/components/icons/Circle.tsx +++ b/src/components/icons/Circle.tsx @@ -4,7 +4,7 @@ export function CircleIcon({ margin, }: { color: string - size: string + size: string | number margin?: string }) { return ( diff --git a/src/components/icons/KebabMenu.tsx b/src/components/icons/KebabMenu.tsx new file mode 100644 index 00000000..822d817d --- /dev/null +++ b/src/components/icons/KebabMenu.tsx @@ -0,0 +1,21 @@ +import { CircleIcon } from 'src/components/icons/Circle' +import { Box } from 'src/components/layout/Box' + +// A.k.a. three dots dropdown +export function KebabMenuIcon({ + color, + size, + margin, +}: { + color: string + size: number + margin?: string +}) { + return ( + + + + + + ) +} diff --git a/src/features/nft/NftDashboardScreen.tsx b/src/features/nft/NftDashboardScreen.tsx index b9661141..b0a24c88 100644 --- a/src/features/nft/NftDashboardScreen.tsx +++ b/src/features/nft/NftDashboardScreen.tsx @@ -1,13 +1,20 @@ -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { useNavigate } from 'react-router-dom' import { useAppDispatch, useAppSelector } from 'src/app/hooks' +import { transparentButtonStyles } from 'src/components/buttons/Button' import { DashedBorderButton } from 'src/components/buttons/DashedBorderButton' +import { KebabMenuIcon } from 'src/components/icons/KebabMenu' +import NftIcon from 'src/components/icons/nft.svg' +import { Box } from 'src/components/layout/Box' import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' import { useModal } from 'src/components/modal/useModal' import { Spinner } from 'src/components/Spinner' import { fetchNftsActions, fetchNftsSagaName } from 'src/features/nft/fetchNfts' +import { Nft } from 'src/features/nft/types' import { AddTokenModal } from 'src/features/tokens/AddTokenModal' +import { Color } from 'src/styles/Color' import { Font } from 'src/styles/fonts' +import { mq } from 'src/styles/mediaQueries' import { Stylesheet } from 'src/styles/types' import { SagaStatus } from 'src/utils/saga' import { useSagaStatus } from 'src/utils/useSagaStatus' @@ -28,9 +35,17 @@ export function NftDashboardScreen() { const isLoading = status === SagaStatus.Started const owned = useAppSelector((state) => state.nft.owned) + const sortedOwned = useMemo(() => { + let sortedNfts: Nft[] = [] + const sortedContracts = Object.keys(owned).sort((a, b) => (a < b ? 1 : -1)) + for (const contract of sortedContracts) { + sortedNfts = [...sortedNfts, ...owned[contract]] + } + return sortedNfts + }, [owned]) - const onClickNft = (address: Address, id: string) => { - navigate('/nft/details', { state: { address, id } }) + const onClickNft = (nft: Nft) => { + navigate('/nft/details', { state: { nft } }) } const { showModalWithContent, closeModal } = useModal() @@ -43,15 +58,56 @@ export function NftDashboardScreen() { return (

Your Non-Fungible Tokens (NFTs)

- {isLoading && ( -
- -
- )} +
+ {isLoading && ( +
+ +
+ )} - - + Add missing NFT - + {isLoading && !sortedOwned.length && ( + +

Searching contracts for your NFTs...

+
+ )} + + {!isLoading && !sortedOwned.length && ( + +

No NFTs found for this account

+

Try adding the NFT contract manually

+
+ )} + + {sortedOwned.length > 0 && ( + + {sortedOwned.map((nft) => ( + + ))} + + )} + + + + Add missing NFT + +
) } @@ -59,12 +115,67 @@ export function NftDashboardScreen() { const style: Stylesheet = { h1: { ...Font.h2Green, - marginBottom: '1.5em', + marginBottom: 0, + }, + nftButton: { + ...transparentButtonStyles, + display: 'flex', + margin: '1.5em 1.5em 0 0', + [mq[1024]]: { + margin: '1.8em 1.8em 0 0', + }, + [mq[1200]]: { + margin: '2em 2em 0 0', + }, + }, + defaultImageContainer: { + background: '#CFD4D9', + borderRadius: 8, + width: '14em', + height: '12em', + [mq[1024]]: { + width: '16em', + height: '14em', + }, + zIndex: 10, + }, + defaultImage: { + width: '5em', + height: '5em', + [mq[1024]]: { + width: '6em', + height: '6em', + }, + }, + infoContainer: { + position: 'relative', + top: -10, + borderRadius: '0 0 8px 8px', + padding: '1.5em 1em 1em 1em', + border: `1px solid ${Color.borderMedium}`, + background: 'rgba(46, 51, 56, 0.02)', + zIndex: 5, + }, + infoHeader: { + ...Font.body2, + color: Color.textGrey, + }, + infoText: { + ...Font.body, + ...Font.bold, + fontSize: '1.2em', + marginTop: '0.1em', }, spinner: { display: 'flex', alignItems: 'center', justifyContent: 'center', - opacity: 0.8, + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + zIndex: 100, + opacity: 0.7, }, } diff --git a/src/features/nft/NftDetailsScreen.tsx b/src/features/nft/NftDetailsScreen.tsx index b43fccd3..a6580422 100644 --- a/src/features/nft/NftDetailsScreen.tsx +++ b/src/features/nft/NftDetailsScreen.tsx @@ -1,13 +1,13 @@ import { useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' +import { Nft } from 'src/features/nft/types' import { Font } from 'src/styles/fonts' import { Stylesheet } from 'src/styles/types' import { useLocationState } from 'src/utils/useLocationState' interface LocationState { - address: Address - id: string + nft: Nft } export function NftDetailsScreen() { @@ -16,22 +16,22 @@ export function NftDetailsScreen() { useEffect(() => { // Make sure we belong on this screen - if (!locationState?.address || !locationState?.id) { + if (!locationState?.nft) { navigate('/nft') return } }, [locationState]) - if (!locationState?.address || !locationState?.id) return null - const { address, id } = locationState + if (!locationState?.nft) return null + const nft = locationState.nft const onClickSend = () => { - navigate('/nft/send', { state: { address, id } }) + navigate('/nft/send', { state: { nft } }) } return ( -

Your Non-Fungible Tokens (NFTs)

+

TODO

) } diff --git a/src/features/nft/NftSendFormScreen.tsx b/src/features/nft/NftSendFormScreen.tsx index 245b608a..71217051 100644 --- a/src/features/nft/NftSendFormScreen.tsx +++ b/src/features/nft/NftSendFormScreen.tsx @@ -2,13 +2,13 @@ import { useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { useAppDispatch } from 'src/app/hooks' import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' +import { Nft } from 'src/features/nft/types' import { Font } from 'src/styles/fonts' import { Stylesheet } from 'src/styles/types' import { useLocationState } from 'src/utils/useLocationState' interface LocationState { - address: Address - id: string + nft: Nft } export function NftSendFormScreen() { @@ -18,14 +18,14 @@ export function NftSendFormScreen() { useEffect(() => { // Make sure we belong on this screen - if (!locationState?.address || !locationState?.id) { + if (!locationState?.nft) { navigate('/nft') return } }, [locationState]) - if (!locationState?.address || !locationState?.id) return null - const { address, id } = locationState + if (!locationState?.nft) return null + const nft = locationState.nft const onClickContinue = () => { navigate('/nft/confirm') diff --git a/src/features/nft/fetchNfts.ts b/src/features/nft/fetchNfts.ts index f5fd38e8..17def7ee 100644 --- a/src/features/nft/fetchNfts.ts +++ b/src/features/nft/fetchNfts.ts @@ -49,6 +49,7 @@ async function fetchNftsForContracts(account: Address, contracts: Address[]) { tokens.push({ tokenId: tokenId.toNumber(), tokenUri, + contract: contractAddr, }) } if (tokens.length) { diff --git a/src/features/nft/nftSlice.ts b/src/features/nft/nftSlice.ts index aec8d3c3..e3b3e0a2 100644 --- a/src/features/nft/nftSlice.ts +++ b/src/features/nft/nftSlice.ts @@ -5,7 +5,7 @@ import { Nft, NftContract } from 'src/features/nft/types' interface NftState { owned: Record - lastUpdated: number | null + lastUpdated: number | null // for owned customContracts: NftContract[] } diff --git a/src/features/nft/types.ts b/src/features/nft/types.ts index 3cb25d62..0cb9280b 100644 --- a/src/features/nft/types.ts +++ b/src/features/nft/types.ts @@ -1,6 +1,7 @@ export interface Nft { tokenId: number tokenUri: string + contract: Address } export interface NftContract { diff --git a/src/styles/fonts.ts b/src/styles/fonts.ts index 530626dd..464e8b76 100644 --- a/src/styles/fonts.ts +++ b/src/styles/fonts.ts @@ -89,6 +89,12 @@ export const Font: Stylesheet = { color: Color.primaryBlack, }, }, + light: { + fontWeight: 300, + }, + regular: { + fontWeight: 400, + }, bold: { fontWeight: 500, }, From 9cbf38d149bd39cd342061bb27b8b199d5270326 Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Sat, 23 Apr 2022 17:19:06 -0400 Subject: [PATCH 06/20] Continue work on nft dashboard screen --- src/components/buttons/RefreshButton.tsx | 4 +- src/consts.ts | 1 + src/features/nft/NftDashboardScreen.tsx | 122 +++++++++++++++-------- src/features/nft/fetchNfts.ts | 11 +- src/features/nft/hooks.ts | 30 ++++++ 5 files changed, 123 insertions(+), 45 deletions(-) create mode 100644 src/features/nft/hooks.ts diff --git a/src/components/buttons/RefreshButton.tsx b/src/components/buttons/RefreshButton.tsx index d694f560..aba097ab 100644 --- a/src/components/buttons/RefreshButton.tsx +++ b/src/components/buttons/RefreshButton.tsx @@ -11,8 +11,8 @@ interface Props { export function RefreshButton({ width, height, onClick, styles }: Props) { return ( - ) } diff --git a/src/consts.ts b/src/consts.ts index 129b9cf0..4963815f 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -54,6 +54,7 @@ export const VALIDATOR_ACTIVATABLE_STALE_TIME = 43200000 // 12 hours export const STAKE_EVENTS_STALE_TIME = 10000 // 10 seconds export const PROPOSAL_LIST_STALE_TIME = 60000 // 1 minutes export const TOKEN_PRICE_STALE_TIME = 900000 // 15 minutes +export const NFT_SEARCH_STALE_TIME = 30000 // 30 seconds export const GOVERNANCE_GITHUB_BASEURL = 'https://api.github.com/repos/celo-org/governance/contents/CGPs/' diff --git a/src/features/nft/NftDashboardScreen.tsx b/src/features/nft/NftDashboardScreen.tsx index b0a24c88..cf520808 100644 --- a/src/features/nft/NftDashboardScreen.tsx +++ b/src/features/nft/NftDashboardScreen.tsx @@ -1,15 +1,18 @@ -import { useEffect, useMemo } from 'react' +import { useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { useAppDispatch, useAppSelector } from 'src/app/hooks' -import { transparentButtonStyles } from 'src/components/buttons/Button' -import { DashedBorderButton } from 'src/components/buttons/DashedBorderButton' +import { useAppDispatch } from 'src/app/hooks' +import { Button, transparentButtonStyles } from 'src/components/buttons/Button' +import { RefreshButton } from 'src/components/buttons/RefreshButton' +import { TextButton } from 'src/components/buttons/TextButton' import { KebabMenuIcon } from 'src/components/icons/KebabMenu' import NftIcon from 'src/components/icons/nft.svg' +import { PlusIcon } from 'src/components/icons/Plus' import { Box } from 'src/components/layout/Box' import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' import { useModal } from 'src/components/modal/useModal' import { Spinner } from 'src/components/Spinner' import { fetchNftsActions, fetchNftsSagaName } from 'src/features/nft/fetchNfts' +import { useNftContracts, useSortedOwnedNfts } from 'src/features/nft/hooks' import { Nft } from 'src/features/nft/types' import { AddTokenModal } from 'src/features/tokens/AddTokenModal' import { Color } from 'src/styles/Color' @@ -34,20 +37,17 @@ export function NftDashboardScreen() { ) const isLoading = status === SagaStatus.Started - const owned = useAppSelector((state) => state.nft.owned) - const sortedOwned = useMemo(() => { - let sortedNfts: Nft[] = [] - const sortedContracts = Object.keys(owned).sort((a, b) => (a < b ? 1 : -1)) - for (const contract of sortedContracts) { - sortedNfts = [...sortedNfts, ...owned[contract]] - } - return sortedNfts - }, [owned]) + const contracts = useNftContracts() + const owned = useSortedOwnedNfts() const onClickNft = (nft: Nft) => { navigate('/nft/details', { state: { nft } }) } + const onClickRefresh = () => { + dispatch(fetchNftsActions.trigger(true)) + } + const { showModalWithContent, closeModal } = useModal() const onClickAdd = () => { @@ -57,30 +57,52 @@ export function NftDashboardScreen() { return ( -

Your Non-Fungible Tokens (NFTs)

-
+ +

Non-Fungible Tokens (NFTs)

+ {/* TODO make buttons look better and fix on mobile*/} +
) } const style: Stylesheet = { + content: { + position: 'relative', + }, h1: { ...Font.h2Green, marginBottom: 0, }, + h3: { + ...Font.h3, + color: Color.textGrey, + textAlign: 'center', + }, + h4: { + ...Font.h4Center, + color: Color.textGrey, + marginTop: '0.2em', + }, + emptyImage: { + width: '3em', + height: '3em', + filter: 'invert(1)', + opacity: 0.3, + }, nftButton: { ...transparentButtonStyles, + boxShadow: '0px 4px 4px rgba(0, 0, 0, 0.05)', + borderRadius: 8, display: 'flex', + position: 'relative', margin: '1.5em 1.5em 0 0', [mq[1024]]: { margin: '1.8em 1.8em 0 0', }, - [mq[1200]]: { - margin: '2em 2em 0 0', + ':hover': { + top: -2, + boxShadow: '0px 6px 4px rgba(0, 0, 0, 0.1)', }, }, defaultImageContainer: { background: '#CFD4D9', - borderRadius: 8, - width: '14em', - height: '12em', + borderRadius: '8px 8px 0 0', [mq[1024]]: { width: '16em', height: '14em', @@ -149,9 +190,8 @@ const style: Stylesheet = { }, infoContainer: { position: 'relative', - top: -10, borderRadius: '0 0 8px 8px', - padding: '1.5em 1em 1em 1em', + padding: '1em', border: `1px solid ${Color.borderMedium}`, background: 'rgba(46, 51, 56, 0.02)', zIndex: 5, @@ -171,11 +211,13 @@ const style: Stylesheet = { alignItems: 'center', justifyContent: 'center', position: 'absolute', - left: 0, + left: -10, right: 0, - top: 0, + top: 20, bottom: 0, zIndex: 100, opacity: 0.7, + background: Color.fillLighter, + borderRadius: 20, }, } diff --git a/src/features/nft/fetchNfts.ts b/src/features/nft/fetchNfts.ts index 17def7ee..8e19319a 100644 --- a/src/features/nft/fetchNfts.ts +++ b/src/features/nft/fetchNfts.ts @@ -1,18 +1,23 @@ import { BigNumber } from 'ethers' import { appSelect } from 'src/app/appSelect' import { getErc721Contract } from 'src/blockchain/contracts' +import { NFT_SEARCH_STALE_TIME } from 'src/consts' import { POPULAR_NFT_CONTRACTS } from 'src/features/nft/consts' import { updateOwnedNfts } from 'src/features/nft/nftSlice' import { Nft } from 'src/features/nft/types' import { logger } from 'src/utils/logger' import { createMonitoredSaga } from 'src/utils/saga' +import { isStale } from 'src/utils/time' import { call, put } from 'typed-redux-saga' -function* fetchNfts() { +function* fetchNfts(force?: boolean) { const address = yield* appSelect((state) => state.wallet.address) if (!address) throw new Error('Cannot fetch NFTs before address is set') - const customContracts = yield* appSelect((state) => state.nft.customContracts) + const { customContracts, lastUpdated } = yield* appSelect((state) => state.nft) + + if (!isStale(lastUpdated, NFT_SEARCH_STALE_TIME) && !force) return + const customContractsAddrs = customContracts.map((p) => p.contract) const popularContractAddrs = POPULAR_NFT_CONTRACTS.map((p) => p.contract) const allContracts = new Set
([...customContractsAddrs, ...popularContractAddrs]) @@ -67,4 +72,4 @@ export const { wrappedSaga: fetchNftsSaga, reducer: fetchNftsReducer, actions: fetchNftsActions, -} = createMonitoredSaga(fetchNfts, 'fetchNfts') +} = createMonitoredSaga(fetchNfts, 'fetchNfts') diff --git a/src/features/nft/hooks.ts b/src/features/nft/hooks.ts new file mode 100644 index 00000000..3e7627bf --- /dev/null +++ b/src/features/nft/hooks.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react' +import { useAppSelector } from 'src/app/hooks' +import { POPULAR_NFT_CONTRACTS } from 'src/features/nft/consts' +import { Nft, NftContract } from 'src/features/nft/types' + +export function useNftContracts(): Record { + const customContracts = useAppSelector((s) => s.nft.customContracts) + return useMemo(() => { + const result: Record = {} + for (const contract of POPULAR_NFT_CONTRACTS) { + result[contract.contract] = contract + } + for (const contract of customContracts) { + result[contract.contract] = contract + } + return result + }, [customContracts]) +} + +export function useSortedOwnedNfts(): Nft[] { + const owned = useAppSelector((state) => state.nft.owned) + return useMemo(() => { + let sortedNfts: Nft[] = [] + const sortedContracts = Object.keys(owned).sort((a, b) => (a < b ? 1 : -1)) + for (const contract of sortedContracts) { + sortedNfts = [...sortedNfts, ...owned[contract]] + } + return sortedNfts + }, [owned]) +} From 557011e108bca5fa49e4fb0a2e61a7faefe8fe31 Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Sun, 24 Apr 2022 18:46:38 -0400 Subject: [PATCH 07/20] Implement nft details screen ui --- src/consts.ts | 2 +- src/features/nft/NftDashboardScreen.tsx | 44 +++------ src/features/nft/NftDetailsScreen.tsx | 119 +++++++++++++++++++++++- src/features/nft/NftImage.tsx | 37 ++++++++ 4 files changed, 167 insertions(+), 35 deletions(-) create mode 100644 src/features/nft/NftImage.tsx diff --git a/src/consts.ts b/src/consts.ts index 4963815f..d34c73c2 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -54,7 +54,7 @@ export const VALIDATOR_ACTIVATABLE_STALE_TIME = 43200000 // 12 hours export const STAKE_EVENTS_STALE_TIME = 10000 // 10 seconds export const PROPOSAL_LIST_STALE_TIME = 60000 // 1 minutes export const TOKEN_PRICE_STALE_TIME = 900000 // 15 minutes -export const NFT_SEARCH_STALE_TIME = 30000 // 30 seconds +export const NFT_SEARCH_STALE_TIME = 60000 // 60 seconds export const GOVERNANCE_GITHUB_BASEURL = 'https://api.github.com/repos/celo-org/governance/contents/CGPs/' diff --git a/src/features/nft/NftDashboardScreen.tsx b/src/features/nft/NftDashboardScreen.tsx index cf520808..e3e9015a 100644 --- a/src/features/nft/NftDashboardScreen.tsx +++ b/src/features/nft/NftDashboardScreen.tsx @@ -13,6 +13,7 @@ import { useModal } from 'src/components/modal/useModal' import { Spinner } from 'src/components/Spinner' import { fetchNftsActions, fetchNftsSagaName } from 'src/features/nft/fetchNfts' import { useNftContracts, useSortedOwnedNfts } from 'src/features/nft/hooks' +import { NftImage } from 'src/features/nft/NftImage' import { Nft } from 'src/features/nft/types' import { AddTokenModal } from 'src/features/tokens/AddTokenModal' import { Color } from 'src/styles/Color' @@ -109,19 +110,15 @@ export function NftDashboardScreen() { type="button" key={nft.contract + nft.tokenId} > - - - - - - - -
- {contracts[nft.contract].symbol + ' #' + nft.tokenId} -
-
- + + + + +
+ {contracts[nft.contract].symbol + ' #' + nft.tokenId} +
+
))} @@ -158,43 +155,28 @@ const style: Stylesheet = { }, nftButton: { ...transparentButtonStyles, - boxShadow: '0px 4px 4px rgba(0, 0, 0, 0.05)', + overflow: 'hidden', borderRadius: 8, display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', position: 'relative', margin: '1.5em 1.5em 0 0', [mq[1024]]: { margin: '1.8em 1.8em 0 0', }, + boxShadow: '0px 4px 4px rgba(0, 0, 0, 0.05)', ':hover': { top: -2, boxShadow: '0px 6px 4px rgba(0, 0, 0, 0.1)', }, }, - defaultImageContainer: { - background: '#CFD4D9', - borderRadius: '8px 8px 0 0', - [mq[1024]]: { - width: '16em', - height: '14em', - }, - zIndex: 10, - }, - defaultImage: { - width: '5em', - height: '5em', - [mq[1024]]: { - width: '6em', - height: '6em', - }, - }, infoContainer: { position: 'relative', borderRadius: '0 0 8px 8px', padding: '1em', border: `1px solid ${Color.borderMedium}`, background: 'rgba(46, 51, 56, 0.02)', - zIndex: 5, }, infoHeader: { ...Font.body2, diff --git a/src/features/nft/NftDetailsScreen.tsx b/src/features/nft/NftDetailsScreen.tsx index a6580422..b3b0099e 100644 --- a/src/features/nft/NftDetailsScreen.tsx +++ b/src/features/nft/NftDetailsScreen.tsx @@ -1,9 +1,20 @@ +import { css } from '@emotion/react' import { useEffect } from 'react' import { useNavigate } from 'react-router-dom' +import { BackButton } from 'src/components/buttons/BackButton' +import { Button } from 'src/components/buttons/Button' +import { TextLink } from 'src/components/buttons/TextLink' +import SendIcon from 'src/components/icons/send_payment.svg' +import { Box } from 'src/components/layout/Box' import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' +import { useNftContracts } from 'src/features/nft/hooks' +import { NftImage } from 'src/features/nft/NftImage' import { Nft } from 'src/features/nft/types' +import { Color } from 'src/styles/Color' import { Font } from 'src/styles/fonts' +import { mq } from 'src/styles/mediaQueries' import { Stylesheet } from 'src/styles/types' +import { trimToLength } from 'src/utils/string' import { useLocationState } from 'src/utils/useLocationState' interface LocationState { @@ -13,6 +24,7 @@ interface LocationState { export function NftDetailsScreen() { const navigate = useNavigate() const locationState = useLocationState() + const contracts = useNftContracts() useEffect(() => { // Make sure we belong on this screen @@ -25,20 +37,121 @@ export function NftDetailsScreen() { if (!locationState?.nft) return null const nft = locationState.nft + const contract = contracts[nft.contract] + const fullName = `${contract.symbol} #${nft.tokenId}` + const onClickSend = () => { navigate('/nft/send', { state: { nft } }) } return ( -

TODO

+ + +

{'Your ' + (fullName || 'Unknown NFT')}

+
+
+
+ +
+
+ +
{contract.name}
+ +
{fullName}
+ + + {trimToLength(nft.tokenUri, 32)} + + +
+
) } const style: Stylesheet = { h1: { - ...Font.h2Green, - marginBottom: '1.5em', + ...Font.h2, + margin: '0 0 0 1em', + }, + container: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + marginTop: '2em', + [mq[1024]]: { + marginTop: '2.5em', + flexDirection: 'row', + }, + }, + nftImage: { + width: '100%', + height: '100%', + maxHeight: '100%', + maxWidth: '100%', + }, + navButtonIcon: { + height: '1.4em', + width: '1.4em', + }, + infoContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + marginTop: '3em', + [mq[1024]]: { + marginTop: '0.5em', + marginLeft: '4em', + }, + }, + infoHeader: { + ...Font.body, + ...Font.bold, + color: Color.textGrey, + }, + infoText: { + ...Font.h3, + margin: '0.3em 0 1.7em 0', }, } + +const frameStyle = css` + position: relative; + display: inline-block; + box-sizing: border-box; + text-align: center; + width: 19em; + height: 19em; + margin: 0 0.1em 0 0.1em; + background-color: #cfd4d9; + border: solid 1em #eee; + border-top-color: #ddd; + border-bottom-color: #fff; + border-left-color: #eee; + border-right-color: #eee; + border-radius: 3px; + box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1) inset, 0 2px 8px 4px rgba(0, 0, 0, 0.1); + &:before { + position: absolute; + border-radius: 3px; + bottom: -0.4em; + left: -0.4em; + right: -0.4em; + top: -0.4em; + content: ''; + box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.25) inset; + } + &:after { + position: absolute; + border-radius: 3px; + bottom: -0.5em; + left: -0.5em; + right: -0.5em; + top: -0.5em; + content: ''; + box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.25); + } +` diff --git a/src/features/nft/NftImage.tsx b/src/features/nft/NftImage.tsx new file mode 100644 index 00000000..9bf36a47 --- /dev/null +++ b/src/features/nft/NftImage.tsx @@ -0,0 +1,37 @@ +import NftIcon from 'src/components/icons/nft.svg' +import { Box } from 'src/components/layout/Box' +import { Nft, NftContract } from 'src/features/nft/types' +import { mq } from 'src/styles/mediaQueries' +import { Styles, Stylesheet } from 'src/styles/types' + +interface Props { + nft: Nft + contract: NftContract + styles?: Styles +} +export function NftImage({ nft, contract, styles }: Props) { + const containerStyle = styles + ? { ...style.defaultImageContainer, ...styles } + : style.defaultImageContainer + return ( + + + + ) +} + +const style: Stylesheet = { + defaultImageContainer: { + background: '#CFD4D9', + width: '16em', + height: '14em', + }, + defaultImage: { + width: '5em', + height: '5em', + [mq[1024]]: { + width: '6em', + height: '6em', + }, + }, +} From 9fbb279ebd7fba36023b8364e72d851aba1535a2 Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Mon, 25 Apr 2022 14:06:22 -0400 Subject: [PATCH 08/20] Implement nft send form ui --- src/app/App.tsx | 2 +- src/features/nft/NftDashboardScreen.tsx | 42 +---- src/features/nft/NftImage.tsx | 57 ++++++- src/features/nft/NftSendFormScreen.tsx | 194 ++++++++++++++++++++++-- src/features/nft/sendNft.ts | 54 ++++++- src/features/nft/types.ts | 6 + src/features/txFlow/types.ts | 8 + src/utils/amount.ts | 11 ++ 8 files changed, 311 insertions(+), 63 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 36fbc073..65c534db 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -102,7 +102,7 @@ export const App = () => { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/src/features/nft/NftDashboardScreen.tsx b/src/features/nft/NftDashboardScreen.tsx index e3e9015a..e53d6d7c 100644 --- a/src/features/nft/NftDashboardScreen.tsx +++ b/src/features/nft/NftDashboardScreen.tsx @@ -4,7 +4,6 @@ import { useAppDispatch } from 'src/app/hooks' import { Button, transparentButtonStyles } from 'src/components/buttons/Button' import { RefreshButton } from 'src/components/buttons/RefreshButton' import { TextButton } from 'src/components/buttons/TextButton' -import { KebabMenuIcon } from 'src/components/icons/KebabMenu' import NftIcon from 'src/components/icons/nft.svg' import { PlusIcon } from 'src/components/icons/Plus' import { Box } from 'src/components/layout/Box' @@ -13,7 +12,7 @@ import { useModal } from 'src/components/modal/useModal' import { Spinner } from 'src/components/Spinner' import { fetchNftsActions, fetchNftsSagaName } from 'src/features/nft/fetchNfts' import { useNftContracts, useSortedOwnedNfts } from 'src/features/nft/hooks' -import { NftImage } from 'src/features/nft/NftImage' +import { NftImageWithInfo } from 'src/features/nft/NftImage' import { Nft } from 'src/features/nft/types' import { AddTokenModal } from 'src/features/tokens/AddTokenModal' import { Color } from 'src/styles/Color' @@ -110,16 +109,7 @@ export function NftDashboardScreen() { type="button" key={nft.contract + nft.tokenId} > - - - - -
- {contracts[nft.contract].symbol + ' #' + nft.tokenId} -
-
- -
+ ))}
@@ -155,39 +145,13 @@ const style: Stylesheet = { }, nftButton: { ...transparentButtonStyles, - overflow: 'hidden', - borderRadius: 8, - display: 'flex', - flexDirection: 'column', - alignItems: 'stretch', position: 'relative', margin: '1.5em 1.5em 0 0', [mq[1024]]: { margin: '1.8em 1.8em 0 0', }, - boxShadow: '0px 4px 4px rgba(0, 0, 0, 0.05)', - ':hover': { - top: -2, - boxShadow: '0px 6px 4px rgba(0, 0, 0, 0.1)', - }, - }, - infoContainer: { - position: 'relative', - borderRadius: '0 0 8px 8px', - padding: '1em', - border: `1px solid ${Color.borderMedium}`, - background: 'rgba(46, 51, 56, 0.02)', - }, - infoHeader: { - ...Font.body2, - color: Color.textGrey, - }, - infoText: { - ...Font.body, - ...Font.bold, - fontSize: '1.2em', - marginTop: '0.1em', }, + spinner: { display: 'flex', alignItems: 'center', diff --git a/src/features/nft/NftImage.tsx b/src/features/nft/NftImage.tsx index 9bf36a47..f6fd6301 100644 --- a/src/features/nft/NftImage.tsx +++ b/src/features/nft/NftImage.tsx @@ -1,14 +1,18 @@ +import { KebabMenuIcon } from 'src/components/icons/KebabMenu' import NftIcon from 'src/components/icons/nft.svg' import { Box } from 'src/components/layout/Box' import { Nft, NftContract } from 'src/features/nft/types' +import { Color } from 'src/styles/Color' +import { Font } from 'src/styles/fonts' import { mq } from 'src/styles/mediaQueries' import { Styles, Stylesheet } from 'src/styles/types' interface Props { - nft: Nft - contract: NftContract + nft: Nft | null + contract: NftContract | null styles?: Styles } + export function NftImage({ nft, contract, styles }: Props) { const containerStyle = styles ? { ...style.defaultImageContainer, ...styles } @@ -20,6 +24,26 @@ export function NftImage({ nft, contract, styles }: Props) { ) } +export function NftImageWithInfo({ nft, contract, styles }: Props) { + const containerStyle = styles + ? { ...style.imageAndInfoContainer, ...styles } + : style.imageAndInfoContainer + return ( +
+ + + + +
+ {contract && nft ? contract.symbol + ' #' + nft.tokenId : ''} +
+
+ +
+
+ ) +} + const style: Stylesheet = { defaultImageContainer: { background: '#CFD4D9', @@ -34,4 +58,33 @@ const style: Stylesheet = { height: '6em', }, }, + imageAndInfoContainer: { + position: 'relative', + overflow: 'hidden', + borderRadius: 8, + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + boxShadow: '0px 4px 4px rgba(0, 0, 0, 0.05)', + ':hover': { + top: -2, + boxShadow: '0px 6px 4px rgba(0, 0, 0, 0.1)', + }, + }, + infoContainer: { + borderRadius: '0 0 8px 8px', + padding: '1em', + border: `1px solid ${Color.borderMedium}`, + background: 'rgba(46, 51, 56, 0.02)', + }, + infoHeader: { + ...Font.body2, + color: Color.textGrey, + }, + infoText: { + ...Font.body, + ...Font.bold, + fontSize: '1.2em', + marginTop: '0.1em', + }, } diff --git a/src/features/nft/NftSendFormScreen.tsx b/src/features/nft/NftSendFormScreen.tsx index 71217051..bb3aec16 100644 --- a/src/features/nft/NftSendFormScreen.tsx +++ b/src/features/nft/NftSendFormScreen.tsx @@ -1,46 +1,208 @@ -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { useNavigate } from 'react-router-dom' -import { useAppDispatch } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' +import { BackButton } from 'src/components/buttons/BackButton' +import { Button } from 'src/components/buttons/Button' +import PasteIcon from 'src/components/icons/paste.svg' +import { AddressInput } from 'src/components/input/AddressInput' +import { NumberInput } from 'src/components/input/NumberInput' +import { SelectInput } from 'src/components/input/SelectInput' +import { Box } from 'src/components/layout/Box' import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' -import { Nft } from 'src/features/nft/types' +import { useContactsAndAccountsSelect } from 'src/features/contacts/hooks' +import { useNftContracts } from 'src/features/nft/hooks' +import { NftImageWithInfo } from 'src/features/nft/NftImage' +import { validate } from 'src/features/nft/sendNft' +import { Nft, SendNftParams } from 'src/features/nft/types' +import { useFlowTransaction } from 'src/features/txFlow/hooks' +import { txFlowStarted } from 'src/features/txFlow/txFlowSlice' +import { TxFlowTransaction, TxFlowType } from 'src/features/txFlow/types' import { Font } from 'src/styles/fonts' +import { mq } from 'src/styles/mediaQueries' import { Stylesheet } from 'src/styles/types' +import { isValidAddress, normalizeAddress } from 'src/utils/addresses' +import { isClipboardReadSupported, tryClipboardGet } from 'src/utils/clipboard' +import { useCustomForm } from 'src/utils/useCustomForm' import { useLocationState } from 'src/utils/useLocationState' interface LocationState { nft: Nft } +const initialValues: SendNftParams = { + recipient: '', + contract: '', + tokenId: '', +} + export function NftSendFormScreen() { const dispatch = useAppDispatch() const navigate = useNavigate() const locationState = useLocationState() + const tx = useFlowTransaction() + const contactOptions = useContactsAndAccountsSelect() + const contracts = useNftContracts() + const owned = useAppSelector((state) => state.nft.owned) + + const getInitialFormValues = () => getInitialValues(locationState, tx) + + const onSubmit = (values: SendNftParams) => { + dispatch(txFlowStarted({ type: TxFlowType.SendNft, params: values })) + navigate('/nft/review') + } + const { values, errors, handleChange, handleBlur, handleSubmit, setValues, resetValues } = + useCustomForm(getInitialFormValues(), onSubmit, validate) + + // Keep form in sync with tx state useEffect(() => { - // Make sure we belong on this screen - if (!locationState?.nft) { - navigate('/nft') - return - } - }, [locationState]) + resetValues(getInitialFormValues()) + }, [tx]) - if (!locationState?.nft) return null - const nft = locationState.nft + // Derive chosen contract and nft from inputted values + const { contract, nft } = useMemo(() => { + const { contract: contractAddr, tokenId } = values + if (!contractAddr || !tokenId || !isValidAddress(contractAddr)) { + return { + contract: null, + nft: null, + } + } + const normalizedAddr = normalizeAddress(contractAddr) + const contract = contracts[normalizedAddr] || null + const nft = owned[normalizedAddr]?.find((n) => n.tokenId.toString() === tokenId) || null + return { + contract, + nft, + } + }, [values.contract, values.tokenId]) - const onClickContinue = () => { - navigate('/nft/confirm') + const onPasteAddress = async () => { + const value = await tryClipboardGet() + if (!value || !isValidAddress(value)) return + setValues({ ...values, recipient: value }) } return ( -

{`Send ${name}`}

+ + +

Send an NFT

+
+ +
+
+ + + + + {isClipboardReadSupported() && ( + + )} + + + + + + + + + + + +
+
+
+ +
+
) } +function getInitialValues( + locationState: LocationState | null, + tx: TxFlowTransaction | null +): SendNftParams { + if (tx?.params && tx?.type === TxFlowType.SendNft) { + return tx.params + } else if (locationState?.nft) { + return { + ...initialValues, + contract: locationState.nft.contract, + tokenId: locationState.nft.tokenId.toString(), + } + } else { + return initialValues + } +} + const style: Stylesheet = { + content: { + width: '100%', + maxWidth: '26em', + paddingBottom: '1em', + }, h1: { - ...Font.h2Green, - marginBottom: '1.5em', + ...Font.h2, + margin: '0 0 0 1em', + }, + inputLabel: { + ...Font.inputLabel, + marginBottom: '0.5em', + }, + copyIcon: { + height: '1em', + width: '1.25em', + }, + imageContainer: { + display: 'none', + [mq[1024]]: { + display: 'block', + marginLeft: '3em', + }, + }, + nftImage: { + ':hover': undefined, }, } diff --git a/src/features/nft/sendNft.ts b/src/features/nft/sendNft.ts index bf9b4787..badd0e6a 100644 --- a/src/features/nft/sendNft.ts +++ b/src/features/nft/sendNft.ts @@ -1,12 +1,56 @@ import { appSelect } from 'src/app/appSelect' +import { SendNftParams } from 'src/features/nft/types' +import { isValidAddress } from 'src/utils/addresses' +import { safeParseInt } from 'src/utils/amount' +import { logger } from 'src/utils/logger' import { createMonitoredSaga } from 'src/utils/saga' +import { ErrorState, invalidInput } from 'src/utils/validation' -interface sendNftParams { - contractAddress: Address - id: string +export function validate(params: SendNftParams): ErrorState { + const { recipient, contract, tokenId } = params + let errors: ErrorState = { isValid: true } + + if (!recipient) { + logger.error(`Invalid recipient: ${recipient}`) + errors = { + ...errors, + ...invalidInput('recipient', 'Recipient is required'), + } + } else if (!isValidAddress(recipient)) { + logger.error(`Invalid recipient: ${recipient}`) + errors = { + ...errors, + ...invalidInput('recipient', 'Invalid Recipient'), + } + } + + if (!contract) { + logger.error(`Invalid recipient: ${contract}`) + errors = { + ...errors, + ...invalidInput('contract', 'Contract is required'), + } + } else if (!isValidAddress(contract)) { + logger.error(`Invalid recipient: ${contract}`) + errors = { + ...errors, + ...invalidInput('contract', 'Invalid Contract'), + } + } + + const parsedTokenId = safeParseInt(tokenId) + if (parsedTokenId === null || parsedTokenId < 0) { + logger.error(`Invalid tokenId: ${tokenId}`) + errors = { + ...errors, + ...invalidInput('tokenId', 'Invalid Token Id'), + } + } + + return errors } -function* sendNft({ contractAddress, id }: sendNftParams) { +function* sendNft({ recipient, contract, tokenId }: SendNftParams) { const address = yield* appSelect((state) => state.wallet.address) if (!address) throw new Error('Cannot send Nfts before address is set') } @@ -16,4 +60,4 @@ export const { wrappedSaga: sendNftSaga, reducer: sendNftReducer, actions: sendNftActions, -} = createMonitoredSaga(sendNft, 'sendNft') +} = createMonitoredSaga(sendNft, 'sendNft') diff --git a/src/features/nft/types.ts b/src/features/nft/types.ts index 0cb9280b..f654ffcb 100644 --- a/src/features/nft/types.ts +++ b/src/features/nft/types.ts @@ -10,3 +10,9 @@ export interface NftContract { symbol: string uri?: string } + +export interface SendNftParams { + recipient: Address + contract: Address + tokenId: string +} diff --git a/src/features/txFlow/types.ts b/src/features/txFlow/types.ts index 231e0d3a..5810257e 100644 --- a/src/features/txFlow/types.ts +++ b/src/features/txFlow/types.ts @@ -1,6 +1,7 @@ import { ExchangeTokenParams } from 'src/features/exchange/types' import { GovernanceVoteParams } from 'src/features/governance/types' import { LockTokenParams } from 'src/features/lock/types' +import { SendNftParams } from 'src/features/nft/types' import { SendTokenParams } from 'src/features/send/types' import { StakeTokenParams } from 'src/features/validators/types' @@ -12,6 +13,7 @@ export enum TxFlowType { Lock, Stake, Governance, + SendNft, WalletConnect, } @@ -40,9 +42,15 @@ export interface GovernanceFlowTx { params: GovernanceVoteParams } +export interface SendNftFlowTx { + type: TxFlowType.SendNft + params: SendNftParams +} + export type TxFlowTransaction = | SendFlowTx | ExchangeFlowTx | LockFlowTx | StakeFlowTx | GovernanceFlowTx + | SendNftFlowTx diff --git a/src/utils/amount.ts b/src/utils/amount.ts index 9cccd9af..363fb9c9 100644 --- a/src/utils/amount.ts +++ b/src/utils/amount.ts @@ -236,6 +236,17 @@ export function amountFieldFromWei( } } +export function safeParseInt(value: string | number | null | undefined) { + if (value === null || value === undefined) return null + try { + const asInt = parseInt('' + value) + return asInt + } catch (error) { + logger.warn('Error parsing int', error) + return null + } +} + export function fromFixidity(value: BigNumberish | null | undefined): number { if (!value) return 0 return FixedNumber.from(value) From a79b73cf4ff7ff959abe89ab83194b28f1f58789 Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Tue, 26 Apr 2022 12:36:00 -0400 Subject: [PATCH 09/20] Implement send nft confirmation screen ui --- src/components/buttons/BackButton.tsx | 11 +- src/features/nft/NftImage.tsx | 9 +- src/features/nft/NftSendConfirmScreen.tsx | 150 ++++++++++++++++--- src/features/nft/NftSendFormScreen.tsx | 30 +--- src/features/nft/hooks.ts | 23 +++ src/features/nft/sendNft.ts | 13 ++ src/features/nft/types.ts | 3 + src/features/send/SendConfirmationScreen.tsx | 3 +- src/features/types.ts | 7 + 9 files changed, 198 insertions(+), 51 deletions(-) diff --git a/src/components/buttons/BackButton.tsx b/src/components/buttons/BackButton.tsx index 3a4ad9f7..6913a862 100644 --- a/src/components/buttons/BackButton.tsx +++ b/src/components/buttons/BackButton.tsx @@ -6,12 +6,17 @@ import { import { ArrowIcon } from 'src/components/icons/Arrow' import { Color } from 'src/styles/Color' -export function BackButton(props: Omit, 'onClick'>) { - const { styles, iconStyles, margin, title, color } = props +type Props = Omit, 'onClick'> & { + onGoBack?: () => void +} + +export function BackButton(props: Props) { + const { styles, iconStyles, margin, title, color, onGoBack } = props const navigate = useNavigate() const onClickBack = () => { - navigate(-1) + if (onGoBack) onGoBack() + else navigate(-1) } return ( diff --git a/src/features/nft/NftImage.tsx b/src/features/nft/NftImage.tsx index f6fd6301..43ed539a 100644 --- a/src/features/nft/NftImage.tsx +++ b/src/features/nft/NftImage.tsx @@ -28,15 +28,16 @@ export function NftImageWithInfo({ nft, contract, styles }: Props) { const containerStyle = styles ? { ...style.imageAndInfoContainer, ...styles } : style.imageAndInfoContainer + + const isValid = !!(contract && nft) + return (
- -
- {contract && nft ? contract.symbol + ' #' + nft.tokenId : ''} -
+ +
{isValid ? contract.symbol + ' #' + nft.tokenId : ''}
diff --git a/src/features/nft/NftSendConfirmScreen.tsx b/src/features/nft/NftSendConfirmScreen.tsx index d2357e55..3050e5f0 100644 --- a/src/features/nft/NftSendConfirmScreen.tsx +++ b/src/features/nft/NftSendConfirmScreen.tsx @@ -1,54 +1,71 @@ -import { BigNumber } from 'ethers' import { useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { useAppDispatch } from 'src/app/hooks' +import { Address } from 'src/components/Address' +import { BackButton } from 'src/components/buttons/BackButton' +import { Button } from 'src/components/buttons/Button' +import SendPaymentIcon from 'src/components/icons/send_payment.svg' +import { Box } from 'src/components/layout/Box' import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' +import { MoneyValue } from 'src/components/MoneyValue' import { estimateFeeActions } from 'src/features/fees/estimateFee' import { useFee } from 'src/features/fees/utils' +import { useResolvedNftAndContract } from 'src/features/nft/hooks' +import { NftImageWithInfo } from 'src/features/nft/NftImage' +import { createNftTransferTx, sendNftActions, sendNftSagaName } from 'src/features/nft/sendNft' import { useFlowTransaction } from 'src/features/txFlow/hooks' import { txFlowCanceled } from 'src/features/txFlow/txFlowSlice' import { TxFlowType } from 'src/features/txFlow/types' import { useTxFlowStatusModals } from 'src/features/txFlow/useTxFlowStatusModals' +import { TransactionType } from 'src/features/types' +import { useWalletAddress } from 'src/features/wallet/hooks' +import { Color } from 'src/styles/Color' import { Font } from 'src/styles/fonts' +import { mq } from 'src/styles/mediaQueries' import { Stylesheet } from 'src/styles/types' import { logger } from 'src/utils/logger' export function NftSendConfirmScreen() { const dispatch = useAppDispatch() const navigate = useNavigate() + const activeAddress = useWalletAddress() const tx = useFlowTransaction() useEffect(() => { // Make sure we belong on this screen - if (tx?.type !== TxFlowType.NftSend) { + if (tx?.type !== TxFlowType.SendNft) { navigate('/nft') return } // There are no gas pre-computes for nft transfers, need to get real tx to estimate - const txRequestP = createNftTransferTx(token, recipient, BigNumber.from(amountInWei)) - txRequestP + createNftTransferTx(activeAddress, tx.params) .then((txRequest) => dispatch( - estimateFeeActions.trigger({ txs: [{ type, tx: txRequest }], forceGasEstimation: true }) + estimateFeeActions.trigger({ + txs: [{ type: TransactionType.NftTransfer, tx: txRequest }], + forceGasEstimation: true, + }) ) ) - .catch((e) => logger.error('Error computing token transfer gas', e)) + .catch((e) => logger.error('Error computing nft transfer gas', e)) }, [tx]) - if (tx?.type !== TxFlowType.NftSend) return null + if (tx?.type !== TxFlowType.SendNft) return null const params = tx.params - const { amount, total, feeAmount, feeCurrency, feeEstimates } = useFee(params.amountInWei) + const { feeAmount, feeCurrency, feeEstimates } = useFee('0') - const onGoBack = () => { + const { contract, nft } = useResolvedNftAndContract(params.contract, params.tokenId) + + const onClickBack = () => { dispatch(sendNftActions.reset()) dispatch(txFlowCanceled()) navigate(-1) } - const onSend = () => { + const onClickSend = () => { if (!tx || !feeEstimates) return dispatch(sendNftActions.trigger({ ...params, feeEstimate: feeEstimates[0] })) } @@ -56,24 +73,121 @@ export function NftSendConfirmScreen() { const { isWorking } = useTxFlowStatusModals({ sagaName: sendNftSagaName, signaturesNeeded: 1, - loadingTitle: 'Sending Payment...', - successTitle: 'Payment Sent!', - successMsg: 'Your payment has been sent successfully', - errorTitle: 'Payment Failed', - errorMsg: 'Your payment could not be processed', - reqSignatureWarningLabel: params.comment ? 'payments with comments' : undefined, + loadingTitle: 'Sending NFT...', + successTitle: 'NFT Sent!', + successMsg: 'Your NFT has been sent successfully', + errorTitle: 'Transfer Failed', + errorMsg: 'Your NFT transfer could not be processed', }) return ( -

{`Send ${name}`}

+ + +

Confirm NFT Transfer

+
+ +
+ + + +
+ + + + + + + + + + + + + + + + {feeAmount && feeCurrency ? ( + + + + ) : ( + // TODO a proper loader (need to update mocks) +
...
+ )} +
+ + + + + +
+
+ +
+
) } const style: Stylesheet = { h1: { - ...Font.h2Green, + ...Font.h2, + margin: '0 0 0 1em', + }, + content: { + width: '100%', + maxWidth: '23em', + }, + inputRow: { marginBottom: '1.5em', + [mq[1024]]: { + marginBottom: '2em', + }, + }, + labelCol: { + ...Font.inputLabel, + color: Color.primaryGrey, + width: '9em', + marginRight: '1em', + }, + valueCol: { + width: '12em', + textAlign: 'end', + }, + imageContainer: { + display: 'none', + [mq[1024]]: { + display: 'block', + marginLeft: '3em', + }, + }, + nftImage: { + ':hover': undefined, }, } diff --git a/src/features/nft/NftSendFormScreen.tsx b/src/features/nft/NftSendFormScreen.tsx index bb3aec16..de652c3f 100644 --- a/src/features/nft/NftSendFormScreen.tsx +++ b/src/features/nft/NftSendFormScreen.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo } from 'react' +import { useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { useAppDispatch, useAppSelector } from 'src/app/hooks' +import { useAppDispatch } from 'src/app/hooks' import { BackButton } from 'src/components/buttons/BackButton' import { Button } from 'src/components/buttons/Button' import PasteIcon from 'src/components/icons/paste.svg' @@ -10,7 +10,7 @@ import { SelectInput } from 'src/components/input/SelectInput' import { Box } from 'src/components/layout/Box' import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' import { useContactsAndAccountsSelect } from 'src/features/contacts/hooks' -import { useNftContracts } from 'src/features/nft/hooks' +import { useResolvedNftAndContract } from 'src/features/nft/hooks' import { NftImageWithInfo } from 'src/features/nft/NftImage' import { validate } from 'src/features/nft/sendNft' import { Nft, SendNftParams } from 'src/features/nft/types' @@ -20,7 +20,7 @@ import { TxFlowTransaction, TxFlowType } from 'src/features/txFlow/types' import { Font } from 'src/styles/fonts' import { mq } from 'src/styles/mediaQueries' import { Stylesheet } from 'src/styles/types' -import { isValidAddress, normalizeAddress } from 'src/utils/addresses' +import { isValidAddress } from 'src/utils/addresses' import { isClipboardReadSupported, tryClipboardGet } from 'src/utils/clipboard' import { useCustomForm } from 'src/utils/useCustomForm' import { useLocationState } from 'src/utils/useLocationState' @@ -41,8 +41,6 @@ export function NftSendFormScreen() { const locationState = useLocationState() const tx = useFlowTransaction() const contactOptions = useContactsAndAccountsSelect() - const contracts = useNftContracts() - const owned = useAppSelector((state) => state.nft.owned) const getInitialFormValues = () => getInitialValues(locationState, tx) @@ -59,23 +57,7 @@ export function NftSendFormScreen() { resetValues(getInitialFormValues()) }, [tx]) - // Derive chosen contract and nft from inputted values - const { contract, nft } = useMemo(() => { - const { contract: contractAddr, tokenId } = values - if (!contractAddr || !tokenId || !isValidAddress(contractAddr)) { - return { - contract: null, - nft: null, - } - } - const normalizedAddr = normalizeAddress(contractAddr) - const contract = contracts[normalizedAddr] || null - const nft = owned[normalizedAddr]?.find((n) => n.tokenId.toString() === tokenId) || null - return { - contract, - nft, - } - }, [values.contract, values.tokenId]) + const { contract, nft } = useResolvedNftAndContract(values.contract, values.tokenId) const onPasteAddress = async () => { const value = await tryClipboardGet() @@ -147,7 +129,7 @@ export function NftSendFormScreen() { {...errors['tokenId']} /> - diff --git a/src/features/nft/hooks.ts b/src/features/nft/hooks.ts index 3e7627bf..3542ac2e 100644 --- a/src/features/nft/hooks.ts +++ b/src/features/nft/hooks.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { useAppSelector } from 'src/app/hooks' import { POPULAR_NFT_CONTRACTS } from 'src/features/nft/consts' import { Nft, NftContract } from 'src/features/nft/types' +import { isValidAddress, normalizeAddress } from 'src/utils/addresses' export function useNftContracts(): Record { const customContracts = useAppSelector((s) => s.nft.customContracts) @@ -28,3 +29,25 @@ export function useSortedOwnedNfts(): Nft[] { return sortedNfts }, [owned]) } + +// Resolve chosen contract and nft from inputted values +export function useResolvedNftAndContract(contractAddr: Address, tokenId: string) { + const contracts = useNftContracts() + const owned = useAppSelector((state) => state.nft.owned) + + return useMemo(() => { + if (!contractAddr || !tokenId || !isValidAddress(contractAddr)) { + return { + contract: null, + nft: null, + } + } + const normalizedAddr = normalizeAddress(contractAddr) + const contract = contracts[normalizedAddr] || null + const nft = owned[normalizedAddr]?.find((n) => n.tokenId.toString() === tokenId) || null + return { + contract, + nft, + } + }, [contractAddr, tokenId]) +} diff --git a/src/features/nft/sendNft.ts b/src/features/nft/sendNft.ts index badd0e6a..c5add902 100644 --- a/src/features/nft/sendNft.ts +++ b/src/features/nft/sendNft.ts @@ -1,4 +1,5 @@ import { appSelect } from 'src/app/appSelect' +import { getErc721Contract } from 'src/blockchain/contracts' import { SendNftParams } from 'src/features/nft/types' import { isValidAddress } from 'src/utils/addresses' import { safeParseInt } from 'src/utils/amount' @@ -61,3 +62,15 @@ export const { reducer: sendNftReducer, actions: sendNftActions, } = createMonitoredSaga(sendNft, 'sendNft') + +export function createNftTransferTx(accountAddr: Address, params: SendNftParams) { + const { recipient, contract: contractAddr, tokenId } = params + const contract = getErc721Contract(contractAddr) + if (!contract) throw new Error(`No contract found for nft ${contractAddr}`) + // Need to specify signature in method name because erc721 overloads safeTransferFrom + return contract.populateTransaction['safeTransferFrom(address,address,uint256)']( + accountAddr, + recipient, + tokenId + ) +} diff --git a/src/features/nft/types.ts b/src/features/nft/types.ts index f654ffcb..935f1ab8 100644 --- a/src/features/nft/types.ts +++ b/src/features/nft/types.ts @@ -1,3 +1,5 @@ +import { FeeEstimate } from 'src/features/fees/types' + export interface Nft { tokenId: number tokenUri: string @@ -15,4 +17,5 @@ export interface SendNftParams { recipient: Address contract: Address tokenId: string + feeEstimate?: FeeEstimate } diff --git a/src/features/send/SendConfirmationScreen.tsx b/src/features/send/SendConfirmationScreen.tsx index 4b0b5cab..07369405 100644 --- a/src/features/send/SendConfirmationScreen.tsx +++ b/src/features/send/SendConfirmationScreen.tsx @@ -54,8 +54,7 @@ export function SendConfirmationScreen() { } else { // There are no gas pre-computes for non-native tokens, need to get real tx to estimate const token = tokens[tokenAddress] - const txRequestP = createTransferTx(token, recipient, BigNumber.from(amountInWei)) - txRequestP + createTransferTx(token, recipient, BigNumber.from(amountInWei)) .then((txRequest) => dispatch( estimateFeeActions.trigger({ txs: [{ type, tx: txRequest }], forceGasEstimation: true }) diff --git a/src/features/types.ts b/src/features/types.ts index f9095d61..6c8026e0 100644 --- a/src/features/types.ts +++ b/src/features/types.ts @@ -40,6 +40,7 @@ export enum TransactionType { ValidatorRevokePendingCelo, ValidatorActivateCelo, GovernanceVote, + NftTransfer, Other, } @@ -133,6 +134,12 @@ export interface GovernanceVoteTx extends Transaction { vote: VoteValue } +export interface NftTransferTx extends Transaction { + type: TransactionType.NftTransfer + contract: Address + tokenId: string +} + export interface OtherTx extends Transaction { type: TransactionType.Other } From b12ea6b0cc1be60aa89958dab2ea82c8c8caefd9 Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Wed, 27 Apr 2022 17:53:49 -0400 Subject: [PATCH 10/20] Implement sendNft saga Create modal to add nft contract Create addNftContract saga --- src/blockchain/contracts.ts | 11 +-- src/features/nft/AddNftContractModal.tsx | 87 ++++++++++++++++++++++++ src/features/nft/NftDashboardScreen.tsx | 10 +-- src/features/nft/addNftContract.ts | 66 ++++++++++++++++++ src/features/nft/nftSlice.ts | 2 +- src/features/nft/sendNft.ts | 47 ++++++++++++- src/features/nft/types.ts | 4 ++ src/features/tokens/addToken.ts | 5 +- src/features/types.ts | 1 + 9 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 src/features/nft/AddNftContractModal.tsx create mode 100644 src/features/nft/addNftContract.ts diff --git a/src/blockchain/contracts.ts b/src/blockchain/contracts.ts index ca9c7d11..cdb92baa 100644 --- a/src/blockchain/contracts.ts +++ b/src/blockchain/contracts.ts @@ -13,7 +13,7 @@ import { ABI as StableTokenAbi } from 'src/blockchain/ABIs/stableToken' import { ABI as ValidatorsAbi } from 'src/blockchain/ABIs/validators' import { getSigner } from 'src/blockchain/signer' import { CeloContract, config } from 'src/config' -import { areAddressesEqual } from 'src/utils/addresses' +import { areAddressesEqual, normalizeAddress } from 'src/utils/addresses' let contractCache: Partial> = {} let tokenContractCache: Partial> = {} // token address to contract @@ -38,12 +38,13 @@ export function getErc721Contract(tokenAddress: Address) { } // Search for token contract by address -export function getTokenContract(tokenAddress: Address, abi: string) { - const cachedContract = tokenContractCache[tokenAddress] +function getTokenContract(tokenAddress: Address, abi: string) { + const normalizedAddr = normalizeAddress(tokenAddress) + const cachedContract = tokenContractCache[normalizedAddr] if (cachedContract) return cachedContract const signer = getSigner().signer - const contract = new Contract(tokenAddress, abi, signer) - tokenContractCache[tokenAddress] = contract + const contract = new Contract(normalizedAddr, abi, signer) + tokenContractCache[normalizedAddr] = contract return contract } diff --git a/src/features/nft/AddNftContractModal.tsx b/src/features/nft/AddNftContractModal.tsx new file mode 100644 index 00000000..f1f6ef95 --- /dev/null +++ b/src/features/nft/AddNftContractModal.tsx @@ -0,0 +1,87 @@ +import { useAppDispatch, useAppSelector } from 'src/app/hooks' +import { Button } from 'src/components/buttons/Button' +import { AddressInput } from 'src/components/input/AddressInput' +import { HelpText } from 'src/components/input/HelpText' +import { Box } from 'src/components/layout/Box' +import { modalStyles } from 'src/components/modal/modalStyles' +import { + addNftContractActions, + addNftContractSagaName, + validate, +} from 'src/features/nft/addNftContract' +import { AddNftContractParams } from 'src/features/nft/types' +import { Color } from 'src/styles/Color' +import { Stylesheet } from 'src/styles/types' +import { SagaStatus } from 'src/utils/saga' +import { useCustomForm } from 'src/utils/useCustomForm' +import { useSagaStatusNoModal } from 'src/utils/useSagaStatus' + +const initialValues: AddNftContractParams = { + address: '', +} + +export function AddNftContractModal(props: { close: () => void }) { + const dispatch = useAppDispatch() + const customContracts = useAppSelector((s) => s.nft.customContracts) + + const onSubmit = (values: AddNftContractParams) => { + dispatch(addNftContractActions.trigger(values)) + } + + const validateForm = (values: AddNftContractParams) => validate(values, customContracts) + + const { values, errors, handleChange, handleBlur, handleSubmit } = + useCustomForm(initialValues, onSubmit, validateForm) + + const sagaStatus = useSagaStatusNoModal(addNftContractSagaName, props.close) + const isLoading = sagaStatus === SagaStatus.Started + const isFailure = sagaStatus === SagaStatus.Failure + + return ( +
+ +

+ Most cERC-721 compatible contract can be added. +
+ Enter the NFT contract address. +

+ + {isFailure && ( + + Unable to add contract, please check address. + + )} + + + + +
+
+ ) +} + +const style: Stylesheet = { + p: { + ...modalStyles.p, + margin: '0 0 1.5em 0', + }, +} diff --git a/src/features/nft/NftDashboardScreen.tsx b/src/features/nft/NftDashboardScreen.tsx index e53d6d7c..4a7db85e 100644 --- a/src/features/nft/NftDashboardScreen.tsx +++ b/src/features/nft/NftDashboardScreen.tsx @@ -10,11 +10,11 @@ import { Box } from 'src/components/layout/Box' import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' import { useModal } from 'src/components/modal/useModal' import { Spinner } from 'src/components/Spinner' +import { AddNftContractModal } from 'src/features/nft/AddNftContractModal' import { fetchNftsActions, fetchNftsSagaName } from 'src/features/nft/fetchNfts' import { useNftContracts, useSortedOwnedNfts } from 'src/features/nft/hooks' import { NftImageWithInfo } from 'src/features/nft/NftImage' import { Nft } from 'src/features/nft/types' -import { AddTokenModal } from 'src/features/tokens/AddTokenModal' import { Color } from 'src/styles/Color' import { Font } from 'src/styles/fonts' import { mq } from 'src/styles/mediaQueries' @@ -49,10 +49,11 @@ export function NftDashboardScreen() { } const { showModalWithContent, closeModal } = useModal() - const onClickAdd = () => { - //TODO - showModalWithContent({ head: 'Add New NFT', content: }) + showModalWithContent({ + head: 'Add New NFT', + content: , + }) } return ( @@ -151,7 +152,6 @@ const style: Stylesheet = { margin: '1.8em 1.8em 0 0', }, }, - spinner: { display: 'flex', alignItems: 'center', diff --git a/src/features/nft/addNftContract.ts b/src/features/nft/addNftContract.ts new file mode 100644 index 00000000..0408cc30 --- /dev/null +++ b/src/features/nft/addNftContract.ts @@ -0,0 +1,66 @@ +import { appSelect } from 'src/app/appSelect' +import { getErc721Contract } from 'src/blockchain/contracts' +import { POPULAR_NFT_CONTRACTS } from 'src/features/nft/consts' +import { fetchNftsActions } from 'src/features/nft/fetchNfts' +import { addCustomContract } from 'src/features/nft/nftSlice' +import { AddNftContractParams, NftContract } from 'src/features/nft/types' +import { isValidAddress, normalizeAddress } from 'src/utils/addresses' +import { logger } from 'src/utils/logger' +import { createMonitoredSaga } from 'src/utils/saga' +import { ErrorState, invalidInput, validateOrThrow } from 'src/utils/validation' +import { call, put } from 'typed-redux-saga' + +export function validate(params: AddNftContractParams, customContracts: NftContract[]): ErrorState { + const { address } = params + if (!address) { + return invalidInput('address', 'Contract address is required') + } + if (!isValidAddress(address)) { + logger.error(`Invalid nft contract address: ${address}`) + return invalidInput('address', 'Invalid contract address') + } + const normalized = normalizeAddress(address) + if (POPULAR_NFT_CONTRACTS.find((c) => c.contract === normalized)) { + logger.error(`Contract already exists in popular list: ${address}`) + return invalidInput('address', 'Contract is already checked by default') + } + if (customContracts.find((c) => c.contract === normalized)) { + logger.error(`Contract already exists in custom list: ${address}`) + return invalidInput('address', 'Contract already included') + } + return { isValid: true } +} + +function* addNftContract(params: AddNftContractParams) { + const customContracts = yield* appSelect((state) => state.nft.customContracts) + validateOrThrow(() => validate(params, customContracts), 'Invalid Nft Contract') + + const newContract = yield* call(getNftInfo, params.address) + yield* put(addCustomContract(newContract)) + + yield* put(fetchNftsActions.trigger()) +} + +async function getNftInfo(contractAddress: Address): Promise { + const normalizedAddr = normalizeAddress(contractAddress) + const contract = getErc721Contract(normalizedAddr) + // Note this requires the contract implement the ERC721 Metadata and + // Enumerable extensions, otherwise rejects + const symbolP: Promise = contract.symbol() + const nameP: Promise = contract.name() + const [symbol, name] = await Promise.all([symbolP, nameP]) + if (!symbol || typeof symbol !== 'string') throw new Error('Invalid nft symbol') + if (!name || typeof name !== 'string') throw new Error('Invalid nft name') + return { + symbol: symbol.substring(0, 20), + name, + contract: normalizedAddr, + } +} + +export const { + name: addNftContractSagaName, + wrappedSaga: addNftContractSaga, + reducer: addNftContractReducer, + actions: addNftContractActions, +} = createMonitoredSaga(addNftContract, 'addNftContract') diff --git a/src/features/nft/nftSlice.ts b/src/features/nft/nftSlice.ts index e3b3e0a2..5cc9ef05 100644 --- a/src/features/nft/nftSlice.ts +++ b/src/features/nft/nftSlice.ts @@ -30,7 +30,7 @@ const nftSlice = createSlice({ }, }) -export const { updateOwnedNfts, resetNfts } = nftSlice.actions +export const { updateOwnedNfts, addCustomContract, resetNfts } = nftSlice.actions const nftReducer = nftSlice.reducer const persistConfig = { diff --git a/src/features/nft/sendNft.ts b/src/features/nft/sendNft.ts index c5add902..d9712e18 100644 --- a/src/features/nft/sendNft.ts +++ b/src/features/nft/sendNft.ts @@ -1,11 +1,20 @@ +import { providers } from 'ethers' import { appSelect } from 'src/app/appSelect' import { getErc721Contract } from 'src/blockchain/contracts' +import { sendSignedTransaction, signTransaction } from 'src/blockchain/transaction' +import { fetchBalancesActions } from 'src/features/balances/fetchBalances' +import { addPlaceholderTransaction } from 'src/features/feed/feedSlice' +import { createPlaceholderForTx } from 'src/features/feed/placeholder' +import { fetchNftsActions } from 'src/features/nft/fetchNfts' import { SendNftParams } from 'src/features/nft/types' +import { setNumSignatures } from 'src/features/txFlow/txFlowSlice' +import { NftTransferTx, TransactionType } from 'src/features/types' import { isValidAddress } from 'src/utils/addresses' import { safeParseInt } from 'src/utils/amount' import { logger } from 'src/utils/logger' import { createMonitoredSaga } from 'src/utils/saga' -import { ErrorState, invalidInput } from 'src/utils/validation' +import { ErrorState, invalidInput, validateOrThrow } from 'src/utils/validation' +import { call, put } from 'typed-redux-saga' export function validate(params: SendNftParams): ErrorState { const { recipient, contract, tokenId } = params @@ -51,9 +60,23 @@ export function validate(params: SendNftParams): ErrorState { return errors } -function* sendNft({ recipient, contract, tokenId }: SendNftParams) { +function* sendNft(params: SendNftParams) { const address = yield* appSelect((state) => state.wallet.address) if (!address) throw new Error('Cannot send Nfts before address is set') + + validateOrThrow(() => validate(params), 'Invalid transaction') + + const signedTx = yield* call(createAndSignTx, address, params) + yield* put(setNumSignatures(1)) + + const txReceipt = yield* call(sendSignedTransaction, signedTx) + logger.info(`NFT transfer hash received: ${txReceipt.transactionHash}`) + + const placeholderTx = getPlaceholderTx(params, txReceipt) + yield* put(addPlaceholderTransaction(placeholderTx)) + + yield* put(fetchNftsActions.trigger()) + yield* put(fetchBalancesActions.trigger()) } export const { @@ -63,6 +86,13 @@ export const { actions: sendNftActions, } = createMonitoredSaga(sendNft, 'sendNft') +async function createAndSignTx(accountAddr: Address, params: SendNftParams) { + const tx = await createNftTransferTx(accountAddr, params) + logger.info(`Signing tx to send NFT to ${params.recipient}`) + const signedTx = await signTransaction(tx, params.feeEstimate) + return signedTx +} + export function createNftTransferTx(accountAddr: Address, params: SendNftParams) { const { recipient, contract: contractAddr, tokenId } = params const contract = getErc721Contract(contractAddr) @@ -74,3 +104,16 @@ export function createNftTransferTx(accountAddr: Address, params: SendNftParams) tokenId ) } + +function getPlaceholderTx( + params: SendNftParams, + txReceipt: providers.TransactionReceipt +): NftTransferTx { + return { + ...createPlaceholderForTx(txReceipt, '0', params.feeEstimate!), + type: TransactionType.NftTransfer, + to: params.recipient, + contract: params.contract, + tokenId: params.tokenId, + } +} diff --git a/src/features/nft/types.ts b/src/features/nft/types.ts index 935f1ab8..8b68ca86 100644 --- a/src/features/nft/types.ts +++ b/src/features/nft/types.ts @@ -19,3 +19,7 @@ export interface SendNftParams { tokenId: string feeEstimate?: FeeEstimate } + +export interface AddNftContractParams { + address: Address +} diff --git a/src/features/tokens/addToken.ts b/src/features/tokens/addToken.ts index 443f0ecf..ddac3df3 100644 --- a/src/features/tokens/addToken.ts +++ b/src/features/tokens/addToken.ts @@ -59,7 +59,8 @@ export function* addTokensByAddress(addresses: Set) { } async function getTokenInfo(tokenAddress: Address): Promise { - const contract = getErc20Contract(tokenAddress) + const normalizedAddr = normalizeAddress(tokenAddress) + const contract = getErc20Contract(normalizedAddr) // Note this assumes the existence of decimals, symbols, and name methods, // which are technically optional. May revisit later const symbolP: Promise = contract.symbol() @@ -73,7 +74,7 @@ async function getTokenInfo(tokenAddress: Address): Promise { return { symbol: symbol.substring(0, 8), name, - address: normalizeAddress(tokenAddress), + address: normalizedAddr, decimals, chainId: config.chainId, } diff --git a/src/features/types.ts b/src/features/types.ts index 6c8026e0..1f16ca88 100644 --- a/src/features/types.ts +++ b/src/features/types.ts @@ -168,6 +168,7 @@ export type CeloTransaction = | LockTokenTx | StakeTokenTx | GovernanceVoteTx + | NftTransferTx | OtherTx export type TransactionMap = Record // hash to item From af7ac096161df6195e143465e9d44e90e1be520f Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Thu, 28 Apr 2022 13:35:51 -0400 Subject: [PATCH 11/20] Change ledger init config check to fail open --- src/features/ledger/LedgerSigner.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/features/ledger/LedgerSigner.ts b/src/features/ledger/LedgerSigner.ts index b4109e8d..2f028d94 100644 --- a/src/features/ledger/LedgerSigner.ts +++ b/src/features/ledger/LedgerSigner.ts @@ -30,9 +30,17 @@ export class LedgerSigner extends Signer { } private async validateCeloAppVersion() { - const appConfiguration = await this.perform((celoApp) => celoApp.getAppConfiguration()) - if (!appConfiguration) throw new Error('Unable to retrieve Ledger app configuration') - if (!appConfiguration.version) throw new Error('Ledger app configuration missing version info') + let appConfiguration + try { + appConfiguration = await this.perform((celoApp) => celoApp.getAppConfiguration()) + if (!appConfiguration) throw new Error('Unable to retrieve Ledger app configuration') + if (!appConfiguration.version) throw new Error('Ledger app config missing version info') + } catch (error) { + // The getAppConfiguration has been flaky since the latest Ledger firmware update + // To prevent valid app configs from being blocked, this will fail open for now + logger.error('Unable to get ledger app config. Sometimes flaky, swallowing error.', error) + return + } const version: string = appConfiguration.version const versionSegments = version.split('.').map((s) => parseInt(s)) From 27a5f323f3a034ad4c5dc0f260ec85ba383571cc Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Thu, 28 Apr 2022 13:59:32 -0400 Subject: [PATCH 12/20] Fix addNftContract modal and saga Partially implement NFT tx feed support --- src/app/rootSaga.ts | 11 +++ src/features/feed/TransactionReview.tsx | 8 +++ .../feed/components/NftTransferReview.tsx | 67 +++++++++++++++++++ src/features/feed/parseFeedTransaction.ts | 2 + src/features/feed/transactionDescription.ts | 4 ++ src/features/nft/AddNftContractModal.tsx | 4 +- src/features/nft/NftDashboardScreen.tsx | 27 +++++--- src/features/nft/NftImage.tsx | 2 + src/features/nft/addNftContract.ts | 4 +- 9 files changed, 116 insertions(+), 13 deletions(-) create mode 100644 src/features/feed/components/NftTransferReview.tsx diff --git a/src/app/rootSaga.ts b/src/app/rootSaga.ts index ebcf1bda..28b67736 100644 --- a/src/app/rootSaga.ts +++ b/src/app/rootSaga.ts @@ -50,6 +50,12 @@ import { lockTokenSaga, lockTokenSagaName, } from 'src/features/lock/lockToken' +import { + addNftContractActions, + addNftContractReducer, + addNftContractSaga, + addNftContractSagaName, +} from 'src/features/nft/addNftContract' import { fetchNftsActions, fetchNftsReducer, @@ -239,6 +245,11 @@ export const monitoredSagas: { reducer: sendNftReducer, actions: sendNftActions, }, + [addNftContractSagaName]: { + saga: addNftContractSaga, + reducer: addNftContractReducer, + actions: addNftContractActions, + }, [editAccountSagaName]: { saga: editAccountSaga, reducer: editAccountReducer, diff --git a/src/features/feed/TransactionReview.tsx b/src/features/feed/TransactionReview.tsx index ce461c3e..681e08c1 100644 --- a/src/features/feed/TransactionReview.tsx +++ b/src/features/feed/TransactionReview.tsx @@ -6,6 +6,7 @@ import { CloseButton } from 'src/components/buttons/CloseButton' import { Box } from 'src/components/layout/Box' import { GenericTransactionReview } from 'src/features/feed/components/GenericTransactionReview' import { GovernanceVoteReview } from 'src/features/feed/components/GovernanceVoteReview' +import { NftTransferReview } from 'src/features/feed/components/NftTransferReview' import { StakeTokenReview } from 'src/features/feed/components/StakeTokenReview' import { TokenExchangeReview } from 'src/features/feed/components/TokenExchangeReview' import { TokenTransferReview } from 'src/features/feed/components/TokenTransferReview' @@ -110,6 +111,13 @@ function getContentByTxType(tx: CeloTransaction, tokens: TokenMap) { } } + if (tx.type === TransactionType.NftTransfer) { + return { + description, + content: , + } + } + return { description, content: , diff --git a/src/features/feed/components/NftTransferReview.tsx b/src/features/feed/components/NftTransferReview.tsx new file mode 100644 index 00000000..e93e565b --- /dev/null +++ b/src/features/feed/components/NftTransferReview.tsx @@ -0,0 +1,67 @@ +import { Address, useSendToAddress } from 'src/components/Address' +import { Button } from 'src/components/buttons/Button' +import { Box } from 'src/components/layout/Box' +import { MoneyValue } from 'src/components/MoneyValue' +import { TransactionStatusProperty } from 'src/features/feed/components/CommonTransactionProperties' +import { + TransactionProperty, + TransactionPropertyGroup, +} from 'src/features/feed/components/TransactionPropertyGroup' +import { txReviewStyles } from 'src/features/feed/components/txReviewStyles' +import { getFeeFromConfirmedTx } from 'src/features/fees/utils' +import { useNftContracts } from 'src/features/nft/hooks' +import { NftTransferTx } from 'src/features/types' +import { Stylesheet } from 'src/styles/types' + +interface Props { + tx: NftTransferTx +} + +export function NftTransferReview({ tx }: Props) { + const contracts = useNftContracts() + const { feeValue, feeCurrency } = getFeeFromConfirmedTx(tx) + + const onClickSendButton = useSendToAddress(tx.to) + + return ( + + + +
+
+ +
+
+ + + Fee: + + + + + + Contract: + {contracts[tx.contract]?.name || tx.contract} + + + Token Id: + {tx.tokenId} + + +
+ ) +} + +const style: Stylesheet = { + amountLabel: { + display: 'inline-block', + minWidth: '5em', + }, +} diff --git a/src/features/feed/parseFeedTransaction.ts b/src/features/feed/parseFeedTransaction.ts index cbab38c4..acdbb2b8 100644 --- a/src/features/feed/parseFeedTransaction.ts +++ b/src/features/feed/parseFeedTransaction.ts @@ -132,6 +132,8 @@ export function parseTransaction( return parseGovernanceTx(tx, abiInterfaces) } + // TODO parse NFT transfers here + if (tx.tokenTransfers && tx.tokenTransfers.length && !isTxInputEmpty(tx)) { return parseTxWithTokenTransfers(tx, address, abiInterfaces) } diff --git a/src/features/feed/transactionDescription.ts b/src/features/feed/transactionDescription.ts index cce5f8b2..f5a008f9 100644 --- a/src/features/feed/transactionDescription.ts +++ b/src/features/feed/transactionDescription.ts @@ -65,5 +65,9 @@ export function getTransactionDescription( return 'Governance Vote' } + if (tx.type === TransactionType.NftTransfer) { + return 'Nft Sent' + } + return 'Transaction Sent' } diff --git a/src/features/nft/AddNftContractModal.tsx b/src/features/nft/AddNftContractModal.tsx index f1f6ef95..e9a2f06a 100644 --- a/src/features/nft/AddNftContractModal.tsx +++ b/src/features/nft/AddNftContractModal.tsx @@ -43,6 +43,8 @@ export function AddNftContractModal(props: { close: () => void }) {

Most cERC-721 compatible contract can be added.
+ The Metadata + Enumerable extensions are required. +
Enter the NFT contract address.

void }) { Unable to add contract, please check address. )} - +
+ ) : transparent ? ( +
+ {children} +
) : null } + +const style: Stylesheet = { + transparent: { + visibility: 'hidden', + position: 'relative', + }, +} diff --git a/src/features/nft/NftImage.tsx b/src/features/nft/NftImage.tsx index e2c59b82..1194951d 100644 --- a/src/features/nft/NftImage.tsx +++ b/src/features/nft/NftImage.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react' +import { Fade } from 'src/components/animation/Fade' import { KebabMenuIcon } from 'src/components/icons/KebabMenu' import NftIcon from 'src/components/icons/nft.svg' import { Box } from 'src/components/layout/Box' @@ -14,24 +16,26 @@ interface Props { } export function NftImage({ nft, contract, styles }: Props) { + const [loaded, setLoaded] = useState(false) + const containerStyle = styles ? { ...style.defaultImageContainer, ...styles } : style.defaultImageContainer + return ( - {nft?.tokenUri && contract && ( -
- + {nft?.imageUri && contract && ( +
+ + {nft.tokenId.toString()} setLoaded(true)} + onError={() => setLoaded(false)} + /> +
)} @@ -59,18 +63,6 @@ export function NftImageWithInfo({ nft, contract, styles }: Props) { ) } -// TODO add csp here too? -function ImageFrameSrcdoc(tokenUri: string): string { - return ` - - - - - ` -} - const style: Stylesheet = { defaultImageContainer: { position: 'relative', @@ -115,14 +107,20 @@ const style: Stylesheet = { fontSize: '1.2em', marginTop: '0.1em', }, - frameContainer: { + actualImageContainer: { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, + div: { + width: '100%', + height: '100%', + }, }, - imageIframe: { - border: 'none', + actualImage: { + width: '100%', + height: '100%', + objectFit: 'cover', }, } From ab3ff81f6971bf2328963898a7844534c807f5c5 Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Mon, 2 May 2022 11:41:44 -0400 Subject: [PATCH 16/20] Use actual address while fetching --- src/features/nft/fetchNfts.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/features/nft/fetchNfts.ts b/src/features/nft/fetchNfts.ts index be06aa79..28951875 100644 --- a/src/features/nft/fetchNfts.ts +++ b/src/features/nft/fetchNfts.ts @@ -22,13 +22,7 @@ function* fetchNfts(force?: boolean) { const contractList = getContractList(customContracts) - const ownedUpdated = yield* call( - fetchNftsForContracts, - //TODO - '0xDE33e71fAECdEad20e6A8af8f362d2236CbA005f', - contractList, - owned - ) + const ownedUpdated = yield* call(fetchNftsForContracts, address, contractList, owned) yield* put(updateOwnedNfts(ownedUpdated)) yield* spawn(fetchNftImageUris, ownedUpdated) } From 015121697b75f97971374ef3041e11df6145eadc Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Mon, 2 May 2022 11:42:09 -0400 Subject: [PATCH 17/20] Bump version to 1.7.0 --- package-electron.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-electron.json b/package-electron.json index 1e014472..14ad460a 100644 --- a/package-electron.json +++ b/package-electron.json @@ -1,6 +1,6 @@ { "name": "celo-web-wallet", - "version": "1.6.2", + "version": "1.7.0", "description": "A lightweight web and desktop wallet for the Celo network", "main": "main.js", "keywords": [ diff --git a/package.json b/package.json index 7f66e34c..60a73924 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "celo-web-wallet", - "version": "1.6.2", + "version": "1.7.0", "description": "A lightweight web and desktop wallet for the Celo network", "keywords": [ "Celo", From 3857e8b96e5059cc062037b14965672609813177 Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Mon, 2 May 2022 12:14:26 -0400 Subject: [PATCH 18/20] Remove unused dev dep --- package.json | 1 - yarn.lock | 12 ------------ 2 files changed, 13 deletions(-) diff --git a/package.json b/package.json index 60a73924..295a4b46 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,6 @@ "jasmine": "^4.0.2", "node-fetch": "^2.6.7", "prettier": "^2.5.1", - "redux-mock-store": "^1.5.4", "style-loader": "^3.3.1", "ts-loader": "^9.2.7", "typescript": "^4.5.5", diff --git a/yarn.lock b/yarn.lock index 66513a94..ba9f68c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6178,11 +6178,6 @@ lodash.isequal@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= -lodash.isplainobject@^4.0.6: - version "4.0.6" - resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" - integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= - lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -7298,13 +7293,6 @@ rechoir@^0.7.0: dependencies: resolve "^1.9.0" -redux-mock-store@^1.5.4: - version "1.5.4" - resolved "https://registry.yarnpkg.com/redux-mock-store/-/redux-mock-store-1.5.4.tgz#90d02495fd918ddbaa96b83aef626287c9ab5872" - integrity sha512-xmcA0O/tjCLXhh9Fuiq6pMrJCwFRaouA8436zcikdIpYWWCjU76CRk+i2bHx8EeiSiMGnB85/lZdU3wIJVXHTA== - dependencies: - lodash.isplainobject "^4.0.6" - redux-persist@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8" From 3c898128fa917a646620d3ed0c3b6d0f794c8abe Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Mon, 2 May 2022 16:24:29 -0400 Subject: [PATCH 19/20] Finish feed support for outgoing NFT transfers --- src/blockchain/contracts.ts | 13 +++++- src/features/feed/fetchFeed.ts | 10 +++++ src/features/feed/parseFeedTransaction.ts | 52 ++++++++++++++++++++++- src/features/feed/types.ts | 2 + src/features/nft/NftDashboardScreen.tsx | 4 +- src/features/nft/NftImage.tsx | 6 ++- src/features/nft/hooks.ts | 29 ++++++++----- src/features/nft/types.ts | 2 + src/features/types.ts | 4 +- 9 files changed, 105 insertions(+), 17 deletions(-) diff --git a/src/blockchain/contracts.ts b/src/blockchain/contracts.ts index cdb92baa..f2b60986 100644 --- a/src/blockchain/contracts.ts +++ b/src/blockchain/contracts.ts @@ -1,4 +1,4 @@ -import { Contract } from 'ethers' +import { Contract, utils } from 'ethers' import { ABI as AccountsAbi } from 'src/blockchain/ABIs/accounts' import { ABI as ElectionAbi } from 'src/blockchain/ABIs/election' import { ABI as Erc20Abi } from 'src/blockchain/ABIs/erc20' @@ -99,6 +99,17 @@ export function getContractName(address: Address): CeloContract | null { return null } +let erc721Interface: utils.Interface + +// Normally, interfaces are retrieved through the getContract() function +// but ERC721 is an exception because no core celo contracts use it +export function getErc721AbiInterface() { + if (!erc721Interface) { + erc721Interface = new utils.Interface(Erc721Abi) + } + return erc721Interface +} + // Necessary if the signer changes, as in after a logout export function clearContractCache() { contractCache = {} diff --git a/src/features/feed/fetchFeed.ts b/src/features/feed/fetchFeed.ts index 351c7bbf..a878a77f 100644 --- a/src/features/feed/fetchFeed.ts +++ b/src/features/feed/fetchFeed.ts @@ -12,6 +12,8 @@ import { BlockscoutTx, BlockscoutTxBase, } from 'src/features/feed/types' +import { selectNftContracts } from 'src/features/nft/hooks' +import { NftContractMap } from 'src/features/nft/types' import { addTokensByAddress } from 'src/features/tokens/addToken' import { selectTokens } from 'src/features/tokens/hooks' import { TokenMap } from 'src/features/tokens/types' @@ -35,11 +37,13 @@ function* fetchFeed() { const lastBlockNumber = yield* appSelect((state) => state.feed.lastBlockNumber) const tokensByAddress = yield* selectTokens() + const nftContractsByAddress = yield* selectNftContracts() const { newTransactions, newLastBlockNumber } = yield* call( doFetchFeed, address, tokensByAddress, + nftContractsByAddress, lastBlockNumber ) yield* put( @@ -67,6 +71,7 @@ export const { async function doFetchFeed( address: Address, tokensByAddress: TokenMap, + nftContractsByAddress: NftContractMap, lastBlockNumber: number | null ) { const txList = await fetchTxsFromBlockscout(address, lastBlockNumber) @@ -85,6 +90,7 @@ async function doFetchFeed( address, tokensByAddress, exchangesByAddress, + nftContractsByAddress, abiInterfaces ) if (parsedTx) newTransactions[parsedTx.hash] = parsedTx @@ -124,6 +130,10 @@ async function fetchTxsFromBlockscout(address: Address, lastBlockNumber: number // Attach the token transfers to their parent txs in the first list for (const tx of tokenTxList) { + // Ignoring incoming NFT transfers for now + // TODO revisit this if incoming NFT parsing is needed + if (tx.tokenId || !tx.value) continue + // If transfer doesn't have a corresponding tx from the first list, make a placeholder // Most common reason would be incoming token transfer if (!txMap.has(tx.hash)) { diff --git a/src/features/feed/parseFeedTransaction.ts b/src/features/feed/parseFeedTransaction.ts index acdbb2b8..912f0c9d 100644 --- a/src/features/feed/parseFeedTransaction.ts +++ b/src/features/feed/parseFeedTransaction.ts @@ -1,8 +1,10 @@ import { BigNumber, BigNumberish, utils } from 'ethers' +import { getErc721AbiInterface } from 'src/blockchain/contracts' import { CeloContract, config } from 'src/config' import { MAX_COMMENT_CHAR_LENGTH } from 'src/consts' import { AbiInterfaceMap, BlockscoutTokenTransfer, BlockscoutTx } from 'src/features/feed/types' import { OrderedVoteValue } from 'src/features/governance/types' +import { NftContract, NftContractMap } from 'src/features/nft/types' import { isNativeTokenAddress, isStableToken, @@ -18,6 +20,7 @@ import { EscrowWithdrawTx, GovernanceVoteTx, LockTokenTx, + NftTransferTx, OtherTokenApproveTx, OtherTokenTransferTx, OtherTx, @@ -82,6 +85,7 @@ export function parseTransaction( address: Address, // wallet address tokensByAddress: Record, exchangesByAddress: Record, + nftContractsByAddress: NftContractMap, abiInterfaces: AbiInterfaceMap ): CeloTransaction | null { const to = normalizeAddress(tx.to) @@ -132,7 +136,12 @@ export function parseTransaction( return parseGovernanceTx(tx, abiInterfaces) } - // TODO parse NFT transfers here + if (nftContractsByAddress[to]) { + // If recipient was a known NFT + return parseOutgoingNftTx(tx, nftContractsByAddress[to]) + } + + // TODO, support incoming NFT tx parsing. See related TODO in fetchFeed.ts#fetchTxsFromBlockscout if (tx.tokenTransfers && tx.tokenTransfers.length && !isTxInputEmpty(tx)) { return parseTxWithTokenTransfers(tx, address, abiInterfaces) @@ -354,6 +363,47 @@ function parseTxWithTokenTransfers( } } +// Parse transactions to nft contracts +function parseOutgoingNftTx(tx: BlockscoutTx, contract: NftContract): NftTransferTx | OtherTx { + try { + const abiInterface = getErc721AbiInterface() + const txDescription = abiInterface.parseTransaction({ data: tx.input }) + const name = txDescription.name + if (name === 'safeTransferFrom' || name === 'transferFrom') { + return parseOutgoingNftTransfer( + tx, + contract, + txDescription.args.to, + txDescription.args.tokenId + ) + } else { + logger.warn(`Parsing nft tx with unsupported tx description name: ${name}`, tx) + return parseOtherTx(tx) + } + } catch (error) { + logger.error('Failed to parse nft tx data', error, tx) + return parseOtherTx(tx) + } +} + +function parseOutgoingNftTransfer( + tx: BlockscoutTx, + contract: NftContract, + to: Address, + tokenId: string +): NftTransferTx { + if (!to || !isValidAddress(to) || !tokenId) { + throw new Error('Transfer tx has invalid properties') + } + return { + ...parseOtherTx(tx), + type: TransactionType.NftTransfer, + to, + tokenId: tokenId.toString(), + contract: contract.address, + } +} + function parseOutgoingEscrowTx( tx: BlockscoutTx, address: Address, diff --git a/src/features/feed/types.ts b/src/features/feed/types.ts index f54e2211..c1d5cf53 100644 --- a/src/features/feed/types.ts +++ b/src/features/feed/types.ts @@ -33,6 +33,8 @@ export interface BlockscoutTokenTransfer extends BlockscoutTxBase { tokenName: string tokenDecimal: string logIndex: string + // Note, this is normally included for ERC721 (NFT) transfers but not ERC20 + tokenId?: string } export type AbiInterfaceMap = Partial> diff --git a/src/features/nft/NftDashboardScreen.tsx b/src/features/nft/NftDashboardScreen.tsx index 4fde24f5..fb9e6475 100644 --- a/src/features/nft/NftDashboardScreen.tsx +++ b/src/features/nft/NftDashboardScreen.tsx @@ -83,7 +83,7 @@ export function NftDashboardScreen() { )} {isLoading && !owned.length && ( - +

Searching contracts for your NFTs...

@@ -169,7 +169,7 @@ const style: Stylesheet = { top: 20, bottom: 0, zIndex: 100, - opacity: 0.7, + opacity: 0.6, background: Color.fillLighter, borderRadius: 20, }, diff --git a/src/features/nft/NftImage.tsx b/src/features/nft/NftImage.tsx index 1194951d..5097c8fe 100644 --- a/src/features/nft/NftImage.tsx +++ b/src/features/nft/NftImage.tsx @@ -8,6 +8,7 @@ import { Color } from 'src/styles/Color' import { Font } from 'src/styles/fonts' import { mq } from 'src/styles/mediaQueries' import { Styles, Stylesheet } from 'src/styles/types' +import { logger } from 'src/utils/logger' interface Props { nft: Nft | null @@ -33,7 +34,10 @@ export function NftImage({ nft, contract, styles }: Props) { css={style.actualImage} alt={nft.tokenId.toString()} onLoad={() => setLoaded(true)} - onError={() => setLoaded(false)} + onError={(e) => { + logger.error('Error loading nft image', e, nft.imageUri) + setLoaded(false) + }} />
diff --git a/src/features/nft/hooks.ts b/src/features/nft/hooks.ts index a943c8ff..4b82d2d9 100644 --- a/src/features/nft/hooks.ts +++ b/src/features/nft/hooks.ts @@ -1,21 +1,28 @@ import { useMemo } from 'react' +import { appSelect } from 'src/app/appSelect' import { useAppSelector } from 'src/app/hooks' import { POPULAR_NFT_CONTRACTS } from 'src/features/nft/consts' -import { Nft, NftContract } from 'src/features/nft/types' +import { Nft, NftContract, NftContractMap } from 'src/features/nft/types' import { isValidAddress, normalizeAddress } from 'src/utils/addresses' -export function useNftContracts(): Record { +export function useNftContracts(): NftContractMap { const customContracts = useAppSelector((s) => s.nft.customContracts) - return useMemo(() => { - const result: Record = {} - for (const contract of POPULAR_NFT_CONTRACTS) { - result[contract.address] = contract - } - for (const contract of customContracts) { + return useMemo(() => mergeWithPopularContracts(customContracts), [customContracts]) +} + +export function* selectNftContracts() { + const customContracts = yield* appSelect((s) => s.nft.customContracts) + return mergeWithPopularContracts(customContracts) +} + +function mergeWithPopularContracts(customContracts: NftContract[]): NftContractMap { + return [...POPULAR_NFT_CONTRACTS, ...customContracts].reduce( + (result, contract) => { result[contract.address] = contract - } - return result - }, [customContracts]) + return result + }, + {} + ) } export function useSortedOwnedNfts(): Nft[] { diff --git a/src/features/nft/types.ts b/src/features/nft/types.ts index 1e7e17a0..efaf1a8e 100644 --- a/src/features/nft/types.ts +++ b/src/features/nft/types.ts @@ -14,6 +14,8 @@ export interface NftContract { uri?: string } +export type NftContractMap = Record + export interface SendNftParams { recipient: Address contract: Address diff --git a/src/features/types.ts b/src/features/types.ts index 1f16ca88..b72fb836 100644 --- a/src/features/types.ts +++ b/src/features/types.ts @@ -17,6 +17,8 @@ interface Transaction { inputData?: string } +// Note, new tx types must be added at the bottom +// or old txs will be mislabeled in the feed. export enum TransactionType { StableTokenTransfer, StableTokenTransferWithComment, @@ -40,8 +42,8 @@ export enum TransactionType { ValidatorRevokePendingCelo, ValidatorActivateCelo, GovernanceVote, - NftTransfer, Other, + NftTransfer, } interface TokenTransferTx extends Transaction { From dacab5844834da83aceccb8289c47baabbff347c Mon Sep 17 00:00:00 2001 From: J M Rossy Date: Tue, 3 May 2022 12:43:12 -0400 Subject: [PATCH 20/20] Improve nft image fetching reliability --- src/app/rootSaga.ts | 3 +- src/consts.ts | 2 +- src/features/nft/NftDashboardScreen.tsx | 3 +- src/features/nft/NftDetailsScreen.tsx | 22 ++++++--- src/features/nft/NftImage.tsx | 4 +- src/features/nft/fetchNfts.ts | 64 +++++++++++++++++++------ src/features/nft/hooks.ts | 4 +- src/utils/retry.ts | 4 +- src/utils/saga.ts | 4 +- 9 files changed, 77 insertions(+), 33 deletions(-) diff --git a/src/app/rootSaga.ts b/src/app/rootSaga.ts index 28b67736..1c7ff2e0 100644 --- a/src/app/rootSaga.ts +++ b/src/app/rootSaga.ts @@ -57,6 +57,7 @@ import { addNftContractSagaName, } from 'src/features/nft/addNftContract' import { + fetchNftImagesSaga, fetchNftsActions, fetchNftsReducer, fetchNftsSaga, @@ -144,7 +145,7 @@ function* init() { } // All regular sagas must be included here -const sagas = [walletStatusPoller, watchWalletConnect] +const sagas = [walletStatusPoller, watchWalletConnect, fetchNftImagesSaga] // All monitored sagas must be included here export const monitoredSagas: { diff --git a/src/consts.ts b/src/consts.ts index e19f2bef..5279cecc 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -54,7 +54,7 @@ export const VALIDATOR_ACTIVATABLE_STALE_TIME = 43200000 // 12 hours export const STAKE_EVENTS_STALE_TIME = 10000 // 10 seconds export const PROPOSAL_LIST_STALE_TIME = 60000 // 1 minutes export const TOKEN_PRICE_STALE_TIME = 900000 // 15 minutes -export const NFT_SEARCH_STALE_TIME = 60000 // 60 seconds +export const NFT_SEARCH_STALE_TIME = 120000 // 2 minutes export const GOVERNANCE_GITHUB_BASEURL = 'https://api.github.com/repos/celo-org/governance/contents/CGPs/' diff --git a/src/features/nft/NftDashboardScreen.tsx b/src/features/nft/NftDashboardScreen.tsx index fb9e6475..7b6f6d61 100644 --- a/src/features/nft/NftDashboardScreen.tsx +++ b/src/features/nft/NftDashboardScreen.tsx @@ -161,8 +161,9 @@ const style: Stylesheet = { }, spinner: { display: 'flex', - alignItems: 'center', + alignItems: 'flex-start', justifyContent: 'center', + paddingTop: 'min(10%, 12em)', position: 'absolute', left: -10, right: 0, diff --git a/src/features/nft/NftDetailsScreen.tsx b/src/features/nft/NftDetailsScreen.tsx index b3b0099e..37c0b42e 100644 --- a/src/features/nft/NftDetailsScreen.tsx +++ b/src/features/nft/NftDetailsScreen.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/react' import { useEffect } from 'react' +import { useDispatch } from 'react-redux' import { useNavigate } from 'react-router-dom' import { BackButton } from 'src/components/buttons/BackButton' import { Button } from 'src/components/buttons/Button' @@ -7,7 +8,8 @@ import { TextLink } from 'src/components/buttons/TextLink' import SendIcon from 'src/components/icons/send_payment.svg' import { Box } from 'src/components/layout/Box' import { ScreenContentFrame } from 'src/components/layout/ScreenContentFrame' -import { useNftContracts } from 'src/features/nft/hooks' +import { fetchNftImagesTrigger } from 'src/features/nft/fetchNfts' +import { useResolvedNftAndContract } from 'src/features/nft/hooks' import { NftImage } from 'src/features/nft/NftImage' import { Nft } from 'src/features/nft/types' import { Color } from 'src/styles/Color' @@ -22,22 +24,30 @@ interface LocationState { } export function NftDetailsScreen() { + const dispatch = useDispatch() const navigate = useNavigate() const locationState = useLocationState() - const contracts = useNftContracts() useEffect(() => { - // Make sure we belong on this screen if (!locationState?.nft) { navigate('/nft') return } }, [locationState]) - if (!locationState?.nft) return null - const nft = locationState.nft + const { contract, nft } = useResolvedNftAndContract( + locationState?.nft?.contract, + locationState?.nft?.tokenId?.toString() + ) + + useEffect(() => { + if (nft && !nft.imageUri) { + dispatch(fetchNftImagesTrigger([nft])) + } + }, [nft]) + + if (!contract || !nft) return null - const contract = contracts[nft.contract] const fullName = `${contract.symbol} #${nft.tokenId}` const onClickSend = () => { diff --git a/src/features/nft/NftImage.tsx b/src/features/nft/NftImage.tsx index 5097c8fe..481c3df8 100644 --- a/src/features/nft/NftImage.tsx +++ b/src/features/nft/NftImage.tsx @@ -34,8 +34,8 @@ export function NftImage({ nft, contract, styles }: Props) { css={style.actualImage} alt={nft.tokenId.toString()} onLoad={() => setLoaded(true)} - onError={(e) => { - logger.error('Error loading nft image', e, nft.imageUri) + onError={() => { + logger.error('Error loading nft image', nft.imageUri) setLoaded(false) }} /> diff --git a/src/features/nft/fetchNfts.ts b/src/features/nft/fetchNfts.ts index 28951875..fd7cf44a 100644 --- a/src/features/nft/fetchNfts.ts +++ b/src/features/nft/fetchNfts.ts @@ -7,11 +7,18 @@ import { setImageUri, updateOwnedNfts } from 'src/features/nft/nftSlice' import { Nft, NftContract, NftMetadata } from 'src/features/nft/types' import { formatIpfsUrl, getUrlExtensionType, UrlExtensionType } from 'src/features/nft/utils' import { logger } from 'src/utils/logger' -import { createMonitoredSaga } from 'src/utils/saga' +import { retryAsync } from 'src/utils/retry' +import { createMonitoredSaga, createSaga } from 'src/utils/saga' import { isStale } from 'src/utils/time' import { fetchWithTimeout } from 'src/utils/timeout' import { call, put, spawn } from 'typed-redux-saga' +// Skip fetching NFTs for a contract when balance is > this +const NFT_FETCH_LIMIT = 300 +const NFT_FETCH_LIMIT_ERROR = new Error('NFT_FETCH_LIMIT_ERROR') +// Skip image fetching for nft sets > this +const IMAGE_FETCH_LIMIT = 30 + function* fetchNfts(force?: boolean) { const address = yield* appSelect((state) => state.wallet.address) if (!address) throw new Error('Cannot fetch NFTs before address is set') @@ -24,7 +31,7 @@ function* fetchNfts(force?: boolean) { const ownedUpdated = yield* call(fetchNftsForContracts, address, contractList, owned) yield* put(updateOwnedNfts(ownedUpdated)) - yield* spawn(fetchNftImageUris, ownedUpdated) + yield* spawn(fetchNftImageUris, Object.values(ownedUpdated).flat()) } export const { @@ -51,8 +58,10 @@ async function fetchNftsForContracts( for (const contractAddr of contracts) { try { const contract = getErc721Contract(contractAddr) - const numOwned = await contract.balanceOf(account) + const _numOwned = await contract.balanceOf(account) + const numOwned = BigNumber.from(_numOwned).toNumber() if (!numOwned || numOwned <= 0) continue + if (numOwned > NFT_FETCH_LIMIT) throw NFT_FETCH_LIMIT_ERROR const nfts: Nft[] = [] for (let i = 0; i < numOwned; i++) { const nft = await fetchNftDetails(contract, i, account, ownedState[contractAddr]) @@ -61,6 +70,8 @@ async function fetchNftsForContracts( if (nfts.length) result[contractAddr] = nfts } catch (error) { logger.error('Failed to fetch NFTs for contract:', contractAddr, error) + if (error === NFT_FETCH_LIMIT_ERROR) + throw new Error(`Nft count exceeds limit of ${NFT_FETCH_LIMIT}`) } } return result @@ -92,7 +103,7 @@ async function fetchNftDetails( logger.error('Invalid token uri from contract:', contract.address, fetchedTokenUri) return null } - tokenUri = fetchedTokenUri + tokenUri = fetchedTokenUri.toString() } let imageUri: string | undefined @@ -114,8 +125,14 @@ async function fetchNftDetails( } // Note, only IPFS-based images are allowed for now -function* fetchNftImageUris(owned: Record) { - const nftList = Object.values(owned).flat() +function* fetchNftImageUris(nftList: Nft[]) { + // IPFS, even via cloudflare, is unreliable + // So skipping image fetching for large lists + if (nftList.length > IMAGE_FETCH_LIMIT) { + logger.debug('NFT length exceeds image fetch limit, skipping', nftList.length) + return + } + for (const nft of nftList) { if (nft.imageUri) continue @@ -137,6 +154,10 @@ function* fetchNftImageUris(owned: Record) { } } +export const { wrappedSaga: fetchNftImagesSaga, trigger: fetchNftImagesTrigger } = createSaga< + Nft[] +>(fetchNftImageUris, 'fetchNftImages') + async function fetchNftImageUri(nft: Nft) { logger.debug('Attempting to fetch NFT image uri from:', nft.tokenUri) try { @@ -145,14 +166,8 @@ async function fetchNftImageUri(nft: Nft) { const formattedTokenUri = formatIpfsUrl(tokenUri) if (!formattedTokenUri) return null - const result = await fetchWithTimeout(formattedTokenUri, undefined, 12000) - const json = (await result.json()) as NftMetadata - if (!json?.image) { - logger.debug('No image found for nft', tokenUri) - return null - } - - const imageUri = json.image + const imageUri = await fetchImageFromTokenUri(formattedTokenUri) + if (!imageUri) return null const imageUriExt = getUrlExtensionType(imageUri) if (imageUriExt !== UrlExtensionType.image) { @@ -162,9 +177,28 @@ async function fetchNftImageUri(nft: Nft) { const formattedImageUri = formatIpfsUrl(imageUri) if (!formattedImageUri) return null - else return formattedImageUri + + logger.debug('Found NFT image uri:', formattedImageUri, nft.tokenId) + return formattedImageUri } catch (error) { logger.error('Failed to fetch NFT imageUri for:', nft.tokenId, nft.contract, error) return null } } + +async function fetchImageFromTokenUri(tokenUri: string) { + // IPFS is unreliable so timeouts + errors are frequent + // This still doesn't work very well + const result = await retryAsync(async () => { + const response = await fetchWithTimeout(tokenUri) + if (!response.ok) throw new Error('Response not ok') + const json = (await response.json()) as NftMetadata + if (!json?.image) { + logger.debug('No image found for nft', tokenUri) + return null + } else { + return json.image + } + }) + return result +} diff --git a/src/features/nft/hooks.ts b/src/features/nft/hooks.ts index 4b82d2d9..e3f987ba 100644 --- a/src/features/nft/hooks.ts +++ b/src/features/nft/hooks.ts @@ -38,7 +38,7 @@ export function useSortedOwnedNfts(): Nft[] { } // Resolve chosen contract and nft from inputted values -export function useResolvedNftAndContract(contractAddr: Address, tokenId: string) { +export function useResolvedNftAndContract(contractAddr?: Address, tokenId?: string) { const contracts = useNftContracts() const owned = useAppSelector((state) => state.nft.owned) @@ -56,5 +56,5 @@ export function useResolvedNftAndContract(contractAddr: Address, tokenId: string contract, nft, } - }, [contractAddr, tokenId]) + }, [contractAddr, tokenId, owned, contracts]) } diff --git a/src/utils/retry.ts b/src/utils/retry.ts index 458adcca..6d65ce9b 100644 --- a/src/utils/retry.ts +++ b/src/utils/retry.ts @@ -10,9 +10,9 @@ export async function retryAsync(runner: () => T, attempts = 3, delay = 500) if (result) return result else throw new Error('Empty result') } catch (error) { - await sleep(delay * (i + 1)) - saveError = error logger.error(`retryAsync: Failed to execute function on attempt #${i}:`, error) + saveError = error + await sleep(delay * (i + 1)) } } logger.error(`retryAsync: All attempts failed`) diff --git a/src/utils/saga.ts b/src/utils/saga.ts index 284fd603..9a090c1f 100644 --- a/src/utils/saga.ts +++ b/src/utils/saga.ts @@ -25,9 +25,7 @@ export function createSaga(saga: (...args: any[]) => any, nam return { wrappedSaga, - actions: { - trigger: triggerAction, - }, + trigger: triggerAction, } }