Merge branch 'development' of github.com:gnosis/safe-react into fix/desktop-CI

This commit is contained in:
Mati Dastugue 2020-08-06 18:05:48 -03:00
commit 44782ee7e2
26 changed files with 1264 additions and 378 deletions

View File

@ -85,6 +85,10 @@ const isSafeMethod = (methodId: string): boolean => {
} }
export const decodeMethods = (data: string): DataDecoded | null => { export const decodeMethods = (data: string): DataDecoded | null => {
if(!data.length) {
return null
}
const [methodId, params] = [data.slice(0, 10), data.slice(10)] const [methodId, params] = [data.slice(0, 10), data.slice(10)]
if (isSafeMethod(methodId)) { if (isSafeMethod(methodId)) {

View File

@ -3,7 +3,7 @@ import createDecorator from 'final-form-calculate'
import React from 'react' import React from 'react'
import { useField, useFormState } from 'react-final-form' import { useField, useFormState } from 'react-final-form'
import { SafeApp } from 'src/routes/safe/components/Apps/types' import { SafeApp } from 'src/routes/safe/components/Apps/types.d'
import { getAppInfoFromUrl, getIpfsLinkFromEns, uniqueApp } from 'src/routes/safe/components/Apps/utils' import { getAppInfoFromUrl, getIpfsLinkFromEns, uniqueApp } from 'src/routes/safe/components/Apps/utils'
import { composeValidators, required } from 'src/components/forms/validator' import { composeValidators, required } from 'src/components/forms/validator'
import Field from 'src/components/forms/Field' import Field from 'src/components/forms/Field'

View File

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { useFormState } from 'react-final-form' import { useFormState } from 'react-final-form'
import { SafeApp } from 'src/routes/safe/components/Apps/types' import { SafeApp } from 'src/routes/safe/components/Apps/types.d'
import { isAppManifestValid } from 'src/routes/safe/components/Apps/utils' import { isAppManifestValid } from 'src/routes/safe/components/Apps/utils'
interface SubmitButtonStatusProps { interface SubmitButtonStatusProps {

View File

@ -6,7 +6,7 @@ import AppAgreement from './AppAgreement'
import AppUrl, { AppInfoUpdater, appUrlResolver } from './AppUrl' import AppUrl, { AppInfoUpdater, appUrlResolver } from './AppUrl'
import SubmitButtonStatus from './SubmitButtonStatus' import SubmitButtonStatus from './SubmitButtonStatus'
import { SafeApp } from 'src/routes/safe/components/Apps/types' import { SafeApp } from 'src/routes/safe/components/Apps/types.d'
import GnoForm from 'src/components/forms/GnoForm' import GnoForm from 'src/components/forms/GnoForm'
import Img from 'src/components/layout/Img' import Img from 'src/components/layout/Img'
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg' import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'

View File

@ -0,0 +1,34 @@
import React from 'react'
import { FixedDialog, Text } from '@gnosis.pm/safe-react-components'
interface OwnProps {
onCancel: () => void
onConfirm: () => void
}
const LegalDisclaimer = ({ onCancel, onConfirm }: OwnProps): JSX.Element => (
<FixedDialog
body={
<>
<Text size="md">
You are now accessing third-party apps, which we do not own, control, maintain or audit. We are not liable for
any loss you may suffer in connection with interacting with the apps, which is at your own risk. You must read
our Terms, which contain more detailed provisions binding on you relating to the apps.
</Text>
<br />
<Text size="md">
I have read and understood the{' '}
<a href="https://gnosis-safe.io/terms" rel="noopener noreferrer" target="_blank">
Terms
</a>{' '}
and this Disclaimer, and agree to be bound by them.
</Text>
</>
}
onCancel={onCancel}
onConfirm={onConfirm}
title="Disclaimer"
/>
)
export default LegalDisclaimer

View File

@ -2,8 +2,8 @@ import { ButtonLink, ManageListModal } from '@gnosis.pm/safe-react-components'
import React, { useState } from 'react' import React, { useState } from 'react'
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg' import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
import AddAppForm from './AddAppForm' import AddAppForm from '../AddAppForm'
import { SafeApp } from './types' import { SafeApp } from '../types'
const FORM_ID = 'add-apps-form' const FORM_ID = 'add-apps-form'

View File

@ -75,7 +75,7 @@ const confirmTransactions = (
ethBalance: string, ethBalance: string,
nameApp: string, nameApp: string,
iconApp: string, iconApp: string,
txs: Array<SafeAppTx>, txs: SafeAppTx[],
openModal: (modalInfo: GenericModalProps) => void, openModal: (modalInfo: GenericModalProps) => void,
closeModal: () => void, closeModal: () => void,
onConfirm: () => void, onConfirm: () => void,

View File

@ -0,0 +1,107 @@
import { useState, useEffect, useCallback } from 'react'
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
import { getAppInfoFromUrl, staticAppsList } from '../utils'
import { SafeApp, StoredSafeApp } from '../types'
const APPS_STORAGE_KEY = 'APPS_STORAGE_KEY'
type onAppToggleHandler = (appId: string, enabled: boolean) => Promise<void>
type onAppAddedHandler = (app: SafeApp) => void
type UseAppListReturnType = {
appList: SafeApp[]
loadingAppList: boolean
onAppToggle: onAppToggleHandler
onAppAdded: onAppAddedHandler
}
const useAppList = (): UseAppListReturnType => {
const [appList, setAppList] = useState<SafeApp[]>([])
const [loadingAppList, setLoadingAppList] = useState<boolean>(true)
// Load apps list
useEffect(() => {
const loadApps = async () => {
// recover apps from storage:
// * third-party apps added by the user
// * disabled status for both static and third-party apps
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || []
const list = [...persistedAppList]
staticAppsList.forEach((staticApp) => {
if (!list.some((persistedApp) => persistedApp.url === staticApp.url)) {
list.push(staticApp)
}
})
const apps = []
// using the appURL to recover app info
for (let index = 0; index < list.length; index++) {
try {
const currentApp = list[index]
const appInfo: any = await getAppInfoFromUrl(currentApp.url)
if (appInfo.error) {
throw Error(`There was a problem trying to load app ${currentApp.url}`)
}
appInfo.disabled = currentApp.disabled === undefined ? false : currentApp.disabled
apps.push(appInfo)
} catch (error) {
console.error(error)
}
}
setAppList(apps)
setLoadingAppList(false)
}
loadApps()
}, [])
const onAppToggle: onAppToggleHandler = useCallback(
async (appId, enabled) => {
// update in-memory list
const appListCopy = [...appList]
const app = appListCopy.find((a) => a.id === appId)
if (!app) {
return
}
app.disabled = !enabled
setAppList(appListCopy)
// update storage list
const listToPersist: StoredSafeApp[] = appListCopy.map(({ url, disabled }) => ({ url, disabled }))
saveToStorage(APPS_STORAGE_KEY, listToPersist)
},
[appList],
)
const onAppAdded: onAppAddedHandler = useCallback(
(app) => {
const newAppList = [
{ url: app.url, disabled: false },
...appList.map((a) => ({
url: a.url,
disabled: a.disabled,
})),
]
saveToStorage(APPS_STORAGE_KEY, newAppList)
setAppList([...appList, { ...app, disabled: false }])
},
[appList],
)
return {
appList,
loadingAppList,
onAppToggle,
onAppAdded,
}
}
export { useAppList }

View File

@ -0,0 +1,52 @@
import { useEffect } from 'react'
const useIframeMessageHandler = (): void => {
useEffect(() => {
// const handleIframeMessage = (data) => {
// if (!data || !data.messageId) {
// console.error('ThirdPartyApp: A message was received without message id.')
// return
// }
// switch (data.messageId) {
// case operations.SEND_TRANSACTIONS: {
// const onConfirm = async () => {
// closeModal()
// await sendTransactions(dispatch, safeAddress, data.data, enqueueSnackbar, closeSnackbar, selectedApp.id)
// }
// confirmTransactions(
// safeAddress,
// safeName,
// ethBalance,
// selectedApp.name,
// selectedApp.iconUrl,
// data.data,
// openModal,
// closeModal,
// onConfirm,
// )
// break
// }
// default: {
// console.error(`ThirdPartyApp: A message was received with an unknown message id ${data.messageId}.`)
// break
// }
// }
// }
// const onIframeMessage = async ({ data, origin }) => {
// if (origin === window.origin) {
// return
// }
// if (!selectedApp.url.includes(origin)) {
// console.error(`ThirdPartyApp: A message was received from an unknown origin ${origin}`)
// return
// }
// handleIframeMessage(data)
// }
// window.addEventListener('message', onIframeMessage)
// return () => {
// window.removeEventListener('message', onIframeMessage)
// }
}, [])
}
export { useIframeMessageHandler }

View File

@ -0,0 +1,29 @@
import { useState, useEffect, useCallback } from 'react'
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
const APPS_LEGAL_CONSENT_RECEIVED = 'APPS_LEGAL_CONSENT_RECEIVED'
const useLegalConsent = (): { consentReceived: boolean; onConsentReceipt: () => void } => {
const [consentReceived, setConsentReceived] = useState<boolean>(false)
useEffect(() => {
const checkLegalDisclaimer = async () => {
const storedConsentReceived = await loadFromStorage(APPS_LEGAL_CONSENT_RECEIVED)
if (storedConsentReceived) {
setConsentReceived(true)
}
}
checkLegalDisclaimer()
}, [])
const onConsentReceipt = useCallback((): void => {
setConsentReceived(true)
saveToStorage(APPS_LEGAL_CONSENT_RECEIVED, true)
}, [])
return { consentReceived, onConsentReceipt }
}
export { useLegalConsent }

View File

@ -1,14 +1,16 @@
import { Card, FixedDialog, FixedIcon, IconText, Loader, Menu, Text, Title } from '@gnosis.pm/safe-react-components' import { Card, FixedIcon, IconText, Loader, Menu, Title } from '@gnosis.pm/safe-react-components'
import { withSnackbar } from 'notistack' import { withSnackbar } from 'notistack'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState, useMemo } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { useHistory } from 'react-router-dom' import { useHistory } from 'react-router-dom'
import styled from 'styled-components' import styled from 'styled-components'
import ManageApps from './ManageApps' import ManageApps from './components/ManageApps'
import confirmTransactions from './confirmTransactions' import confirmTransactions from './confirmTransactions'
import sendTransactions from './sendTransactions' import sendTransactions from './sendTransactions'
import { getAppInfoFromUrl, staticAppsList } from './utils' import LegalDisclaimer from './components/LegalDisclaimer'
import { useLegalConsent } from './hooks/useLegalConsent'
import { useAppList } from './hooks/useAppList'
import LCL from 'src/components/ListContentLayout' import LCL from 'src/components/ListContentLayout'
import { networkSelector } from 'src/logic/wallets/store/selectors' import { networkSelector } from 'src/logic/wallets/store/selectors'
@ -19,12 +21,7 @@ import {
safeNameSelector, safeNameSelector,
safeParamAddressFromStateSelector, safeParamAddressFromStateSelector,
} from 'src/routes/safe/store/selectors' } from 'src/routes/safe/store/selectors'
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
import { isSameHref } from 'src/utils/url' import { isSameHref } from 'src/utils/url'
import { SafeApp, StoredSafeApp } from './types'
const APPS_STORAGE_KEY = 'APPS_STORAGE_KEY'
const APPS_LEGAL_DISCLAIMER_STORAGE_KEY = 'APPS_LEGAL_DISCLAIMER_STORAGE_KEY'
const StyledIframe = styled.iframe` const StyledIframe = styled.iframe`
padding: 15px; padding: 15px;
@ -63,11 +60,10 @@ const operations = {
} }
function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) { function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
const [appList, setAppList] = useState<Array<SafeApp>>([]) const { appList, loadingAppList, onAppToggle, onAppAdded } = useAppList()
const [legalDisclaimerAccepted, setLegalDisclaimerAccepted] = useState(false)
const [selectedApp, setSelectedApp] = useState<string>() const [appIsLoading, setAppIsLoading] = useState<boolean>(true)
const [loading, setLoading] = useState(true) const [selectedAppId, setSelectedAppId] = useState<string>()
const [appIsLoading, setAppIsLoading] = useState(true)
const [iframeEl, setIframeEl] = useState<HTMLIFrameElement | null>(null) const [iframeEl, setIframeEl] = useState<HTMLIFrameElement | null>(null)
const history = useHistory() const history = useHistory()
@ -77,8 +73,36 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
const network = useSelector(networkSelector) const network = useSelector(networkSelector)
const ethBalance = useSelector(safeEthBalanceSelector) const ethBalance = useSelector(safeEthBalanceSelector)
const dispatch = useDispatch() const dispatch = useDispatch()
const { consentReceived, onConsentReceipt } = useLegalConsent()
const getSelectedApp = useCallback(() => appList.find((e) => e.id === selectedApp), [appList, selectedApp]) const selectedApp = useMemo(() => appList.find((app) => app.id === selectedAppId), [appList, selectedAppId])
const onSelectApp = useCallback(
(appId) => {
if (selectedAppId === appId) {
return
}
setAppIsLoading(true)
setSelectedAppId(appId)
},
[selectedAppId],
)
useEffect(() => {
const selectFirstEnabledApp = () => {
const firstEnabledApp = appList.find((a) => !a.disabled)
if (firstEnabledApp) {
setSelectedAppId(firstEnabledApp.id)
}
}
const initialSelect = appList.length && !selectedAppId
const currentAppWasDisabled = selectedApp?.disabled
if (initialSelect || currentAppWasDisabled) {
selectFirstEnabledApp()
}
}, [appList, selectedApp, selectedAppId])
const iframeRef = useCallback((node) => { const iframeRef = useCallback((node) => {
if (node !== null) { if (node !== null) {
@ -86,58 +110,15 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
} }
}, []) }, [])
const onSelectApp = useCallback(
(appId) => {
const selectedApp = getSelectedApp()
if (selectedApp && selectedApp.id === appId) {
return
}
setAppIsLoading(true)
setSelectedApp(appId)
},
[getSelectedApp],
)
const redirectToBalance = () => history.push(`${SAFELIST_ADDRESS}/${safeAddress}/balances`) const redirectToBalance = () => history.push(`${SAFELIST_ADDRESS}/${safeAddress}/balances`)
const onAcceptLegalDisclaimer = () => {
setLegalDisclaimerAccepted(true)
saveToStorage(APPS_LEGAL_DISCLAIMER_STORAGE_KEY, true)
}
const getContent = () => { const getContent = () => {
if (!selectedApp) { if (!selectedApp) {
return null return null
} }
if (!legalDisclaimerAccepted) { if (!consentReceived) {
return ( return <LegalDisclaimer onCancel={redirectToBalance} onConfirm={onConsentReceipt} />
<FixedDialog
body={
<>
<Text size="md">
You are now accessing third-party apps, which we do not own, control, maintain or audit. We are not
liable for any loss you may suffer in connection with interacting with the apps, which is at your own
risk. You must read our Terms, which contain more detailed provisions binding on you relating to the
apps.
</Text>
<br />
<Text size="md">
I have read and understood the{' '}
<a href="https://gnosis-safe.io/terms" rel="noopener noreferrer" target="_blank">
Terms
</a>{' '}
and this Disclaimer, and agree to be bound by them.
</Text>
</>
}
onCancel={redirectToBalance}
onConfirm={onAcceptLegalDisclaimer}
title="Disclaimer"
/>
)
} }
if (network === 'UNKNOWN' || !granted) { if (network === 'UNKNOWN' || !granted) {
@ -149,8 +130,6 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
) )
} }
const app = getSelectedApp()
return ( return (
<IframeWrapper> <IframeWrapper>
{appIsLoading && ( {appIsLoading && (
@ -158,76 +137,26 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
<Loader size="md" /> <Loader size="md" />
</LoadingContainer> </LoadingContainer>
)} )}
<StyledIframe frameBorder="0" id={`iframe-${app.name}`} ref={iframeRef} src={app.url} title={app.name} /> <StyledIframe
frameBorder="0"
id={`iframe-${selectedApp.name}`}
ref={iframeRef}
src={selectedApp.url}
title={selectedApp.name}
/>
</IframeWrapper> </IframeWrapper>
) )
} }
const onAppAdded = (app: SafeApp) => { const enabledApps = useMemo(() => appList.filter((a) => !a.disabled), [appList])
const newAppList = [
{ url: app.url, disabled: false },
...appList.map((a) => ({
url: a.url,
disabled: a.disabled,
})),
]
saveToStorage(APPS_STORAGE_KEY, newAppList)
setAppList([...appList, { ...app, disabled: false }])
}
const selectFirstApp = useCallback(
(apps) => {
const firstEnabledApp = apps.find((a) => !a.disabled)
if (firstEnabledApp) {
onSelectApp(firstEnabledApp.id)
}
},
[onSelectApp],
)
const onAppToggle = async (appId, enabled) => {
// update in-memory list
const copyAppList = [...appList]
const app = copyAppList.find((a) => a.id === appId)
if (!app) {
return
}
app.disabled = !enabled
setAppList(copyAppList)
// update storage list
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || []
let storageApp = persistedAppList.find((a) => a.url === app.url)
if (!storageApp) {
storageApp = { url: app.url }
storageApp.disabled = !enabled
persistedAppList.push(storageApp)
} else {
storageApp.disabled = !enabled
}
saveToStorage(APPS_STORAGE_KEY, persistedAppList)
// select app
const selectedApp = getSelectedApp()
if (!selectedApp || (selectedApp && selectedApp.id === appId)) {
setSelectedApp(undefined)
selectFirstApp(copyAppList)
}
}
const getEnabledApps = () => appList.filter((a) => !a.disabled)
const sendMessageToIframe = useCallback( const sendMessageToIframe = useCallback(
(messageId, data) => { (messageId, data) => {
const app = getSelectedApp() if (iframeEl && selectedApp) {
iframeEl?.contentWindow.postMessage({ messageId, data }, app.url) iframeEl.contentWindow.postMessage({ messageId, data }, selectedApp.url)
}
}, },
[getSelectedApp, iframeEl], [iframeEl, selectedApp],
) )
// handle messages from iframe // handle messages from iframe
@ -242,22 +171,16 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
case operations.SEND_TRANSACTIONS: { case operations.SEND_TRANSACTIONS: {
const onConfirm = async () => { const onConfirm = async () => {
closeModal() closeModal()
await sendTransactions(
dispatch, await sendTransactions(dispatch, safeAddress, data.data, enqueueSnackbar, closeSnackbar, selectedApp.id)
safeAddress,
data.data,
enqueueSnackbar,
closeSnackbar,
getSelectedApp().id,
)
} }
confirmTransactions( confirmTransactions(
safeAddress, safeAddress,
safeName, safeName,
ethBalance, ethBalance,
getSelectedApp().name, selectedApp.name,
getSelectedApp().iconUrl, selectedApp.iconUrl,
data.data, data.data,
openModal, openModal,
closeModal, closeModal,
@ -287,8 +210,7 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
return return
} }
const app = getSelectedApp() if (!selectedApp.url.includes(origin)) {
if (!app.url.includes(origin)) {
console.error(`ThirdPartyApp: A message was received from an unknown origin ${origin}`) console.error(`ThirdPartyApp: A message was received from an unknown origin ${origin}`)
return return
} }
@ -303,65 +225,11 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
} }
}) })
// load legalDisclaimer
useEffect(() => {
const checkLegalDisclaimer = async () => {
const legalDisclaimer = await loadFromStorage(APPS_LEGAL_DISCLAIMER_STORAGE_KEY)
if (legalDisclaimer) {
setLegalDisclaimerAccepted(true)
}
}
checkLegalDisclaimer()
}, [])
// Load apps list
useEffect(() => {
const loadApps = async () => {
// recover apps from storage:
// * third-party apps added by the user
// * disabled status for both static and third-party apps
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || []
const list = [...persistedAppList]
staticAppsList.forEach((staticApp) => {
if (!list.some((persistedApp) => persistedApp.url === staticApp.url)) {
list.push(staticApp)
}
})
const apps = []
// using the appURL to recover app info
for (let index = 0; index < list.length; index++) {
try {
const currentApp = list[index]
const appInfo: SafeApp = await getAppInfoFromUrl(currentApp.url)
if (appInfo.error) {
throw Error(`There was a problem trying to load app ${currentApp.url}`)
}
appInfo.disabled = currentApp.disabled === undefined ? false : currentApp.disabled
apps.push(appInfo)
} catch (error) {
console.error(error)
}
}
setAppList(apps)
setLoading(false)
selectFirstApp(apps)
}
if (!appList.length) {
loadApps()
}
}, [appList.length, selectFirstApp])
// on iframe change // on iframe change
useEffect(() => { useEffect(() => {
const sendMessageToIframe = (messageId, data) => {
iframeEl.contentWindow.postMessage({ messageId, data }, selectedApp.url)
}
const onIframeLoaded = () => { const onIframeLoaded = () => {
setAppIsLoading(false) setAppIsLoading(false)
sendMessageToIframe(operations.ON_SAFE_INFO, { sendMessageToIframe(operations.ON_SAFE_INFO, {
@ -371,8 +239,7 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
}) })
} }
const app = getSelectedApp() if (!iframeEl || !selectedApp || !isSameHref(iframeEl.src, selectedApp.url)) {
if (!iframeEl || !selectedApp || !isSameHref(iframeEl.src, app.url)) {
return return
} }
@ -381,9 +248,9 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
return () => { return () => {
iframeEl.removeEventListener('load', onIframeLoaded) iframeEl.removeEventListener('load', onIframeLoaded)
} }
}, [ethBalance, getSelectedApp, iframeEl, network, safeAddress, selectedApp, sendMessageToIframe]) }, [ethBalance, iframeEl, network, safeAddress, selectedApp])
if (loading || !appList.length) { if (loadingAppList || !appList.length) {
return ( return (
<LoadingContainer> <LoadingContainer>
<Loader size="md" /> <Loader size="md" />
@ -396,26 +263,12 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
<Menu> <Menu>
<ManageApps appList={appList} onAppAdded={onAppAdded} onAppToggle={onAppToggle} /> <ManageApps appList={appList} onAppAdded={onAppAdded} onAppToggle={onAppToggle} />
</Menu> </Menu>
{getEnabledApps().length ? ( {enabledApps.length ? (
<LCL.Wrapper> <LCL.Wrapper>
<LCL.Menu> <LCL.Menu>
<LCL.List activeItem={selectedApp} items={getEnabledApps()} onItemClick={onSelectApp} /> <LCL.List activeItem={selectedAppId} items={enabledApps} onItemClick={onSelectApp} />
</LCL.Menu> </LCL.Menu>
<LCL.Content>{getContent()}</LCL.Content> <LCL.Content>{getContent()}</LCL.Content>
{/* <LCL.Footer>
{getSelectedApp() && getSelectedApp().providedBy && (
<>
<p>This App is provided by </p>
<ButtonLink
onClick={() => window.open(getSelectedApp().providedBy.url, '_blank')}
size="lg"
testId="manage-tokens-btn"
>
{selectedApp && getSelectedApp().providedBy.name}
</ButtonLink>
</>
)}
</LCL.Footer> */}
</LCL.Wrapper> </LCL.Wrapper>
) : ( ) : (
<Card style={{ marginBottom: '24px' }}> <Card style={{ marginBottom: '24px' }}>

View File

@ -4,7 +4,7 @@ import { List } from 'immutable'
import { FIXED } from 'src/components/Table/sorting' import { FIXED } from 'src/components/Table/sorting'
import { formatAmountInUsFormat } from 'src/logic/tokens/utils/formatAmount' import { formatAmountInUsFormat } from 'src/logic/tokens/utils/formatAmount'
import { ETH_ADDRESS } from 'src/logic/tokens/utils/tokenHelpers' import { ETH_ADDRESS } from 'src/logic/tokens/utils/tokenHelpers'
import { TableColumn } from 'src/components/Table/types' import { TableColumn } from 'src/components/Table/types.d'
import { AVAILABLE_CURRENCIES, BalanceCurrencyList } from 'src/logic/currencyValues/store/model/currencyValues' import { AVAILABLE_CURRENCIES, BalanceCurrencyList } from 'src/logic/currencyValues/store/model/currencyValues'
import { Token } from 'src/logic/tokens/store/model/token' import { Token } from 'src/logic/tokens/store/model/token'

View File

@ -1,5 +1,5 @@
import { List } from 'immutable' import { List } from 'immutable'
import { TableColumn } from 'src/components/Table/types' import { TableColumn } from 'src/components/Table/types.d'
import { ModulePair } from 'src/routes/safe/store/models/safe' import { ModulePair } from 'src/routes/safe/store/models/safe'
export const MODULES_TABLE_ADDRESS_ID = 'address' export const MODULES_TABLE_ADDRESS_ID = 'address'

View File

@ -1,5 +1,5 @@
import { List } from 'immutable' import { List } from 'immutable'
import { TableColumn } from 'src/components/Table/types' import { TableColumn } from 'src/components/Table/types.d'
export const OWNERS_TABLE_NAME_ID = 'name' export const OWNERS_TABLE_NAME_ID = 'name'
export const OWNERS_TABLE_ADDRESS_ID = 'address' export const OWNERS_TABLE_ADDRESS_ID = 'address'

View File

@ -22,9 +22,12 @@ import Paragraph from 'src/components/layout/Paragraph'
import LinkWithRef from 'src/components/layout/Link' import LinkWithRef from 'src/components/layout/Link'
import { shortVersionOf } from 'src/logic/wallets/ethAddresses' import { shortVersionOf } from 'src/logic/wallets/ethAddresses'
import { Transaction } from 'src/routes/safe/store/models/types/transaction' import { Transaction } from 'src/routes/safe/store/models/types/transaction'
import { DataDecoded } from 'src/routes/safe/store/models/types/transactions.d'
import DividerLine from 'src/components/DividerLine'
export const TRANSACTIONS_DESC_CUSTOM_VALUE_TEST_ID = 'tx-description-custom-value' export const TRANSACTIONS_DESC_CUSTOM_VALUE_TEST_ID = 'tx-description-custom-value'
export const TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID = 'tx-description-custom-data' export const TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID = 'tx-description-custom-data'
export const TRANSACTION_DESC_ACTION_TEST_ID = 'tx-description-action-data'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
@ -45,12 +48,29 @@ const TxInfo = styled.div`
padding: 8px 8px 8px 16px; padding: 8px 8px 8px 16px;
` `
const MultiSendCustomData = ({ tx, order }: { tx: MultiSendDetails; order: number }): React.ReactElement => { const TxInfoDetails = ({ data }: { data: DataDecoded }): React.ReactElement => (
<TxInfo>
<TxDetailsMethodName size="lg" strong>
{data.method}
</TxDetailsMethodName>
{data.parameters.map((param, index) => (
<TxDetailsMethodParam key={`${data.method}_param-${index}`}>
<InlineText size="lg" strong>
{param.name}({param.type}):
</InlineText>
<Value method={data.method} type={param.type} value={param.value} />
</TxDetailsMethodParam>
))}
</TxInfo>
)
const MultiSendCustomDataAction = ({ tx, order }: { tx: MultiSendDetails; order: number }): React.ReactElement => {
const classes = useStyles() const classes = useStyles()
const methodName = tx.data?.method ? ` (${tx.data.method})` : '' const methodName = tx.data?.method ? ` (${tx.data.method})` : ''
return ( return (
<>
<Collapse <Collapse
collapseClassName={classes.collapse} collapseClassName={classes.collapse}
headerWrapperClassName={classes.collapseHeaderWrapper} headerWrapperClassName={classes.collapseHeaderWrapper}
@ -61,26 +81,22 @@ const MultiSendCustomData = ({ tx, order }: { tx: MultiSendDetails; order: numbe
<Bold>Send {humanReadableValue(tx.value)} ETH to:</Bold> <Bold>Send {humanReadableValue(tx.value)} ETH to:</Bold>
<OwnerAddressTableCell address={tx.to} showLinks /> <OwnerAddressTableCell address={tx.to} showLinks />
</TxInfo> </TxInfo>
{tx.data && (
<TxInfo> {!!tx.data && <TxInfoDetails data={tx.data} />}
<TxDetailsMethodName size="lg">
<strong>{tx.data.method}</strong>
</TxDetailsMethodName>
{tx.data?.parameters.map((param, index) => (
<TxDetailsMethodParam key={`${tx.operation}_${tx.to}_${tx.data.method}_param-${index}`}>
<InlineText size="lg">
<strong>
{param.name}({param.type}):
</strong>
</InlineText>
<Value method={methodName} type={param.type} value={param.value} />
</TxDetailsMethodParam>
))}
</TxInfo>
)}
</TxDetailsContent> </TxDetailsContent>
</Collapse> </Collapse>
</> )
}
const MultiSendCustomData = ({ txDetails }: { txDetails: MultiSendDetails[] }): React.ReactElement => {
const classes = useStyles()
return (
<Block className={classes.multiSendTxData} data-testid={TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID}>
{txDetails.map((tx, index) => (
<MultiSendCustomDataAction key={`${tx.to}-row-${index}`} tx={tx} order={index} />
))}
</Block>
) )
} }
@ -128,13 +144,31 @@ const TxData = ({ data }: { data: string }): React.ReactElement => {
) )
} }
const TxActionData = ({ dataDecoded }: { dataDecoded: DataDecoded }): React.ReactElement => {
const classes = useStyles()
return (
<>
<DividerLine withArrow={false} />
<Block className={classes.txData} data-testid={TRANSACTION_DESC_ACTION_TEST_ID}>
<Bold>Action</Bold>
<TxInfoDetails data={dataDecoded} />
</Block>
<DividerLine withArrow={false} />
</>
)
}
interface GenericCustomDataProps { interface GenericCustomDataProps {
amount?: string amount?: string
data: string data: string
recipient: string recipient: string
storedTx: Transaction
} }
const GenericCustomData = ({ amount = '0', data, recipient }: GenericCustomDataProps): React.ReactElement => { const GenericCustomData = ({ amount = '0', data, recipient, storedTx }: GenericCustomDataProps): React.ReactElement => {
const classes = useStyles() const classes = useStyles()
const recipientName = useSelector((state) => getNameFromAddressBook(state, recipient)) const recipientName = useSelector((state) => getNameFromAddressBook(state, recipient))
@ -148,6 +182,9 @@ const GenericCustomData = ({ amount = '0', data, recipient }: GenericCustomDataP
<EtherscanLink knownAddress={false} type="address" value={recipient} /> <EtherscanLink knownAddress={false} type="address" value={recipient} />
)} )}
</Block> </Block>
{!!storedTx?.dataDecoded && <TxActionData dataDecoded={storedTx.dataDecoded} />}
<Block className={classes.txData} data-testid={TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID}> <Block className={classes.txData} data-testid={TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID}>
<Bold>Data (hex encoded):</Bold> <Bold>Data (hex encoded):</Bold>
<TxData data={data} /> <TxData data={data} />
@ -164,16 +201,12 @@ interface CustomDescriptionProps {
} }
const CustomDescription = ({ amount, data, recipient, storedTx }: CustomDescriptionProps): React.ReactElement => { const CustomDescription = ({ amount, data, recipient, storedTx }: CustomDescriptionProps): React.ReactElement => {
const classes = useStyles() const txDetails = (storedTx.multiSendTx && extractMultiSendDecodedData(storedTx).txDetails) ?? undefined
return storedTx.multiSendTx ? ( return txDetails ? (
<Block className={classes.multiSendTxData} data-testid={TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID}> <MultiSendCustomData txDetails={txDetails} />
{extractMultiSendDecodedData(storedTx).txDetails?.map((tx, index) => (
<MultiSendCustomData key={`${tx.to}-row-${index}`} tx={tx} order={index} />
))}
</Block>
) : ( ) : (
<GenericCustomData amount={amount} data={data} recipient={recipient} /> <GenericCustomData amount={amount} data={data} recipient={recipient} storedTx={storedTx} />
) )
} }

View File

@ -8,7 +8,7 @@ import SettingsTxIcon from './assets/settings.svg'
import CustomIconText from 'src/components/CustomIconText' import CustomIconText from 'src/components/CustomIconText'
import { getAppInfoFromOrigin, getAppInfoFromUrl } from 'src/routes/safe/components/Apps/utils' import { getAppInfoFromOrigin, getAppInfoFromUrl } from 'src/routes/safe/components/Apps/utils'
import { SafeApp } from 'src/routes/safe/components/Apps/types' import { SafeApp } from 'src/routes/safe/components/Apps/types.d'
const typeToIcon = { const typeToIcon = {
outgoing: OutgoingTxIcon, outgoing: OutgoingTxIcon,

View File

@ -8,7 +8,7 @@ import React from 'react'
import TxType from './TxType' import TxType from './TxType'
import { buildOrderFieldFrom } from 'src/components/Table/sorting' import { buildOrderFieldFrom } from 'src/components/Table/sorting'
import { TableColumn } from 'src/components/Table/types' import { TableColumn } from 'src/components/Table/types.d'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount' import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { INCOMING_TX_TYPES } from 'src/routes/safe/store/models/incomingTransaction' import { INCOMING_TX_TYPES } from 'src/routes/safe/store/models/incomingTransaction'
import { Transaction } from 'src/routes/safe/store/models/types/transaction' import { Transaction } from 'src/routes/safe/store/models/types/transaction'

View File

@ -13,8 +13,7 @@ describe('TxsTable Columns > getTxTableData', () => {
const txRow = txTableData.first() const txRow = txTableData.first()
// Then // Then
// expect(txRow[TX_TABLE_RAW_CANCEL_TX_ID]).toEqual(mockedCancelTransaction) expect(txRow[TX_TABLE_RAW_CANCEL_TX_ID]).toEqual(mockedCancelTransaction)
expect(txRow[TX_TABLE_RAW_CANCEL_TX_ID]).toBeUndefined()
}) })
it('should not include CancelTx object inside TxTableData', () => { it('should not include CancelTx object inside TxTableData', () => {
// Given // Given

View File

@ -8,7 +8,7 @@ import activateAssetsByBalance from 'src/logic/tokens/store/actions/activateAsse
import fetchSafeTokens from 'src/logic/tokens/store/actions/fetchSafeTokens' import fetchSafeTokens from 'src/logic/tokens/store/actions/fetchSafeTokens'
import { fetchTokens } from 'src/logic/tokens/store/actions/fetchTokens' import { fetchTokens } from 'src/logic/tokens/store/actions/fetchTokens'
import { COINS_LOCATION_REGEX, COLLECTIBLES_LOCATION_REGEX } from 'src/routes/safe/components/Balances' import { COINS_LOCATION_REGEX, COLLECTIBLES_LOCATION_REGEX } from 'src/routes/safe/components/Balances'
import { Dispatch } from 'src/routes/safe/store/actions/types' import { Dispatch } from 'src/routes/safe/store/actions/types.d'
export const useFetchTokens = (safeAddress: string): void => { export const useFetchTokens = (safeAddress: string): void => {
const dispatch = useDispatch<Dispatch>() const dispatch = useDispatch<Dispatch>()

View File

@ -8,7 +8,7 @@ import fetchLatestMasterContractVersion from 'src/routes/safe/store/actions/fetc
import fetchSafe from 'src/routes/safe/store/actions/fetchSafe' import fetchSafe from 'src/routes/safe/store/actions/fetchSafe'
import fetchTransactions from 'src/routes/safe/store/actions/transactions/fetchTransactions' import fetchTransactions from 'src/routes/safe/store/actions/transactions/fetchTransactions'
import fetchSafeCreationTx from 'src/routes/safe/store/actions/fetchSafeCreationTx' import fetchSafeCreationTx from 'src/routes/safe/store/actions/fetchSafeCreationTx'
import { Dispatch } from 'src/routes/safe/store/actions/types' import { Dispatch } from 'src/routes/safe/store/actions/types.d'
export const useLoadSafe = (safeAddress: string): void => { export const useLoadSafe = (safeAddress: string): void => {
const dispatch = useDispatch<Dispatch>() const dispatch = useDispatch<Dispatch>()

View File

@ -1,56 +1,54 @@
import { getNewTxNonce, shouldExecuteTransaction } from 'src/routes/safe/store/actions/utils' import { getNewTxNonce, shouldExecuteTransaction } from 'src/routes/safe/store/actions/utils'
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
import { TxServiceModel } from 'src/routes/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions'
describe('Store actions utils > getNewTxNonce', () => { describe('Store actions utils > getNewTxNonce', () => {
it(`should return txNonce if it's a valid value`, async () => { it(`Should return passed predicted transaction nonce if it's a valid value`, async () => {
// Given // Given
const txNonce = '45' const txNonce = '45'
const lastTx = { const lastTx = { nonce: 44 } as TxServiceModel
nonce: 44 const safeInstance = {}
}
const safeInstance = {
nonce: () => Promise.resolve({
toString: () => Promise.resolve('45')
})
}
// When // When
const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance) const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance as GnosisSafe)
// Then // Then
expect(nonce).toBe('45') expect(nonce).toBe('45')
}) })
it(`should return lastTx.nonce + 1 if txNonce is not valid`, async () => { it(`Should return nonce of a last transaction + 1 if passed nonce is less than last transaction or invalid`, async () => {
// Given // Given
const txNonce = '' const txNonce = ''
const lastTx = { const lastTx = { nonce: 44 } as TxServiceModel
nonce: 44
}
const safeInstance = { const safeInstance = {
nonce: () => Promise.resolve({ methods: {
toString: () => Promise.resolve('45') nonce: () => ({
}) call: () => Promise.resolve('45'),
}),
},
} }
// When // When
const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance) const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance as GnosisSafe)
// Then // Then
expect(nonce).toBe('45') expect(nonce).toBe('45')
}) })
it(`should retrieve contract's instance nonce value, if txNonce and lastTx are not valid`, async () => { it(`Should retrieve contract's instance nonce value as a fallback, if txNonce and lastTx are not valid`, async () => {
// Given // Given
const txNonce = '' const txNonce = ''
const lastTx = null const lastTx = null
const safeInstance = { const safeInstance = {
nonce: () => Promise.resolve({ methods: {
toString: () => Promise.resolve('45') nonce: () => ({
}) call: () => Promise.resolve('45'),
}),
},
} }
// When // When
const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance) const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance as GnosisSafe)
// Then // Then
expect(nonce).toBe('45') expect(nonce).toBe('45')
@ -61,17 +59,17 @@ describe('Store actions utils > shouldExecuteTransaction', () => {
it(`should return false if there's a previous tx pending to be executed`, async () => { it(`should return false if there's a previous tx pending to be executed`, async () => {
// Given // Given
const safeInstance = { const safeInstance = {
getThreshold: () => Promise.resolve({ methods: {
toNumber: () => 1 getThreshold: () => ({
}) call: () => Promise.resolve('1'),
}),
},
} }
const nonce = '1' const nonce = '1'
const lastTx = { const lastTx = { isExecuted: false } as TxServiceModel
isExecuted: false
}
// When // When
const isExecution = await shouldExecuteTransaction(safeInstance, nonce, lastTx) const isExecution = await shouldExecuteTransaction(safeInstance as GnosisSafe, nonce, lastTx)
// Then // Then
expect(isExecution).toBeFalsy() expect(isExecution).toBeFalsy()
@ -80,17 +78,17 @@ describe('Store actions utils > shouldExecuteTransaction', () => {
it(`should return false if threshold is greater than 1`, async () => { it(`should return false if threshold is greater than 1`, async () => {
// Given // Given
const safeInstance = { const safeInstance = {
getThreshold: () => Promise.resolve({ methods: {
toNumber: () => 2 getThreshold: () => ({
}) call: () => Promise.resolve('2'),
}),
},
} }
const nonce = '1' const nonce = '1'
const lastTx = { const lastTx = { isExecuted: true } as TxServiceModel
isExecuted: true
}
// When // When
const isExecution = await shouldExecuteTransaction(safeInstance, nonce, lastTx) const isExecution = await shouldExecuteTransaction(safeInstance as GnosisSafe, nonce, lastTx)
// Then // Then
expect(isExecution).toBeFalsy() expect(isExecution).toBeFalsy()
@ -99,17 +97,17 @@ describe('Store actions utils > shouldExecuteTransaction', () => {
it(`should return true is threshold is 1 and previous tx is executed`, async () => { it(`should return true is threshold is 1 and previous tx is executed`, async () => {
// Given // Given
const safeInstance = { const safeInstance = {
getThreshold: () => Promise.resolve({ methods: {
toNumber: () => 1 getThreshold: () => ({
}) call: () => Promise.resolve('1'),
}),
},
} }
const nonce = '1' const nonce = '1'
const lastTx = { const lastTx = { isExecuted: true } as TxServiceModel
isExecuted: true
}
// When // When
const isExecution = await shouldExecuteTransaction(safeInstance, nonce, lastTx) const isExecution = await shouldExecuteTransaction(safeInstance as GnosisSafe, nonce, lastTx)
// Then // Then
expect(isExecution).toBeTruthy() expect(isExecution).toBeTruthy()

View File

@ -1,6 +1,5 @@
import { List, Map } from 'immutable' import { List, Map } from 'immutable'
import { decodeMethods } from 'src/logic/contracts/methodIds'
import { TOKEN_REDUCER_ID } from 'src/logic/tokens/store/reducer/tokens' import { TOKEN_REDUCER_ID } from 'src/logic/tokens/store/reducer/tokens'
import { import {
getERC20DecimalsAndSymbol, getERC20DecimalsAndSymbol,
@ -318,30 +317,6 @@ export type TxToMock = TxArgs & {
} }
export const mockTransaction = (tx: TxToMock, safeAddress: string, state: AppReduxState): Promise<Transaction> => { export const mockTransaction = (tx: TxToMock, safeAddress: string, state: AppReduxState): Promise<Transaction> => {
const submissionDate = new Date().toISOString()
const transactionStructure: TxServiceModel = {
blockNumber: null,
confirmationsRequired: null,
dataDecoded: decodeMethods(tx.data),
ethGasPrice: null,
executionDate: null,
executor: null,
fee: null,
gasUsed: null,
isExecuted: false,
isSuccessful: null,
modified: submissionDate,
origin: null,
safe: safeAddress,
safeTxHash: null,
signatures: null,
submissionDate,
transactionHash: null,
confirmations: [],
...tx,
}
const knownTokens: Map<string, Token> = state[TOKEN_REDUCER_ID] const knownTokens: Map<string, Token> = state[TOKEN_REDUCER_ID]
const safe: SafeRecord = state[SAFE_REDUCER_ID].getIn(['safes', safeAddress]) const safe: SafeRecord = state[SAFE_REDUCER_ID].getIn(['safes', safeAddress])
const cancellationTxs = state[CANCELLATION_TRANSACTIONS_REDUCER_ID].get(safeAddress) || Map() const cancellationTxs = state[CANCELLATION_TRANSACTIONS_REDUCER_ID].get(safeAddress) || Map()
@ -353,7 +328,7 @@ export const mockTransaction = (tx: TxToMock, safeAddress: string, state: AppRed
knownTokens, knownTokens,
outgoingTxs, outgoingTxs,
safe, safe,
tx: transactionStructure, tx: (tx as unknown) as TxServiceModel,
txCode: EMPTY_DATA, txCode: EMPTY_DATA,
}) })
} }

View File

@ -143,4 +143,4 @@ export default handleActions(
}), }),
) )
export * from './types/safe.d' export * from './types/safe'

View File

@ -9,11 +9,13 @@ export interface SafeReducerState {
latestMasterContractVersion: string latestMasterContractVersion: string
} }
interface SafeReducerStateSerialized extends SafeReducerState { interface SafeReducerStateJSON {
defaultSafe: 'NOT_ASKED' | string | undefined
safes: Record<string, SafeRecordProps> safes: Record<string, SafeRecordProps>
latestMasterContractVersion: string
} }
export interface SafeReducerMap extends Map<string, any> { export interface SafeReducerMap extends Map<string, any> {
toJS(): SafeReducerStateSerialized toJS(): SafeReducerStateJSON
get<K extends keyof SafeReducerState>(key: K): SafeReducerState[K] get<K extends keyof SafeReducerState>(key: K): SafeReducerState[K]
} }

View File

@ -24,10 +24,7 @@ export const loadFromStorage = async <T = unknown>(key: string): Promise<T | und
} }
} }
export const saveToStorage = async ( export const saveToStorage = async <T = unknown>(key: string, value: T): Promise<void> => {
key: string,
value: Record<string, unknown> | boolean | string | number | Array<unknown>,
): Promise<void> => {
try { try {
const stringifiedValue = JSON.stringify(value) const stringifiedValue = JSON.stringify(value)
await storage.set(`${PREFIX}__${key}`, stringifiedValue) await storage.set(`${PREFIX}__${key}`, stringifiedValue)

867
yarn.lock

File diff suppressed because it is too large Load Diff