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 => {
if(!data.length) {
return null
}
const [methodId, params] = [data.slice(0, 10), data.slice(10)]
if (isSafeMethod(methodId)) {

View File

@ -3,7 +3,7 @@ import createDecorator from 'final-form-calculate'
import React from 'react'
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 { composeValidators, required } from 'src/components/forms/validator'
import Field from 'src/components/forms/Field'

View File

@ -1,7 +1,7 @@
import React from 'react'
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'
interface SubmitButtonStatusProps {

View File

@ -6,7 +6,7 @@ import AppAgreement from './AppAgreement'
import AppUrl, { AppInfoUpdater, appUrlResolver } from './AppUrl'
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 Img from 'src/components/layout/Img'
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 appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
import AddAppForm from './AddAppForm'
import { SafeApp } from './types'
import AddAppForm from '../AddAppForm'
import { SafeApp } from '../types'
const FORM_ID = 'add-apps-form'

View File

@ -75,7 +75,7 @@ const confirmTransactions = (
ethBalance: string,
nameApp: string,
iconApp: string,
txs: Array<SafeAppTx>,
txs: SafeAppTx[],
openModal: (modalInfo: GenericModalProps) => void,
closeModal: () => 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 React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useState, useMemo } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useHistory } from 'react-router-dom'
import styled from 'styled-components'
import ManageApps from './ManageApps'
import ManageApps from './components/ManageApps'
import confirmTransactions from './confirmTransactions'
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 { networkSelector } from 'src/logic/wallets/store/selectors'
@ -19,12 +21,7 @@ import {
safeNameSelector,
safeParamAddressFromStateSelector,
} from 'src/routes/safe/store/selectors'
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
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`
padding: 15px;
@ -63,11 +60,10 @@ const operations = {
}
function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
const [appList, setAppList] = useState<Array<SafeApp>>([])
const [legalDisclaimerAccepted, setLegalDisclaimerAccepted] = useState(false)
const [selectedApp, setSelectedApp] = useState<string>()
const [loading, setLoading] = useState(true)
const [appIsLoading, setAppIsLoading] = useState(true)
const { appList, loadingAppList, onAppToggle, onAppAdded } = useAppList()
const [appIsLoading, setAppIsLoading] = useState<boolean>(true)
const [selectedAppId, setSelectedAppId] = useState<string>()
const [iframeEl, setIframeEl] = useState<HTMLIFrameElement | null>(null)
const history = useHistory()
@ -77,8 +73,36 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
const network = useSelector(networkSelector)
const ethBalance = useSelector(safeEthBalanceSelector)
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) => {
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 onAcceptLegalDisclaimer = () => {
setLegalDisclaimerAccepted(true)
saveToStorage(APPS_LEGAL_DISCLAIMER_STORAGE_KEY, true)
}
const getContent = () => {
if (!selectedApp) {
return null
}
if (!legalDisclaimerAccepted) {
return (
<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 (!consentReceived) {
return <LegalDisclaimer onCancel={redirectToBalance} onConfirm={onConsentReceipt} />
}
if (network === 'UNKNOWN' || !granted) {
@ -149,8 +130,6 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
)
}
const app = getSelectedApp()
return (
<IframeWrapper>
{appIsLoading && (
@ -158,76 +137,26 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
<Loader size="md" />
</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>
)
}
const onAppAdded = (app: SafeApp) => {
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 enabledApps = useMemo(() => appList.filter((a) => !a.disabled), [appList])
const sendMessageToIframe = useCallback(
(messageId, data) => {
const app = getSelectedApp()
iframeEl?.contentWindow.postMessage({ messageId, data }, app.url)
if (iframeEl && selectedApp) {
iframeEl.contentWindow.postMessage({ messageId, data }, selectedApp.url)
}
},
[getSelectedApp, iframeEl],
[iframeEl, selectedApp],
)
// handle messages from iframe
@ -242,22 +171,16 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
case operations.SEND_TRANSACTIONS: {
const onConfirm = async () => {
closeModal()
await sendTransactions(
dispatch,
safeAddress,
data.data,
enqueueSnackbar,
closeSnackbar,
getSelectedApp().id,
)
await sendTransactions(dispatch, safeAddress, data.data, enqueueSnackbar, closeSnackbar, selectedApp.id)
}
confirmTransactions(
safeAddress,
safeName,
ethBalance,
getSelectedApp().name,
getSelectedApp().iconUrl,
selectedApp.name,
selectedApp.iconUrl,
data.data,
openModal,
closeModal,
@ -287,8 +210,7 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
return
}
const app = getSelectedApp()
if (!app.url.includes(origin)) {
if (!selectedApp.url.includes(origin)) {
console.error(`ThirdPartyApp: A message was received from an unknown origin ${origin}`)
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
useEffect(() => {
const sendMessageToIframe = (messageId, data) => {
iframeEl.contentWindow.postMessage({ messageId, data }, selectedApp.url)
}
const onIframeLoaded = () => {
setAppIsLoading(false)
sendMessageToIframe(operations.ON_SAFE_INFO, {
@ -371,8 +239,7 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
})
}
const app = getSelectedApp()
if (!iframeEl || !selectedApp || !isSameHref(iframeEl.src, app.url)) {
if (!iframeEl || !selectedApp || !isSameHref(iframeEl.src, selectedApp.url)) {
return
}
@ -381,9 +248,9 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
return () => {
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 (
<LoadingContainer>
<Loader size="md" />
@ -396,26 +263,12 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) {
<Menu>
<ManageApps appList={appList} onAppAdded={onAppAdded} onAppToggle={onAppToggle} />
</Menu>
{getEnabledApps().length ? (
{enabledApps.length ? (
<LCL.Wrapper>
<LCL.Menu>
<LCL.List activeItem={selectedApp} items={getEnabledApps()} onItemClick={onSelectApp} />
<LCL.List activeItem={selectedAppId} items={enabledApps} onItemClick={onSelectApp} />
</LCL.Menu>
<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>
) : (
<Card style={{ marginBottom: '24px' }}>

View File

@ -4,7 +4,7 @@ import { List } from 'immutable'
import { FIXED } from 'src/components/Table/sorting'
import { formatAmountInUsFormat } from 'src/logic/tokens/utils/formatAmount'
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 { Token } from 'src/logic/tokens/store/model/token'

View File

@ -1,5 +1,5 @@
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'
export const MODULES_TABLE_ADDRESS_ID = 'address'

View File

@ -1,5 +1,5 @@
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_ADDRESS_ID = 'address'

View File

@ -22,9 +22,12 @@ import Paragraph from 'src/components/layout/Paragraph'
import LinkWithRef from 'src/components/layout/Link'
import { shortVersionOf } from 'src/logic/wallets/ethAddresses'
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_DATA_TEST_ID = 'tx-description-custom-data'
export const TRANSACTION_DESC_ACTION_TEST_ID = 'tx-description-action-data'
const useStyles = makeStyles(styles)
@ -45,12 +48,29 @@ const TxInfo = styled.div`
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 methodName = tx.data?.method ? ` (${tx.data.method})` : ''
return (
<>
<Collapse
collapseClassName={classes.collapse}
headerWrapperClassName={classes.collapseHeaderWrapper}
@ -61,26 +81,22 @@ const MultiSendCustomData = ({ tx, order }: { tx: MultiSendDetails; order: numbe
<Bold>Send {humanReadableValue(tx.value)} ETH to:</Bold>
<OwnerAddressTableCell address={tx.to} showLinks />
</TxInfo>
{tx.data && (
<TxInfo>
<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>
)}
{!!tx.data && <TxInfoDetails data={tx.data} />}
</TxDetailsContent>
</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 {
amount?: string
data: 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 recipientName = useSelector((state) => getNameFromAddressBook(state, recipient))
@ -148,6 +182,9 @@ const GenericCustomData = ({ amount = '0', data, recipient }: GenericCustomDataP
<EtherscanLink knownAddress={false} type="address" value={recipient} />
)}
</Block>
{!!storedTx?.dataDecoded && <TxActionData dataDecoded={storedTx.dataDecoded} />}
<Block className={classes.txData} data-testid={TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID}>
<Bold>Data (hex encoded):</Bold>
<TxData data={data} />
@ -164,16 +201,12 @@ interface CustomDescriptionProps {
}
const CustomDescription = ({ amount, data, recipient, storedTx }: CustomDescriptionProps): React.ReactElement => {
const classes = useStyles()
const txDetails = (storedTx.multiSendTx && extractMultiSendDecodedData(storedTx).txDetails) ?? undefined
return storedTx.multiSendTx ? (
<Block className={classes.multiSendTxData} data-testid={TRANSACTIONS_DESC_CUSTOM_DATA_TEST_ID}>
{extractMultiSendDecodedData(storedTx).txDetails?.map((tx, index) => (
<MultiSendCustomData key={`${tx.to}-row-${index}`} tx={tx} order={index} />
))}
</Block>
return txDetails ? (
<MultiSendCustomData txDetails={txDetails} />
) : (
<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 { 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 = {
outgoing: OutgoingTxIcon,

View File

@ -8,7 +8,7 @@ import React from 'react'
import TxType from './TxType'
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 { INCOMING_TX_TYPES } from 'src/routes/safe/store/models/incomingTransaction'
import { Transaction } from 'src/routes/safe/store/models/types/transaction'

View File

@ -13,8 +13,7 @@ describe('TxsTable Columns > getTxTableData', () => {
const txRow = txTableData.first()
// Then
// expect(txRow[TX_TABLE_RAW_CANCEL_TX_ID]).toEqual(mockedCancelTransaction)
expect(txRow[TX_TABLE_RAW_CANCEL_TX_ID]).toBeUndefined()
expect(txRow[TX_TABLE_RAW_CANCEL_TX_ID]).toEqual(mockedCancelTransaction)
})
it('should not include CancelTx object inside TxTableData', () => {
// 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 { fetchTokens } from 'src/logic/tokens/store/actions/fetchTokens'
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 => {
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 fetchTransactions from 'src/routes/safe/store/actions/transactions/fetchTransactions'
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 => {
const dispatch = useDispatch<Dispatch>()

View File

@ -1,56 +1,54 @@
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', () => {
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
const txNonce = '45'
const lastTx = {
nonce: 44
}
const safeInstance = {
nonce: () => Promise.resolve({
toString: () => Promise.resolve('45')
})
}
const lastTx = { nonce: 44 } as TxServiceModel
const safeInstance = {}
// When
const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance)
const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance as GnosisSafe)
// Then
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
const txNonce = ''
const lastTx = {
nonce: 44
}
const lastTx = { nonce: 44 } as TxServiceModel
const safeInstance = {
nonce: () => Promise.resolve({
toString: () => Promise.resolve('45')
})
methods: {
nonce: () => ({
call: () => Promise.resolve('45'),
}),
},
}
// When
const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance)
const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance as GnosisSafe)
// Then
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
const txNonce = ''
const lastTx = null
const safeInstance = {
nonce: () => Promise.resolve({
toString: () => Promise.resolve('45')
})
methods: {
nonce: () => ({
call: () => Promise.resolve('45'),
}),
},
}
// When
const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance)
const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance as GnosisSafe)
// Then
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 () => {
// Given
const safeInstance = {
getThreshold: () => Promise.resolve({
toNumber: () => 1
})
methods: {
getThreshold: () => ({
call: () => Promise.resolve('1'),
}),
},
}
const nonce = '1'
const lastTx = {
isExecuted: false
}
const lastTx = { isExecuted: false } as TxServiceModel
// When
const isExecution = await shouldExecuteTransaction(safeInstance, nonce, lastTx)
const isExecution = await shouldExecuteTransaction(safeInstance as GnosisSafe, nonce, lastTx)
// Then
expect(isExecution).toBeFalsy()
@ -80,17 +78,17 @@ describe('Store actions utils > shouldExecuteTransaction', () => {
it(`should return false if threshold is greater than 1`, async () => {
// Given
const safeInstance = {
getThreshold: () => Promise.resolve({
toNumber: () => 2
})
methods: {
getThreshold: () => ({
call: () => Promise.resolve('2'),
}),
},
}
const nonce = '1'
const lastTx = {
isExecuted: true
}
const lastTx = { isExecuted: true } as TxServiceModel
// When
const isExecution = await shouldExecuteTransaction(safeInstance, nonce, lastTx)
const isExecution = await shouldExecuteTransaction(safeInstance as GnosisSafe, nonce, lastTx)
// Then
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 () => {
// Given
const safeInstance = {
getThreshold: () => Promise.resolve({
toNumber: () => 1
})
methods: {
getThreshold: () => ({
call: () => Promise.resolve('1'),
}),
},
}
const nonce = '1'
const lastTx = {
isExecuted: true
}
const lastTx = { isExecuted: true } as TxServiceModel
// When
const isExecution = await shouldExecuteTransaction(safeInstance, nonce, lastTx)
const isExecution = await shouldExecuteTransaction(safeInstance as GnosisSafe, nonce, lastTx)
// Then
expect(isExecution).toBeTruthy()

View File

@ -1,6 +1,5 @@
import { List, Map } from 'immutable'
import { decodeMethods } from 'src/logic/contracts/methodIds'
import { TOKEN_REDUCER_ID } from 'src/logic/tokens/store/reducer/tokens'
import {
getERC20DecimalsAndSymbol,
@ -318,30 +317,6 @@ export type TxToMock = TxArgs & {
}
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 safe: SafeRecord = state[SAFE_REDUCER_ID].getIn(['safes', safeAddress])
const cancellationTxs = state[CANCELLATION_TRANSACTIONS_REDUCER_ID].get(safeAddress) || Map()
@ -353,7 +328,7 @@ export const mockTransaction = (tx: TxToMock, safeAddress: string, state: AppRed
knownTokens,
outgoingTxs,
safe,
tx: transactionStructure,
tx: (tx as unknown) as TxServiceModel,
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
}
interface SafeReducerStateSerialized extends SafeReducerState {
interface SafeReducerStateJSON {
defaultSafe: 'NOT_ASKED' | string | undefined
safes: Record<string, SafeRecordProps>
latestMasterContractVersion: string
}
export interface SafeReducerMap extends Map<string, any> {
toJS(): SafeReducerStateSerialized
toJS(): SafeReducerStateJSON
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 (
key: string,
value: Record<string, unknown> | boolean | string | number | Array<unknown>,
): Promise<void> => {
export const saveToStorage = async <T = unknown>(key: string, value: T): Promise<void> => {
try {
const stringifiedValue = JSON.stringify(value)
await storage.set(`${PREFIX}__${key}`, stringifiedValue)

867
yarn.lock

File diff suppressed because it is too large Load Diff