diff --git a/apps/mobile-wallet/src/contexts/walletConnect/WalletConnectContext.tsx b/apps/mobile-wallet/src/contexts/walletConnect/WalletConnectContext.tsx index cd5f93a5f4..0d6a738cf5 100644 --- a/apps/mobile-wallet/src/contexts/walletConnect/WalletConnectContext.tsx +++ b/apps/mobile-wallet/src/contexts/walletConnect/WalletConnectContext.tsx @@ -79,7 +79,6 @@ import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, u import { useTranslation } from 'react-i18next' import { AppState, AppStateStatus } from 'react-native' import BackgroundService from 'react-native-background-actions' -import { Portal } from 'react-native-portalize' import { sendAnalytics } from '~/analytics' import { @@ -88,9 +87,7 @@ import { buildTransferTransaction } from '~/api/transactions' import SpinnerModal from '~/components/SpinnerModal' -import WalletConnectSessionRequestModal from '~/contexts/walletConnect/WalletConnectSessionRequestModal' import useFundPasswordGuard from '~/features/fund-password/useFundPasswordGuard' -import BottomModal from '~/features/modals/DeprecatedBottomModal' import { openModal } from '~/features/modals/modalActions' import { useAppDispatch, useAppSelector } from '~/hooks/redux' import { useBiometricsAuthGuard } from '~/hooks/useBiometrics' @@ -153,7 +150,6 @@ export const WalletConnectContextProvider = ({ children }: { children: ReactNode const [sessionProposalEvent, setSessionProposalEvent] = useState() const [sessionRequestEvent, setSessionRequestEvent] = useState() const [sessionRequestData, setSessionRequestData] = useState() - const [isSessionRequestModalOpen, setIsSessionRequestModalOpen] = useState(false) const [loading, setLoading] = useState('') const [walletConnectClientInitializationAttempts, setWalletConnectClientInitializationAttempts] = useState(0) @@ -340,7 +336,7 @@ export const WalletConnectContextProvider = ({ children }: { children: ReactNode setSessionRequestEvent(requestEvent) console.log('⏳ OPENING MODAL TO APPROVE TX...') - setIsSessionRequestModalOpen(true) + openWalletConnectSessionRequestModal() break } @@ -383,7 +379,7 @@ export const WalletConnectContextProvider = ({ children }: { children: ReactNode setSessionRequestEvent(requestEvent) console.log('⏳ OPENING MODAL TO APPROVE TX...') - setIsSessionRequestModalOpen(true) + openWalletConnectSessionRequestModal() break } @@ -438,7 +434,7 @@ export const WalletConnectContextProvider = ({ children }: { children: ReactNode setSessionRequestEvent(requestEvent) console.log('⏳ OPENING MODAL TO APPROVE TX...') - setIsSessionRequestModalOpen(true) + openWalletConnectSessionRequestModal() break } @@ -467,7 +463,7 @@ export const WalletConnectContextProvider = ({ children }: { children: ReactNode setSessionRequestEvent(requestEvent) console.log('⏳ OPENING MODAL TO SIGN MESSAGE...') - setIsSessionRequestModalOpen(true) + openWalletConnectSessionRequestModal() break } @@ -504,7 +500,7 @@ export const WalletConnectContextProvider = ({ children }: { children: ReactNode setSessionRequestEvent(requestEvent) console.log('⏳ OPENING MODAL TO SIGN UNSIGNED TX...') - setIsSessionRequestModalOpen(true) + openWalletConnectSessionRequestModal() break } @@ -560,7 +556,14 @@ export const WalletConnectContextProvider = ({ children }: { children: ReactNode // The `addresses` dependency causes re-rendering when any property of an Address changes, even though we only need // the `hash` and the `publicKey`. Creating a selector that extracts those 3 doesn't help. // Using addressIds fixes the problem, but now the api/transactions.ts file becomes dependant on the store file. - [walletConnectClient, respondToWalletConnectWithError, addressIds, handleApiResponse, t] + [ + walletConnectClient, + respondToWalletConnectWithError, + addressIds, + openWalletConnectSessionRequestModal, + handleApiResponse, + t + ] ) const onSessionDelete = useCallback( @@ -1031,23 +1034,37 @@ export const WalletConnectContextProvider = ({ children }: { children: ReactNode } } - const handleSessionRequestModalClose = async () => { - setIsSessionRequestModalOpen(false) - onSessionRequestModalClose() - } - - const onSessionRequestModalClose = async () => { - console.log('👉 CLOSING MODAL.') - - if (sessionRequestEvent && walletConnectClient && walletConnectClient?.getPendingSessionRequests().length > 0) { - console.log('👉 USER CLOSED THE MODAL WITHOUT REJECTING/APPROVING SO WE NEED TO REJECT.') - handleRejectPress() - } - } - - useEffect(() => { - if (sessionRequestEvent === undefined && isSessionRequestModalOpen) setIsSessionRequestModalOpen(false) - }, [isSessionRequestModalOpen, sessionRequestEvent]) + const openWalletConnectSessionRequestModal = useCallback( + () => + sessionRequestData && + walletConnectClient && + dispatch( + openModal({ + name: 'WalletConnectSessionRequestModal', + props: { + walletConnectClient, + requestData: sessionRequestData, + onApprove: handleApprovePress, + onReject: handleRejectPress, + onSendTxOrSignFail: handleSendTxOrSignFail, + onSignSuccess: handleSignSuccess, + metadata: activeSessionMetadata, + sessionRequestEvent + } + }) + ), + [ + activeSessionMetadata, + dispatch, + handleApprovePress, + handleRejectPress, + handleSendTxOrSignFail, + handleSignSuccess, + sessionRequestData, + sessionRequestEvent, + walletConnectClient + ] + ) useEffect(() => { if (!isWalletUnlocked || !url || !url.startsWith('wc:') || wcDeepLink.current === url) return @@ -1121,25 +1138,7 @@ export const WalletConnectContextProvider = ({ children }: { children: ReactNode }} > {children} - - {sessionRequestData && ( - ( - - )} - /> - )} - + ) diff --git a/apps/mobile-wallet/src/contexts/walletConnect/WalletConnectSessionRequestModal.tsx b/apps/mobile-wallet/src/contexts/walletConnect/WalletConnectSessionRequestModal.tsx index 0df2bd0aac..acb29e9e45 100644 --- a/apps/mobile-wallet/src/contexts/walletConnect/WalletConnectSessionRequestModal.tsx +++ b/apps/mobile-wallet/src/contexts/walletConnect/WalletConnectSessionRequestModal.tsx @@ -16,7 +16,7 @@ You should have received a copy of the GNU Lesser General Public License along with the library. If not, see . */ -import { getHumanReadableError, WALLETCONNECT_ERRORS, WalletConnectError } from '@alephium/shared' +import { getHumanReadableError, SessionRequestEvent, WALLETCONNECT_ERRORS, WalletConnectError } from '@alephium/shared' import { ALPH } from '@alephium/token-list' import { binToHex, @@ -30,7 +30,9 @@ import { SignUnsignedTxResult, transactionSign } from '@alephium/web3' +import SignClient from '@walletconnect/sign-client' import { SessionTypes } from '@walletconnect/types' +import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Image } from 'react-native' import styled from 'styled-components/native' @@ -46,7 +48,11 @@ import ButtonsRow from '~/components/buttons/ButtonsRow' import BoxSurface from '~/components/layout/BoxSurface' import { ModalScreenTitle, ScreenSection } from '~/components/layout/Screen' import Row from '~/components/Row' -import { ModalContent, ModalContentProps } from '~/features/modals/ModalContent' +import BottomModal from '~/features/modals/BottomModal' +import { closeModal } from '~/features/modals/modalActions' +import { ModalContent } from '~/features/modals/ModalContent' +import { ModalBaseProp } from '~/features/modals/modalTypes' +import withModal from '~/features/modals/withModal' import { useAppDispatch, useAppSelector } from '~/hooks/redux' import { getAddressAsymetricKey } from '~/persistent-storage/wallet' import { selectAddressByHash } from '~/store/addressesSlice' @@ -55,7 +61,8 @@ import { SessionRequestData } from '~/types/walletConnect' import { showExceptionToast } from '~/utils/layout' import { getTransactionAssetAmounts } from '~/utils/transactions' -interface WalletConnectSessionRequestModalProps extends ModalContentProps { +interface WalletConnectSessionRequestModalProps { + walletConnectClient: SignClient requestData: T onApprove: ( sendTransaction: () => Promise< @@ -66,305 +73,330 @@ interface WalletConnectSessionRequestModalProps ex onSendTxOrSignFail: (error: WalletConnectError) => Promise onSignSuccess: (result: SignMessageResult) => Promise metadata?: SessionTypes.Struct['peer']['metadata'] + onClose?: () => void + sessionRequestEvent?: SessionRequestEvent } -const WalletConnectSessionRequestModal = ({ - requestData, - onApprove, - onReject, - onSendTxOrSignFail, - onSignSuccess, - metadata, - ...props -}: WalletConnectSessionRequestModalProps) => { - const dispatch = useAppDispatch() - const signAddress = useAppSelector((s) => selectAddressByHash(s, requestData.wcData.fromAddress)) - const { t } = useTranslation() - - const isSignRequest = requestData.type === 'sign-message' || requestData.type === 'sign-unsigned-tx' - const fees = !isSignRequest - ? BigInt(requestData.unsignedTxData.gasAmount) * BigInt(requestData.unsignedTxData.gasPrice) - : undefined - - const handleApprovePress = () => onApprove(sendTransaction) - - const sendTransaction = async () => { - if (isSignRequest) return - - try { - const data = await signAndSendTransaction( - requestData.wcData.fromAddress, - requestData.unsignedTxData.txId, - requestData.unsignedTxData.unsignedTx - ) - - switch (requestData.type) { - case 'transfer': { - const { attoAlphAmount, tokens } = getTransactionAssetAmounts(requestData.wcData.assetAmounts) - - dispatch( - transactionSent({ - hash: data.txId, - fromAddress: requestData.wcData.fromAddress, - toAddress: requestData.wcData.toAddress, - amount: attoAlphAmount, - tokens, - timestamp: new Date().getTime(), - status: 'pending', - type: 'transfer' - }) - ) - - sendAnalytics({ event: 'WC: Approved transfer' }) - - return { - fromGroup: requestData.unsignedTxData.fromGroup, - toGroup: requestData.unsignedTxData.toGroup, - unsignedTx: requestData.unsignedTxData.unsignedTx, - txId: requestData.unsignedTxData.txId, - signature: data.signature, - gasAmount: requestData.unsignedTxData.gasAmount, - gasPrice: BigInt(requestData.unsignedTxData.gasPrice) - } as SignTransferTxResult +const WalletConnectSessionRequestModal = withModal( + ({ + id, + requestData, + onApprove, + onReject, + onSendTxOrSignFail, + onSignSuccess, + metadata, + onClose, + sessionRequestEvent + }: WalletConnectSessionRequestModalProps & ModalBaseProp) => { + const dispatch = useAppDispatch() + const signAddress = useAppSelector((s) => selectAddressByHash(s, requestData.wcData.fromAddress)) + const { t } = useTranslation() + + const isSignRequest = requestData.type === 'sign-message' || requestData.type === 'sign-unsigned-tx' + const fees = !isSignRequest + ? BigInt(requestData.unsignedTxData.gasAmount) * BigInt(requestData.unsignedTxData.gasPrice) + : undefined + + const handleClose = () => { + console.log('👉 CLOSING MODAL.') + + if (sessionRequestEvent && walletConnectClient && walletConnectClient?.getPendingSessionRequests().length > 0) { + console.log('👉 USER CLOSED THE MODAL WITHOUT REJECTING/APPROVING SO WE NEED TO REJECT.') + onReject() } - case 'call-contract': { - const { attoAlphAmount, tokens } = requestData.wcData.assetAmounts - ? getTransactionAssetAmounts(requestData.wcData.assetAmounts) - : { attoAlphAmount: undefined, tokens: undefined } - - dispatch( - transactionSent({ - hash: data.txId, - fromAddress: requestData.wcData.fromAddress, - amount: attoAlphAmount, - tokens, - timestamp: new Date().getTime(), - status: 'pending', - type: 'call-contract' - }) - ) + } + } - sendAnalytics({ event: 'WC: Approved contract call' }) + // Close modal if the session request event becomes undefined + useEffect(() => { + if (sessionRequestEvent === undefined) dispatch(closeModal({ id })) + }, [dispatch, id, sessionRequestEvent]) - return { - groupIndex: requestData.unsignedTxData.fromGroup, - unsignedTx: requestData.unsignedTxData.unsignedTx, - txId: requestData.unsignedTxData.txId, - signature: data.signature, - gasAmount: requestData.unsignedTxData.gasAmount, - gasPrice: BigInt(requestData.unsignedTxData.gasPrice) - } as SignExecuteScriptTxResult - } - case 'deploy-contract': { - dispatch( - transactionSent({ - hash: data.txId, - fromAddress: requestData.wcData.fromAddress, - timestamp: new Date().getTime(), - status: 'pending', - type: 'deploy-contract' - }) - ) + const handleApprovePress = () => onApprove(sendTransaction) + + const sendTransaction = async () => { + if (isSignRequest) return + + try { + const data = await signAndSendTransaction( + requestData.wcData.fromAddress, + requestData.unsignedTxData.txId, + requestData.unsignedTxData.unsignedTx + ) + + switch (requestData.type) { + case 'transfer': { + const { attoAlphAmount, tokens } = getTransactionAssetAmounts(requestData.wcData.assetAmounts) + + dispatch( + transactionSent({ + hash: data.txId, + fromAddress: requestData.wcData.fromAddress, + toAddress: requestData.wcData.toAddress, + amount: attoAlphAmount, + tokens, + timestamp: new Date().getTime(), + status: 'pending', + type: 'transfer' + }) + ) - sendAnalytics({ event: 'WC: Approved contract deployment' }) - - return { - groupIndex: requestData.unsignedTxData.fromGroup, - unsignedTx: requestData.unsignedTxData.unsignedTx, - txId: requestData.unsignedTxData.txId, - signature: data.signature, - contractAddress: requestData.unsignedTxData.contractAddress, - contractId: binToHex(contractIdFromAddress(requestData.unsignedTxData.contractAddress)), - gasAmount: requestData.unsignedTxData.gasAmount, - gasPrice: BigInt(requestData.unsignedTxData.gasPrice) - } as SignDeployContractTxResult + sendAnalytics({ event: 'WC: Approved transfer' }) + + return { + fromGroup: requestData.unsignedTxData.fromGroup, + toGroup: requestData.unsignedTxData.toGroup, + unsignedTx: requestData.unsignedTxData.unsignedTx, + txId: requestData.unsignedTxData.txId, + signature: data.signature, + gasAmount: requestData.unsignedTxData.gasAmount, + gasPrice: BigInt(requestData.unsignedTxData.gasPrice) + } as SignTransferTxResult + } + case 'call-contract': { + const { attoAlphAmount, tokens } = requestData.wcData.assetAmounts + ? getTransactionAssetAmounts(requestData.wcData.assetAmounts) + : { attoAlphAmount: undefined, tokens: undefined } + + dispatch( + transactionSent({ + hash: data.txId, + fromAddress: requestData.wcData.fromAddress, + amount: attoAlphAmount, + tokens, + timestamp: new Date().getTime(), + status: 'pending', + type: 'call-contract' + }) + ) + + sendAnalytics({ event: 'WC: Approved contract call' }) + + return { + groupIndex: requestData.unsignedTxData.fromGroup, + unsignedTx: requestData.unsignedTxData.unsignedTx, + txId: requestData.unsignedTxData.txId, + signature: data.signature, + gasAmount: requestData.unsignedTxData.gasAmount, + gasPrice: BigInt(requestData.unsignedTxData.gasPrice) + } as SignExecuteScriptTxResult + } + case 'deploy-contract': { + dispatch( + transactionSent({ + hash: data.txId, + fromAddress: requestData.wcData.fromAddress, + timestamp: new Date().getTime(), + status: 'pending', + type: 'deploy-contract' + }) + ) + + sendAnalytics({ event: 'WC: Approved contract deployment' }) + + return { + groupIndex: requestData.unsignedTxData.fromGroup, + unsignedTx: requestData.unsignedTxData.unsignedTx, + txId: requestData.unsignedTxData.txId, + signature: data.signature, + contractAddress: requestData.unsignedTxData.contractAddress, + contractId: binToHex(contractIdFromAddress(requestData.unsignedTxData.contractAddress)), + gasAmount: requestData.unsignedTxData.gasAmount, + gasPrice: BigInt(requestData.unsignedTxData.gasPrice) + } as SignDeployContractTxResult + } } + } catch (error) { + const message = 'Could not send transaction' + const translatedMessage = t(message) + + showExceptionToast(error, translatedMessage) + sendAnalytics({ type: 'error', message }) + onSendTxOrSignFail({ + message: getHumanReadableError(error, translatedMessage), + code: WALLETCONNECT_ERRORS.TRANSACTION_SEND_FAILED + }) } - } catch (error) { - const message = 'Could not send transaction' - const translatedMessage = t(message) - - showExceptionToast(error, translatedMessage) - sendAnalytics({ type: 'error', message }) - onSendTxOrSignFail({ - message: getHumanReadableError(error, translatedMessage), - code: WALLETCONNECT_ERRORS.TRANSACTION_SEND_FAILED - }) } - } - const handleSignPress = async () => { - if (!isSignRequest) return + const handleSignPress = async () => { + if (!isSignRequest) return - if (!signAddress) { - onSendTxOrSignFail({ - message: "Signer address doesn't exist", - code: WALLETCONNECT_ERRORS.SIGNER_ADDRESS_DOESNT_EXIST - }) - return - } + if (!signAddress) { + onSendTxOrSignFail({ + message: "Signer address doesn't exist", + code: WALLETCONNECT_ERRORS.SIGNER_ADDRESS_DOESNT_EXIST + }) + return + } - let signResult: SignUnsignedTxResult | SignMessageResult + let signResult: SignUnsignedTxResult | SignMessageResult - try { - if (requestData.type === 'sign-message') { - const messageHash = hashMessage(requestData.wcData.message, requestData.wcData.messageHasher) - signResult = { signature: sign(messageHash, await getAddressAsymetricKey(signAddress.hash, 'private')) } - } else { - const signature = transactionSign( - requestData.unsignedTxData.unsignedTx.txId, - await getAddressAsymetricKey(signAddress.hash, 'private') - ) + try { + if (requestData.type === 'sign-message') { + const messageHash = hashMessage(requestData.wcData.message, requestData.wcData.messageHasher) + signResult = { signature: sign(messageHash, await getAddressAsymetricKey(signAddress.hash, 'private')) } + } else { + const signature = transactionSign( + requestData.unsignedTxData.unsignedTx.txId, + await getAddressAsymetricKey(signAddress.hash, 'private') + ) - signResult = { - ...requestData.unsignedTxData, - signature, - txId: requestData.unsignedTxData.unsignedTx.txId, - gasAmount: requestData.unsignedTxData.unsignedTx.gasAmount, - gasPrice: BigInt(requestData.unsignedTxData.unsignedTx.gasPrice), - unsignedTx: requestData.wcData.unsignedTx + signResult = { + ...requestData.unsignedTxData, + signature, + txId: requestData.unsignedTxData.unsignedTx.txId, + gasAmount: requestData.unsignedTxData.unsignedTx.gasAmount, + gasPrice: BigInt(requestData.unsignedTxData.unsignedTx.gasPrice), + unsignedTx: requestData.wcData.unsignedTx + } } - } - await onSignSuccess(signResult) - } catch (error) { - const message = - requestData.type === 'sign-message' ? 'Could not sign message' : 'Could not sign unsigned transaction' - const translatedMessage = t(message) - - showExceptionToast(error, translatedMessage) - sendAnalytics({ type: 'error', message }) - onSendTxOrSignFail({ - message: getHumanReadableError(error, translatedMessage), - code: - requestData.type === 'sign-message' - ? WALLETCONNECT_ERRORS.MESSAGE_SIGN_FAILED - : WALLETCONNECT_ERRORS.TRANSACTION_SIGN_FAILED - }) - } finally { - props.onClose && props.onClose() + await onSignSuccess(signResult) + } catch (error) { + const message = + requestData.type === 'sign-message' ? 'Could not sign message' : 'Could not sign unsigned transaction' + const translatedMessage = t(message) + + showExceptionToast(error, translatedMessage) + sendAnalytics({ type: 'error', message }) + onSendTxOrSignFail({ + message: getHumanReadableError(error, translatedMessage), + code: + requestData.type === 'sign-message' + ? WALLETCONNECT_ERRORS.MESSAGE_SIGN_FAILED + : WALLETCONNECT_ERRORS.TRANSACTION_SIGN_FAILED + }) + } finally { + dispatch(closeModal({ id })) + } } - } - return ( - - {metadata && ( - - {metadata.icons && metadata.icons.length > 0 && metadata.icons[0] && ( - - )} - - { - { - transfer: t('Transfer request'), - 'call-contract': t('Smart contract request'), - 'deploy-contract': t('Smart contract request'), - 'sign-message': t('Sign message'), - 'sign-unsigned-tx': t('Sign unsigned transaction') - }[requestData.type] - } - - {metadata.url && ( - - {t('from {{ url }}', { url: metadata.url })} - + return ( + + + {metadata && ( + + {metadata.icons && metadata.icons.length > 0 && metadata.icons[0] && ( + + )} + + { + { + transfer: t('Transfer request'), + 'call-contract': t('Smart contract request'), + 'deploy-contract': t('Smart contract request'), + 'sign-message': t('Sign message'), + 'sign-unsigned-tx': t('Sign unsigned transaction') + }[requestData.type] + } + + {metadata.url && ( + + {t('from {{ url }}', { url: metadata.url })} + + )} + )} - - )} - - - {(requestData.type === 'transfer' || requestData.type === 'call-contract') && - requestData.wcData.assetAmounts && - requestData.wcData.assetAmounts.length > 0 && ( - - - {requestData.wcData.assetAmounts.map(({ id, amount }) => - amount ? : null - )} - - - )} - - - - - {requestData.type === 'deploy-contract' || requestData.type === 'call-contract' ? ( - metadata?.url && ( - - {metadata.url} + + + {(requestData.type === 'transfer' || requestData.type === 'call-contract') && + requestData.wcData.assetAmounts && + requestData.wcData.assetAmounts.length > 0 && ( + + + {requestData.wcData.assetAmounts.map(({ id, amount }) => + amount ? ( + + ) : null + )} + + + )} + + - ) - ) : requestData.type === 'transfer' ? ( - - - - ) : null} - - {requestData.type === 'deploy-contract' && ( - <> - {!!requestData.wcData.initialAlphAmount?.amount && ( - - + + {requestData.type === 'deploy-contract' || requestData.type === 'call-contract' ? ( + metadata?.url && ( + + {metadata.url} + + ) + ) : requestData.type === 'transfer' ? ( + + + ) : null} + + {requestData.type === 'deploy-contract' && ( + <> + {!!requestData.wcData.initialAlphAmount?.amount && ( + + + + )} + {requestData.wcData.issueTokenAmount && ( + + {requestData.wcData.issueTokenAmount} + + )} + )} - {requestData.wcData.issueTokenAmount && ( - - {requestData.wcData.issueTokenAmount} + + {(requestData.type === 'deploy-contract' || requestData.type === 'call-contract') && ( + + {requestData.wcData.bytecode} )} - - )} - - {(requestData.type === 'deploy-contract' || requestData.type === 'call-contract') && ( - - {requestData.wcData.bytecode} - - )} - {requestData.type === 'sign-unsigned-tx' && ( - <> - - {requestData.unsignedTxData.unsignedTx.txId} - - - {requestData.wcData.unsignedTx} - - - )} - {requestData.type === 'sign-message' && ( - - {requestData.wcData.message} - - )} - - - {fees !== undefined && ( - - - - {t('Estimated fees')} - - - - - )} - - -