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

This commit is contained in:
Mati Dastugue 2021-02-10 15:56:55 -03:00
commit 0bac3a5aab
232 changed files with 10554 additions and 3513 deletions

View File

@ -34,6 +34,7 @@ matrix:
- REACT_APP_SENTRY_DSN=${SENTRY_DSN_VOLTA}
- SENTRY_PROJECT=${SENTRY_PROJECT_VOLTA}
- REACT_APP_GOOGLE_ANALYTICS=${REACT_APP_GOOGLE_ANALYTICS_ID_VOLTA}
if: (branch = master) OR tag IS present
- env:
- REACT_APP_NETWORK='energy_web_chain'
- STAGING_BUCKET_NAME=${STAGING_EWC_BUCKET_NAME}
@ -43,7 +44,7 @@ matrix:
if: (branch = master AND NOT type = pull_request) OR tag IS present
cache:
npm: false
yarn: false
yarn: true
before_script:
- if [[ -n "$TRAVIS_TAG" ]]; then export REACT_APP_ENV='production'; fi;
- if [ $TRAVIS_PULL_REQUEST != "false" ]; then export PUBLIC_URL="/${REACT_APP_NETWORK}/app"; fi;
@ -53,7 +54,6 @@ before_install:
- sudo apt-get -y install python3-pip python3-dev libusb-1.0-0-dev libudev-dev
- pip install awscli --upgrade --user
script:
- yarn lint:check
- yarn prettier:check
- yarn test:coverage
- yarn build

View File

@ -73,7 +73,7 @@ export enum FEATURES {
ERC1155 = 'ERC1155',
SAFE_APPS = 'SAFE_APPS',
CONTRACT_INTERACTION = 'CONTRACT_INTERACTION',
ENS_LOOKUP = 'ENS_LOOKUP'
DOMAIN_LOOKUP = 'DOMAIN_LOOKUP'
}
```

View File

@ -1,6 +1,6 @@
{
"name": "safe-react",
"version": "2.18.1",
"version": "2.19.2",
"description": "Allowing crypto users manage funds in a safer way",
"website": "https://github.com/gnosis/safe-react#readme",
"bugs": {
@ -94,7 +94,7 @@
}
]
},
"files": [
"files": [
"build",
"patches",
"public",
@ -158,19 +158,20 @@
]
},
"dependencies": {
"@gnosis.pm/safe-apps-sdk": "1.0.2",
"@gnosis.pm/safe-apps-sdk": "1.0.3",
"@gnosis.pm/safe-apps-sdk-v1": "npm:@gnosis.pm/safe-apps-sdk@0.4.2",
"@gnosis.pm/safe-contracts": "1.1.1-dev.2",
"@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#bf3a84486b7353bd25447ddff39c406f6fafecc6",
"@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#8dea3a6",
"@gnosis.pm/util-contracts": "2.0.6",
"@ledgerhq/hw-transport-node-hid-singleton": "5.38.0",
"@ledgerhq/hw-transport-node-hid-singleton": "5.41.0",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.11.0",
"@material-ui/lab": "4.0.0-alpha.56",
"@material-ui/lab": "4.0.0-alpha.57",
"@openzeppelin/contracts": "3.1.0",
"@sentry/react": "^5.28.0",
"@sentry/tracing": "^5.28.0",
"@sentry/react": "^5.30.0",
"@sentry/tracing": "^5.30.0",
"@truffle/contract": "^4.3.0",
"@unstoppabledomains/resolution": "^1.17.0",
"async-sema": "^3.1.0",
"axios": "0.21.1",
"bignumber.js": "9.0.1",
@ -198,9 +199,13 @@
"immutable": "^4.0.0-rc.12",
"js-cookie": "^2.2.1",
"lodash.debounce": "^4.0.8",
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"lodash.memoize": "^4.1.2",
"lodash.merge": "^4.6.2",
"material-ui-search-bar": "^1.0.0",
"notistack": "https://github.com/gnosis/notistack.git#v0.9.4",
"object-hash": "^2.1.1",
"qrcode.react": "1.0.1",
"query-string": "6.13.8",
"react": "16.13.1",
@ -210,6 +215,7 @@
"react-final-form-listeners": "^1.0.2",
"react-ga": "3.3.0",
"react-hot-loader": "4.13.0",
"react-infinite-scroll-component": "^5.1.0",
"react-qr-reader": "^2.2.1",
"react-redux": "7.2.2",
"react-router-dom": "5.2.0",
@ -228,7 +234,7 @@
},
"devDependencies": {
"@rescripts/cli": "^0.0.15",
"@sentry/cli": "^1.59.0",
"@sentry/cli": "^1.62.0",
"@storybook/addon-actions": "^5.3.19",
"@storybook/addon-links": "^5.3.19",
"@storybook/addons": "^5.3.19",
@ -239,23 +245,25 @@
"@typechain/web3-v1": "^2.0.0",
"@types/history": "4.6.2",
"@types/jest": "^26.0.16",
"@types/lodash.get": "^4.4.6",
"@types/lodash.memoize": "^4.1.6",
"@types/node": "^14.14.10",
"@types/react": "^16.9.55",
"@types/react-dom": "^16.9.9",
"@types/react-redux": "^7.1.11",
"@types/react-router-dom": "^5.1.6",
"@types/redux-actions": "^2.6.1",
"@types/styled-components": "^5.1.4",
"@typescript-eslint/eslint-plugin": "^4.6.0",
"@typescript-eslint/parser": "^4.6.0",
"@typescript-eslint/eslint-plugin": "^4.14.0",
"@typescript-eslint/parser": "^4.14.0",
"cross-env": "^7.0.3",
"dotenv": "^8.2.0",
"dotenv-expand": "^5.1.0",
"electron": "^9.4.0",
"electron-builder": "22.9.1",
"electron-notarize": "1.0.0",
"eslint": "^7.11.0",
"eslint-config-prettier": "^7.0.0",
"eslint": "^7.17.0",
"eslint-config-prettier": "^7.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-prettier": "^3.1.4",
@ -266,7 +274,7 @@
"patch-package": "^6.2.2",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.2.0",
"sass": "^1.29.0",
"sass": "^1.32.0",
"typechain": "^4.0.0",
"typescript": "4.1.3",
"wait-on": "5.2.1"

View File

@ -50,7 +50,7 @@ const Footer = (): React.ReactElement => {
const dispatch = useDispatch()
const openCookiesHandler = () => {
dispatch(openCookieBanner(true))
dispatch(openCookieBanner({ cookieBannerOpen: true }))
}
return (

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g fill="none" fill-rule="evenodd">
<g>
<g>
<path d="M0 0H16V16H0z" transform="translate(-260 -550) translate(260 550)"/>
<path fill="#F02525" d="M8 5.996c.553 0 1 .447 1 1v.998c0 .553-.447 1-1 1-.552 0-1-.447-1-1v-.998c0-.553.448-1 1-1M8 9.895c.607 0 1.101.492 1.101 1.1 0 .607-.494 1.1-1.1 1.1-.608 0-1.1-.493-1.1-1.1 0-.608.492-1.1 1.1-1.1" transform="translate(-260 -550) translate(260 550)"/>
<path fill="#F02525" d="M8 0c-.383 0-.766.193-.975.581L.133 13.373c-.396.734.138 1.624.974 1.624h13.786c.836 0 1.37-.89.974-1.624L8.974.581C8.766.193 8.384 0 8 0m0 3l5.386 9.997H2.613L8 3" transform="translate(-260 -550) translate(260 550)"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -1,10 +1,8 @@
import Checkbox from '@material-ui/core/Checkbox'
import FormControlLabel from '@material-ui/core/FormControlLabel'
import { makeStyles } from '@material-ui/core/styles'
import cn from 'classnames'
import React, { useEffect, useState, useCallback } from 'react'
import React, { ReactElement, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import Button from 'src/components/layout/Button'
import Link from 'src/components/layout/Link'
import { COOKIES_KEY } from 'src/logic/cookies/model/cookie'
@ -13,7 +11,9 @@ import { cookieBannerOpen } from 'src/logic/cookies/store/selectors'
import { loadFromCookie, saveCookie } from 'src/logic/cookies/utils'
import { mainFontFamily, md, primary, screenSm } from 'src/theme/variables'
import { loadGoogleAnalytics } from 'src/utils/googleAnalytics'
import { loadIntercom } from 'src/utils/intercom'
import { closeIntercom, isIntercomLoaded, loadIntercom } from 'src/utils/intercom'
import AlertRedIcon from './assets/alert-red.svg'
import IntercomIcon from './assets/intercom.png'
const isDesktop = process.env.REACT_APP_BUILD_FOR_DESKTOP
@ -27,14 +27,13 @@ const useStyles = makeStyles({
justifyContent: 'center',
left: '0',
minHeight: '200px',
padding: '27px 15px',
padding: '30px 15px 45px',
position: 'fixed',
width: '100%',
zIndex: '15',
zIndex: '999',
},
content: {
maxWidth: '100%',
width: '830px',
},
text: {
color: primary,
@ -42,19 +41,21 @@ const useStyles = makeStyles({
fontSize: md,
fontWeight: 'normal',
lineHeight: '1.38',
margin: '0 0 25px',
margin: '0 auto 35px',
textAlign: 'center',
maxWidth: '810px',
},
form: {
columnGap: '10px',
columnGap: '20px',
display: 'grid',
gridTemplateColumns: '1fr',
paddingBottom: '30px',
rowGap: '10px',
paddingBottom: '50px',
rowGap: '15px',
margin: '0 auto',
[`@media (min-width: ${screenSm}px)`]: {
gridTemplateColumns: '1fr 1fr 1fr',
gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr',
paddingBottom: '0',
rowGap: '5px',
},
},
formItem: {
@ -68,139 +69,199 @@ const useStyles = makeStyles({
textDecoration: 'none',
},
},
acceptPreferences: {
bottom: '-20px',
intercomAlert: {
fontWeight: 'bold',
display: 'flex',
justifyContent: 'center',
padding: '0 0 13px 0',
svg: {
marginRight: '5px',
},
},
intercomImage: {
position: 'fixed',
cursor: 'pointer',
position: 'absolute',
right: '20px',
textDecoration: 'underline',
[`@media (min-width: ${screenSm}px)`]: {
bottom: '-10px',
},
'&:hover': {
textDecoration: 'none',
},
height: '80px',
width: '80px',
bottom: '8px',
right: '10px',
zIndex: '1000',
boxShadow: '1px 2px 10px 0 var(rgba(40, 54, 61, 0.18))',
},
} as any)
const CookiesBanner = () => {
interface CookiesBannerFormProps {
alertMessage: boolean
}
const CookiesBanner = (): ReactElement => {
const classes = useStyles()
const dispatch = useDispatch()
const [showAnalytics, setShowAnalytics] = useState(false)
const [showIntercom, setShowIntercom] = useState(false)
const [localNecessary, setLocalNecessary] = useState(true)
const [localAnalytics, setLocalAnalytics] = useState(false)
const showBanner = useSelector(cookieBannerOpen)
const [localIntercom, setLocalIntercom] = useState(false)
const acceptCookiesHandler = useCallback(async () => {
const newState = {
acceptedNecessary: true,
acceptedAnalytics: !isDesktop,
}
await saveCookie(COOKIES_KEY, newState, 365)
dispatch(openCookieBanner(false))
setShowAnalytics(!isDesktop)
}, [dispatch])
const showBanner = useSelector(cookieBannerOpen)
useEffect(() => {
async function fetchCookiesFromStorage() {
const cookiesState = await loadFromCookie(COOKIES_KEY)
if (cookiesState) {
const { acceptedAnalytics, acceptedNecessary } = cookiesState
if (!cookiesState) {
dispatch(openCookieBanner({ cookieBannerOpen: true }))
} else {
const { acceptedIntercom, acceptedAnalytics, acceptedNecessary } = cookiesState
if (acceptedIntercom === undefined) {
const newState = {
acceptedNecessary,
acceptedAnalytics,
acceptedIntercom: acceptedAnalytics,
}
const expDays = acceptedAnalytics ? 365 : 7
await saveCookie(COOKIES_KEY, newState, expDays)
setLocalIntercom(newState.acceptedIntercom)
setShowIntercom(newState.acceptedIntercom)
} else {
setLocalIntercom(acceptedIntercom)
setShowIntercom(acceptedIntercom)
}
setLocalAnalytics(acceptedAnalytics)
setLocalNecessary(acceptedNecessary)
const openBanner = acceptedNecessary === false || showBanner
dispatch(openCookieBanner(openBanner))
setShowAnalytics(acceptedAnalytics)
} else {
dispatch(openCookieBanner(true))
}
}
fetchCookiesFromStorage()
}, [dispatch, showBanner])
}, [showAnalytics, showIntercom])
useEffect(() => {
if (isDesktop && showBanner) acceptCookiesHandler()
}, [acceptCookiesHandler, showBanner])
const acceptCookiesHandler = async () => {
const newState = {
acceptedNecessary: true,
acceptedAnalytics: !isDesktop,
acceptedIntercom: true,
}
await saveCookie(COOKIES_KEY, newState, 365)
setShowAnalytics(!isDesktop)
setShowIntercom(true)
dispatch(openCookieBanner({ cookieBannerOpen: false }))
}
const closeCookiesBannerHandler = async () => {
const newState = {
acceptedNecessary: true,
acceptedAnalytics: localAnalytics,
acceptedIntercom: localIntercom,
}
const expDays = localAnalytics ? 365 : 7
await saveCookie(COOKIES_KEY, newState, expDays)
setShowAnalytics(localAnalytics)
dispatch(openCookieBanner(false))
setShowIntercom(localIntercom)
if (!localIntercom && isIntercomLoaded()) {
closeIntercom()
}
dispatch(openCookieBanner({ cookieBannerOpen: false }))
}
const cookieBannerContent = (
<div className={classes.container}>
<span
className={cn(classes.acceptPreferences, classes.text)}
onClick={closeCookiesBannerHandler}
onKeyDown={closeCookiesBannerHandler}
role="button"
tabIndex={0}
data-testid="accept-preferences"
>
Accept preferences &gt;
</span>
<div className={classes.content}>
<p className={classes.text}>
We use cookies to give you the best experience and to help improve our website. Please read our{' '}
<Link className={classes.link} to="https://gnosis-safe.io/cookie">
Cookie Policy
</Link>{' '}
for more information. By clicking &quot;Accept all&quot;, you agree to the storing of cookies on your device
to enhance site navigation, analyze site usage and provide customer support.
</p>
<div className={classes.form}>
<div className={classes.formItem}>
<FormControlLabel
checked={localNecessary}
control={<Checkbox disabled />}
disabled
label="Necessary"
name="Necessary"
onChange={() => setLocalNecessary((prev) => !prev)}
value={localNecessary}
/>
</div>
<div className={classes.formItem}>
<FormControlLabel
control={<Checkbox checked={localAnalytics} />}
label="Analytics"
name="Analytics"
onChange={() => setLocalAnalytics((prev) => !prev)}
value={localAnalytics}
/>
</div>
<div className={classes.formItem}>
<Button
color="primary"
component={Link}
minWidth={180}
onClick={() => acceptCookiesHandler()}
variant="outlined"
>
Accept All
</Button>
if (showAnalytics && !isDesktop) {
loadGoogleAnalytics()
}
if (showIntercom) {
loadIntercom()
}
const CookiesBannerForm = (props: CookiesBannerFormProps) => {
const { alertMessage } = props
return (
<div className={classes.container}>
<div className={classes.content}>
{alertMessage && (
<div className={classes.intercomAlert}>
<img src={AlertRedIcon} />
You attempted to open the customer support chat. Please accept the customer support cookie.
</div>
)}
<p className={classes.text}>
We use cookies to provide you with the best experience and to help improve our website and application.
Please read our{' '}
<Link className={classes.link} to="https://gnosis-safe.io/cookie">
Cookie Policy
</Link>{' '}
for more information. By clicking &quot;Accept all&quot;, you agree to the storing of cookies on your device
to enhance site navigation, analyze site usage and provide customer support.
</p>
<div className={classes.form}>
<div className={classes.formItem}>
<FormControlLabel
checked={localNecessary}
control={<Checkbox disabled />}
disabled
label="Necessary"
name="Necessary"
onChange={() => setLocalNecessary((prev) => !prev)}
value={localNecessary}
/>
</div>
<div className={classes.formItem}>
<FormControlLabel
control={<Checkbox checked={localIntercom} />}
label="Customer support"
name="Customer support"
onChange={() => setLocalIntercom((prev) => !prev)}
value={localIntercom}
/>
</div>
<div className={classes.formItem}>
<FormControlLabel
control={<Checkbox checked={localAnalytics} />}
label="Analytics"
name="Analytics"
onChange={() => setLocalAnalytics((prev) => !prev)}
value={localAnalytics}
/>
</div>
<div className={classes.formItem}>
<Button
color="primary"
component={Link}
minWidth={180}
onClick={() => closeCookiesBannerHandler()}
variant="outlined"
>
Accept selection
</Button>
</div>
<div className={classes.formItem}>
<Button
color="primary"
component={Link}
minWidth={180}
onClick={() => acceptCookiesHandler()}
variant="contained"
>
Accept all
</Button>
</div>
</div>
</div>
</div>
</div>
)
if (showAnalytics) {
loadIntercom()
loadGoogleAnalytics()
)
}
if (isDesktop) loadIntercom()
return showBanner && !isDesktop ? cookieBannerContent : null
return (
<>
{!isDesktop && !showIntercom && (
<img
className={classes.intercomImage}
src={IntercomIcon}
onClick={() => dispatch(openCookieBanner({ cookieBannerOpen: true, intercomAlertDisplayed: true }))}
/>
)}
{!isDesktop && showBanner?.cookieBannerOpen && (
<CookiesBannerForm alertMessage={showBanner?.intercomAlertDisplayed} />
)}
</>
)
}
export default CookiesBanner

View File

@ -1,3 +1,4 @@
import { Text } from '@gnosis.pm/safe-react-components'
import React from 'react'
import styled from 'styled-components'
@ -8,16 +9,13 @@ const Wrapper = styled.div`
const Icon = styled.img`
max-width: 15px;
max-height: 15px;
`
const Text = styled.span`
margin-left: 5px;
height: 17px;
margin-right: 9px;
`
const CustomIconText = ({ iconUrl, text }: { iconUrl: string; text?: string }) => (
<Wrapper>
<Icon alt={text} src={iconUrl} />
{text && <Text>{text}</Text>}
{text && <Text size="xl">{text}</Text>}
</Wrapper>
)

View File

@ -0,0 +1,39 @@
import { Loader } from '@gnosis.pm/safe-react-components'
import React, { ReactElement } from 'react'
import { default as ReactInfiniteScroll, Props as ReactInfiniteScrollProps } from 'react-infinite-scroll-component'
import styled from 'styled-components'
import { Overwrite } from 'src/types/helpers'
export const Centered = styled.div<{ padding?: number }>`
width: 100%;
height: 100%;
display: flex;
padding: ${({ padding }) => `${padding}px`};
justify-content: center;
align-items: center;
`
export const SCROLLABLE_TARGET_ID = 'scrollableDiv'
type InfiniteScrollProps = Overwrite<ReactInfiniteScrollProps, { loader?: ReactInfiniteScrollProps['loader'] }>
export const InfiniteScroll = ({ dataLength, next, hasMore, ...props }: InfiniteScrollProps): ReactElement => {
return (
<ReactInfiniteScroll
style={{ overflow: 'hidden' }}
dataLength={dataLength}
next={next}
hasMore={hasMore}
loader={
<Centered>
<Loader size="md" />
</Centered>
}
scrollThreshold="120px"
scrollableTarget={SCROLLABLE_TARGET_ID}
>
{props.children}
</ReactInfiniteScroll>
)
}

View File

@ -9,15 +9,14 @@ const useStyles = makeStyles(
createStyles({
root: {
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
display: 'flex',
overflowY: 'scroll',
},
paper: {
position: 'absolute',
top: '120px',
position: 'relative',
top: '68px',
width: '500px',
height: '580px',
borderRadius: sm,
backgroundColor: '#ffffff',
boxShadow: '0 0 5px 0 rgba(74, 85, 121, 0.5)',
@ -62,7 +61,7 @@ const GnoModal = ({
onClose={handleClose}
open={open}
>
<div className={cn(classes.paper, paperClassName)}>{children}</div>
<div className={cn(classes.paper, paperClassName, 'classpep')}>{children}</div>
</Modal>
)
}

View File

@ -1,8 +1,11 @@
import React from 'react'
import styled from 'styled-components'
import IconButton from '@material-ui/core/IconButton'
import Close from '@material-ui/icons/Close'
import Paragraph from 'src/components/layout/Paragraph'
import { lg } from 'src/theme/variables'
import { md, lg } from 'src/theme/variables'
import Row from 'src/components/layout/Row'
const StyledParagraph = styled(Paragraph)`
&& {
@ -18,14 +21,39 @@ const TitleWrapper = styled.div`
align-items: center;
`
const ModalTitle = ({ iconUrl, title }: { title: string; iconUrl: string }) => {
const StyledRow = styled(Row)`
padding: ${md} ${lg};
justify-content: space-between;
box-sizing: border-box;
max-height: 75px;
`
const StyledClose = styled(Close)`
height: 35px;
width: 35px;
`
const ModalTitle = ({
iconUrl,
title,
onClose,
}: {
title: string
iconUrl: string
onClose?: () => void
}): React.ReactElement => {
return (
<TitleWrapper>
{iconUrl && <IconImg alt={title} src={iconUrl} />}
<StyledParagraph noMargin weight="bolder">
{title}
</StyledParagraph>
</TitleWrapper>
<StyledRow align="center" grow>
<TitleWrapper>
{iconUrl && <IconImg alt={title} src={iconUrl} />}
<StyledParagraph noMargin weight="bolder">
{title}
</StyledParagraph>
</TitleWrapper>
<IconButton disableRipple onClick={onClose}>
<StyledClose />
</IconButton>
</StyledRow>
)
}

View File

@ -3,10 +3,7 @@ import { EstimationStatus } from 'src/logic/hooks/useEstimateTransactionGas'
import Paragraph from 'src/components/layout/Paragraph'
import { getNetworkInfo } from 'src/config'
import { TransactionFailText } from 'src/components/TransactionFailText'
import { useSelector } from 'react-redux'
import { providerNameSelector } from 'src/logic/wallets/store/selectors'
import { sameString } from 'src/utils/strings'
import { WALLETS } from 'src/config/networks/network.d'
import { Text } from '@gnosis.pm/safe-react-components'
type TransactionFailTextProps = {
txEstimationExecutionStatus: EstimationStatus
@ -24,9 +21,10 @@ export const TransactionFees = ({
isOffChainSignature,
txEstimationExecutionStatus,
}: TransactionFailTextProps): React.ReactElement | null => {
const providerName = useSelector(providerNameSelector)
let transactionAction
if (txEstimationExecutionStatus === EstimationStatus.LOADING) {
return null
}
if (isCreation) {
transactionAction = 'create'
} else if (isExecution) {
@ -35,18 +33,20 @@ export const TransactionFees = ({
transactionAction = 'approve'
}
// FIXME this should be removed when estimating with WalletConnect correctly
if (!providerName || sameString(providerName, WALLETS.WALLET_CONNECT)) {
return null
}
return (
<>
<Paragraph>
<Paragraph size="lg" align="center">
You&apos;re about to {transactionAction} a transaction and will have to confirm it with your currently connected
wallet.
{!isOffChainSignature &&
` Make sure you have ${gasCostFormatted} (fee price) ${nativeCoin.name} in this wallet to fund this confirmation.`}
wallet.{' '}
{!isOffChainSignature && (
<>
Make sure you have{' '}
<Text size="lg" as="span" color="text" strong>
{gasCostFormatted}
</Text>{' '}
(fee price) {nativeCoin.name} in this wallet to fund this confirmation.
</>
)}
</Paragraph>
<TransactionFailText txEstimationExecutionStatus={txEstimationExecutionStatus} isExecution={isExecution} />
</>

View File

@ -5,8 +5,8 @@ import { OnChange } from 'react-final-form-listeners'
import TextField from 'src/components/forms/TextField'
import { Validator, composeValidators, mustBeEthereumAddress, required } from 'src/components/forms/validator'
import { trimSpaces } from 'src/utils/strings'
import { getAddressFromENS } from 'src/logic/wallets/getWeb3'
import { isValidEnsName } from 'src/logic/wallets/ethAddresses'
import { getAddressFromDomain } from 'src/logic/wallets/getWeb3'
import { isValidEnsName, isValidCryptoDomainName } from 'src/logic/wallets/ethAddresses'
import { checksumAddress } from 'src/utils/checksumAddress'
// an idea for second field was taken from here
@ -54,9 +54,9 @@ const AddressInput = ({
<OnChange name={name}>
{async (value) => {
const address = trimSpaces(value)
if (isValidEnsName(address)) {
if (isValidEnsName(address) || isValidCryptoDomainName(address)) {
try {
const resolverAddr = await getAddressFromENS(address)
const resolverAddr = await getAddressFromDomain(address)
const formattedAddress = checksumAddress(resolverAddr)
fieldMutator(formattedAddress)
} catch (err) {

View File

@ -1,5 +1,5 @@
import MuiTextField from '@material-ui/core/TextField'
import { withStyles } from '@material-ui/core/styles'
import { createStyles, makeStyles } from '@material-ui/core/styles'
import React from 'react'
import { lg } from 'src/theme/variables'
@ -10,65 +10,93 @@ const overflowStyle = {
width: '100%',
}
const styles = () => ({
root: {
paddingTop: lg,
paddingBottom: '12px',
lineHeight: 0,
},
})
const styles = () =>
createStyles({
root: {
paddingTop: lg,
paddingBottom: '12px',
lineHeight: 0,
},
})
class TextField extends React.PureComponent<any> {
render() {
const {
classes,
input: { name, onChange, value, ...restInput },
inputAdornment,
meta,
multiline,
rows,
testId,
text,
...rest
} = this.props
const helperText = value ? text : undefined
const showError = (meta.touched || !meta.pristine) && !meta.valid
const hasError = !!meta.error || (!meta.modifiedSinceLastSubmit && !!meta.submitError)
const errorMessage = meta.error || meta.submitError
const isInactiveAndPristineOrUntouched = !meta.active && (meta.pristine || !meta.touched)
const isInvalidAndUntouched = typeof meta.error === 'undefined' ? true : !meta.touched
const useStyles = makeStyles(styles)
const disableUnderline = isInactiveAndPristineOrUntouched && isInvalidAndUntouched
const inputRoot = helperText ? classes.root : ''
const statusClasses = meta.valid ? 'isValid' : hasError && showError ? 'isInvalid' : ''
const inputProps = {
...restInput,
autoComplete: 'off',
'data-testid': testId,
}
const inputRootProps = {
...inputAdornment,
className: `${inputRoot} ${statusClasses}`,
disableUnderline: disableUnderline,
}
return (
<MuiTextField
error={hasError && showError}
helperText={hasError && showError ? errorMessage : helperText || ' '}
inputProps={inputProps} // blank in order to force to have helper text
InputProps={inputRootProps}
multiline={multiline}
name={name}
onChange={onChange}
rows={rows}
style={overflowStyle}
value={value}
{...rest}
/>
)
type Props = {
input: {
name: string
onChange?: () => void
value: string
placeholder: string
type: string
}
meta: {
touched?: boolean
pristine?: boolean
valid?: boolean
error?: string
modifiedSinceLastSubmit?: boolean
submitError?: boolean
active?: boolean
}
inputAdornment?: { endAdornment: React.ReactElement } | undefined
multiline: boolean
rows?: string
testId: string
text: string
disabled?: boolean
rowsMax?: number
className?: string
}
export default withStyles(styles as any)(TextField)
const TextField = (props: Props): React.ReactElement => {
const {
input: { name, onChange, value, ...restInput },
inputAdornment,
meta,
multiline,
rows,
testId,
text,
...rest
} = props
const classes = useStyles()
const helperText = value ? text : undefined
const showError = (meta.touched || !meta.pristine) && !meta.valid
const hasError = !!meta.error || (!meta.modifiedSinceLastSubmit && !!meta.submitError)
const errorMessage = meta.error || meta.submitError
const isInactiveAndPristineOrUntouched = !meta.active && (meta.pristine || !meta.touched)
const isInvalidAndUntouched = typeof meta.error === 'undefined' ? true : !meta.touched
const disableUnderline = isInactiveAndPristineOrUntouched && isInvalidAndUntouched
const inputRoot = helperText ? classes.root : ''
const statusClasses = meta.valid ? 'isValid' : hasError && showError ? 'isInvalid' : ''
const inputProps = {
...restInput,
autoComplete: 'off',
'data-testid': testId,
}
const inputRootProps = {
...inputAdornment,
className: `${inputRoot} ${statusClasses}`,
disableUnderline: disableUnderline,
}
return (
<MuiTextField
error={hasError && showError}
helperText={hasError && showError ? errorMessage : helperText || ' '}
inputProps={inputProps} // blank in order to force to have helper text
InputProps={inputRootProps}
multiline={multiline}
name={name}
onChange={onChange}
rows={rows}
style={overflowStyle}
value={value}
{...rest}
/>
)
}
export default TextField

View File

@ -128,7 +128,7 @@ describe('Forms > Validators', () => {
})
describe('mustBeEthereumAddress validator', () => {
const MUST_BE_ETH_ADDRESS_ERR_MSG = 'Address should be a valid Ethereum address or ENS name'
const MUST_BE_ETH_ADDRESS_ERR_MSG = 'Input must be a valid Ethereum address, ENS or Unstoppable domain'
it('Returns undefined for a valid ethereum address', async () => {
expect(await mustBeEthereumAddress('0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe')).toBeUndefined()

View File

@ -42,6 +42,10 @@ export const mustBeUrl = (value: string): ValidatorReturnType => {
}
export const minValue = (min: number | string, inclusive = true) => (value: string): ValidatorReturnType => {
if (!value) {
return undefined
}
if (Number.parseFloat(value) > Number(min) || (inclusive && Number.parseFloat(value) >= Number(min))) {
return undefined
}
@ -64,8 +68,8 @@ export const mustBeEthereumAddress = memoize(
const startsWith0x = address?.startsWith('0x')
const isAddress = getWeb3().utils.isAddress(address)
const errorMessage = `Address should be a valid Ethereum address${
isFeatureEnabled(FEATURES.ENS_LOOKUP) ? ' or ENS name' : ''
const errorMessage = `Input must be a valid Ethereum address${
isFeatureEnabled(FEATURES.DOMAIN_LOOKUP) ? ', ENS or Unstoppable domain' : ''
}`
return startsWith0x && isAddress ? undefined : errorMessage
@ -76,8 +80,8 @@ export const mustBeEthereumContractAddress = memoize(
async (address: string): Promise<ValidatorReturnType> => {
const contractCode = await getWeb3().eth.getCode(address)
const errorMessage = `Address should be a valid Ethereum contract address${
isFeatureEnabled(FEATURES.ENS_LOOKUP) ? ' or ENS name' : ''
const errorMessage = `Input must be a valid Ethereum contract address${
isFeatureEnabled(FEATURES.DOMAIN_LOOKUP) ? ', ENS or Unstoppable domain' : ''
}`
return !contractCode || contractCode.replace('0x', '').replace(/0/g, '') === '' ? errorMessage : undefined

View File

@ -1,23 +1,34 @@
import classNames from 'classnames/bind'
import * as React from 'react'
import React, { MouseEventHandler, CSSProperties, ReactElement, ReactNode } from 'react'
import styles from './index.module.scss'
const cx = classNames.bind(styles)
class Paragraph extends React.PureComponent<any> {
render() {
const { align, children, className, color, dot, noMargin, size, transform, weight, ...props } = this.props
interface Props {
align?: string
children: ReactNode
className?: string
color?: string
dot?: string
noMargin?: boolean
size?: string
transform?: string
weight?: string
onClick?: MouseEventHandler<HTMLParagraphElement>
style?: CSSProperties
}
return (
<p
className={cx(styles.paragraph, className, weight, { noMargin, dot }, size, color, transform, align)}
{...props}
>
{children}
</p>
)
}
const Paragraph = (props: Props): ReactElement => {
const { align, children, className, color, dot, noMargin, size, transform, weight, ...restProps } = props
return (
<p
className={cx(styles.paragraph, className, weight, { noMargin, dot }, size, color, transform, align)}
{...restProps}
>
{children}
</p>
)
}
export default Paragraph

View File

@ -17,6 +17,8 @@ export const getNetworkId = (): ETHEREUM_NETWORK => ETHEREUM_NETWORK[NETWORK]
export const getNetworkName = (): string => ETHEREUM_NETWORK[getNetworkId()]
export const usesInfuraRPC = [ETHEREUM_NETWORK.MAINNET, ETHEREUM_NETWORK.RINKEBY].includes(getNetworkId())
const getCurrentEnvironment = (): string => {
switch (NODE_ENV) {
case 'test': {
@ -66,6 +68,8 @@ const configuration = (): NetworkSpecificConfiguration => {
const getConfig: () => NetworkSpecificConfiguration = ensureOnce(configuration)
export const getClientGatewayUrl = (): string => getConfig().clientGatewayUrl
export const getTxServiceUrl = (): string => getConfig().txServiceUrl
export const getRelayUrl = (): string | undefined => getConfig().relayApiUrl
@ -76,15 +80,13 @@ export const getGasPrice = (): number | undefined => getConfig()?.gasPrice
export const getGasPriceOracle = (): GasPriceOracle | undefined => getConfig()?.gasPriceOracle
export const getRpcServiceUrl = (): string => {
const usesInfuraRPC = [ETHEREUM_NETWORK.MAINNET, ETHEREUM_NETWORK.RINKEBY].includes(getNetworkId())
export const getRpcServiceUrl = (): string =>
usesInfuraRPC ? `${getConfig().rpcServiceUrl}/${INFURA_TOKEN}` : getConfig().rpcServiceUrl
if (usesInfuraRPC) {
return `${getConfig().rpcServiceUrl}/${INFURA_TOKEN}`
}
export const getSafeClientGatewayBaseUrl = (safeAddress: string) => `${getClientGatewayUrl()}/safes/${safeAddress}`
return getConfig().rpcServiceUrl
}
export const getTxDetailsUrl = (clientGatewayTxId: string) =>
`${getClientGatewayUrl()}/transactions/${clientGatewayTxId}`
export const getSafeServiceBaseUrl = (safeAddress: string) => `${getTxServiceUrl()}/safes/${safeAddress}`

View File

@ -4,6 +4,7 @@ import { EnvironmentSettings, ETHEREUM_NETWORK, NetworkConfig, WALLETS } from 's
// @todo (agustin) we need to use fixed gasPrice because the oracle is not working right now and it's returning 0
// once the oracle is fixed we need to remove the fixed value
const baseConfig: EnvironmentSettings = {
clientGatewayUrl: 'https://safe-client.ewc.gnosis.io/v1',
txServiceUrl: 'https://safe-transaction.ewc.gnosis.io/api/v1',
safeAppsUrl: 'https://safe-apps-ewc.staging.gnosisdev.com',
gasPriceOracle: {

View File

@ -2,6 +2,7 @@ import EtherLogo from 'src/config/assets/token_eth.svg'
import { EnvironmentSettings, ETHEREUM_NETWORK, NetworkConfig } from 'src/config/networks/network.d'
const baseConfig: EnvironmentSettings = {
clientGatewayUrl: 'http://localhost:8001/v1',
txServiceUrl: 'http://localhost:8000/api/v1',
relayApiUrl: 'https://safe-relay.staging.gnosisdev.com/api/v1',
safeAppsUrl: 'http://localhost:3002',

View File

@ -2,6 +2,7 @@ import EtherLogo from 'src/config/assets/token_eth.svg'
import { EnvironmentSettings, ETHEREUM_NETWORK, NetworkConfig } from 'src/config/networks/network.d'
const baseConfig: EnvironmentSettings = {
clientGatewayUrl: 'https://safe-client.mainnet.staging.gnosisdev.com/v1',
txServiceUrl: 'https://safe-transaction.mainnet.staging.gnosisdev.com/api/v1',
safeAppsUrl: 'https://safe-apps.dev.gnosisdev.com',
gasPriceOracle: {
@ -25,6 +26,7 @@ const mainnet: NetworkConfig = {
},
production: {
...baseConfig,
clientGatewayUrl: 'https://safe-client.mainnet.gnosis.io/v1',
txServiceUrl: 'https://safe-transaction.mainnet.gnosis.io/api/v1',
safeAppsUrl: 'https://apps.gnosis-safe.io',
},

View File

@ -24,7 +24,7 @@ export enum FEATURES {
ERC1155 = 'ERC1155',
SAFE_APPS = 'SAFE_APPS',
CONTRACT_INTERACTION = 'CONTRACT_INTERACTION',
ENS_LOOKUP = 'ENS_LOOKUP',
DOMAIN_LOOKUP = 'DOMAIN_LOOKUP',
}
type Token = {
@ -85,8 +85,9 @@ type GasPrice =
}
export type EnvironmentSettings = GasPrice & {
clientGatewayUrl: string
txServiceUrl: string
// Shall we keep a reference to the relay?
// TODO: Shall we keep a reference to the relay?
relayApiUrl?: string
safeAppsUrl: string
rpcServiceUrl: string

View File

@ -2,6 +2,7 @@ import EtherLogo from 'src/config/assets/token_eth.svg'
import { EnvironmentSettings, ETHEREUM_NETWORK, NetworkConfig } from 'src/config/networks/network.d'
const baseConfig: EnvironmentSettings = {
clientGatewayUrl: 'https://safe-client.rinkeby.staging.gnosisdev.com/v1',
txServiceUrl: 'https://safe-transaction.staging.gnosisdev.com/api/v1',
safeAppsUrl: 'https://safe-apps.dev.gnosisdev.com',
gasPriceOracle: {
@ -25,6 +26,7 @@ const rinkeby: NetworkConfig = {
},
production: {
...baseConfig,
clientGatewayUrl: 'https://safe-client.rinkeby.gnosis.io/v1',
txServiceUrl: 'https://safe-transaction.rinkeby.gnosis.io/api/v1',
safeAppsUrl: 'https://apps.gnosis-safe.io',
},

View File

@ -2,6 +2,7 @@ import EwcLogo from 'src/config/assets/token_ewc.svg'
import { EnvironmentSettings, ETHEREUM_NETWORK, NetworkConfig, WALLETS } from 'src/config/networks/network.d'
const baseConfig: EnvironmentSettings = {
clientGatewayUrl: 'https://safe-client.volta.gnosis.io/v1',
txServiceUrl: 'https://safe-transaction.volta.gnosis.io/api/v1',
safeAppsUrl: 'https://safe-apps-volta.staging.gnosisdev.com',
gasPriceOracle: {

View File

@ -2,6 +2,7 @@ import xDaiLogo from 'src/config/assets/token_xdai.svg'
import { EnvironmentSettings, ETHEREUM_NETWORK, FEATURES, NetworkConfig, WALLETS } from 'src/config/networks/network.d'
const baseConfig: EnvironmentSettings = {
clientGatewayUrl: 'https://safe-client.xdai.gnosis.io/v1',
txServiceUrl: 'https://safe-transaction.xdai.gnosis.io/api/v1',
safeAppsUrl: 'https://safe-apps-xdai.staging.gnosisdev.com',
gasPrice: 1e9,
@ -51,7 +52,7 @@ const xDai: NetworkConfig = {
WALLETS.AUTHEREUM,
WALLETS.LATTICE,
],
disabledFeatures: [FEATURES.ENS_LOOKUP],
disabledFeatures: [FEATURES.DOMAIN_LOOKUP],
}
export default xDai

View File

@ -26,7 +26,7 @@ Sentry.init({
dsn: SENTRY_DSN,
release: `safe-react@${process.env.REACT_APP_APP_VERSION}`,
integrations: [new Integrations.BrowserTracing()],
sampleRate: 0.2,
sampleRate: 0.01,
})
const root = document.getElementById('root')

View File

@ -9,7 +9,7 @@ type addAddressBookEntryOptions = {
export const addAddressBookEntry = createAction(
ADD_ENTRY,
(entry: AddressBookEntry, options: addAddressBookEntryOptions) => {
(entry: AddressBookEntry, options?: addAddressBookEntryOptions) => {
let notifyEntryUpdate = true
if (options) {
notifyEntryUpdate = options.notifyEntryUpdate

View File

@ -1,11 +1,12 @@
import { handleActions } from 'redux-actions'
import { Action, handleActions } from 'redux-actions'
import { AddressBookState, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { AddressBookEntry, AddressBookState, makeAddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { ADD_ENTRY } from 'src/logic/addressBook/store/actions/addAddressBookEntry'
import { ADD_OR_UPDATE_ENTRY } from 'src/logic/addressBook/store/actions/addOrUpdateAddressBookEntry'
import { LOAD_ADDRESS_BOOK } from 'src/logic/addressBook/store/actions/loadAddressBook'
import { REMOVE_ENTRY } from 'src/logic/addressBook/store/actions/removeAddressBookEntry'
import { UPDATE_ENTRY } from 'src/logic/addressBook/store/actions/updateAddressBookEntry'
import { AppReduxState } from 'src/store'
import { checksumAddress } from 'src/utils/checksumAddress'
import { getValidAddressBookName } from 'src/logic/addressBook/utils'
@ -18,13 +19,19 @@ export const buildAddressBook = (storedAddressBook: AddressBookState): AddressBo
})
}
export default handleActions(
type AddressBookPayload = { addressBook: AddressBookState }
type EntryPayload = { entry: AddressBookEntry }
type RemoveEntryPayload = { entryAddress: string }
type Payloads = AddressBookPayload | EntryPayload | RemoveEntryPayload
export default handleActions<AppReduxState['addressBook'], Payloads>(
{
[LOAD_ADDRESS_BOOK]: (state, action) => {
[LOAD_ADDRESS_BOOK]: (state, action: Action<AddressBookPayload>) => {
const { addressBook } = action.payload
return addressBook
},
[ADD_ENTRY]: (state, action) => {
[ADD_ENTRY]: (state, action: Action<EntryPayload>) => {
const { entry } = action.payload
const entryFound = state.find((oldEntry) => oldEntry.address === entry.address)
@ -34,7 +41,7 @@ export default handleActions(
}
return state
},
[UPDATE_ENTRY]: (state, action) => {
[UPDATE_ENTRY]: (state, action: Action<EntryPayload>) => {
const { entry } = action.payload
const entryIndex = state.findIndex((oldEntry) => oldEntry.address === entry.address)
if (entryIndex >= 0) {
@ -42,13 +49,13 @@ export default handleActions(
}
return state
},
[REMOVE_ENTRY]: (state, action) => {
[REMOVE_ENTRY]: (state, action: Action<RemoveEntryPayload>) => {
const { entryAddress } = action.payload
const entryIndex = state.findIndex((oldEntry) => oldEntry.address === entryAddress)
state.splice(entryIndex, 1)
return state
},
[ADD_OR_UPDATE_ENTRY]: (state, action) => {
[ADD_OR_UPDATE_ENTRY]: (state, action: Action<EntryPayload>) => {
const { entry } = action.payload
// Only updates entries with valid names

View File

@ -1,11 +1,15 @@
import { handleActions } from 'redux-actions'
import { ADD_NFT_ASSETS, ADD_NFT_TOKENS } from 'src/logic/collectibles/store/actions/addCollectibles'
import { NFTAssets, NFTTokens } from 'src/logic/collectibles/sources/collectibles'
import { AppReduxState } from 'src/store'
export const NFT_ASSETS_REDUCER_ID = 'nftAssets'
export const NFT_TOKENS_REDUCER_ID = 'nftTokens'
export const nftAssetReducer = handleActions(
type NFTAssetsPayload = { nftAssets: NFTAssets }
export const nftAssetReducer = handleActions<AppReduxState['nftAssets'], NFTAssetsPayload>(
{
[ADD_NFT_ASSETS]: (state, action) => {
const { nftAssets } = action.payload
@ -16,7 +20,9 @@ export const nftAssetReducer = handleActions(
{},
)
export const nftTokensReducer = handleActions(
type NFTTokensPayload = { nftTokens: NFTTokens }
export const nftTokensReducer = handleActions<AppReduxState['nftTokens'], NFTTokensPayload>(
{
[ADD_NFT_TOKENS]: (state, action) => {
const { nftTokens } = action.payload

View File

@ -1,7 +1,7 @@
import { getNetworkId, getNetworkInfo } from 'src/config'
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
import { nftAssetsListAddressesSelector } from 'src/logic/collectibles/store/selectors'
import { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions'
import { BuildTx, ServiceTx } from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers'
import { TOKEN_TRANSFER_METHODS_NAMES } from 'src/logic/safe/store/models/types/transactions.d'
import { getERC721TokenContract, getStandardTokenContract } from 'src/logic/tokens/store/actions/fetchTokens'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
@ -31,10 +31,10 @@ export const SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH = '42842e0e'
/**
* Verifies that a tx received by the transaction service is an ERC721 token-related transaction
* @param {TxServiceModel} tx
* @param {BuildTx['tx']} tx
* @returns boolean
*/
export const isSendERC721Transaction = (tx: TxServiceModel): boolean => {
export const isSendERC721Transaction = (tx: BuildTx['tx']): boolean => {
let hasERC721Transfer = false
if (tx.dataDecoded && sameString(tx.dataDecoded.method, TOKEN_TRANSFER_METHODS_NAMES.SAFE_TRANSFER_FROM)) {
@ -44,7 +44,7 @@ export const isSendERC721Transaction = (tx: TxServiceModel): boolean => {
// Note: this is only valid with our current case (client rendering), if we move to server side rendering we need to refactor this
const state = store.getState()
const knownAssets = nftAssetsListAddressesSelector(state)
return knownAssets.includes(tx.to) || hasERC721Transfer
return knownAssets.includes((tx as ServiceTx).to) || hasERC721Transfer
}
/**

View File

@ -25,7 +25,7 @@ const decodeInfo = ({ paramsHash, params }: DecodeInfoProps): DataDecoded['param
}))
}
export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null => {
export const decodeParamsFromSafeMethod = (data: string): DataDecoded | undefined => {
const [methodId, paramsHash] = [data.slice(0, 10), data.slice(10)]
const method = SAFE_METHODS_NAMES[methodId]
@ -99,7 +99,7 @@ export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null =>
}
default:
return null
return
}
}
@ -113,7 +113,7 @@ export const isDeleteAllowanceMethod = (data: string): boolean => {
return sameString(SPENDING_LIMIT_METHOD_ID_TO_NAME[methodId], SPENDING_LIMIT_METHODS_NAMES.DELETE_ALLOWANCE)
}
export const decodeParamsFromSpendingLimit = (data: string): DataDecoded | null => {
export const decodeParamsFromSpendingLimit = (data: string): DataDecoded | undefined => {
const [methodId, paramsHash] = [data.slice(0, 10), data.slice(10)]
const method = SPENDING_LIMIT_METHOD_ID_TO_NAME[methodId]
@ -171,7 +171,7 @@ export const decodeParamsFromSpendingLimit = (data: string): DataDecoded | null
}
default:
return null
return
}
}
@ -183,9 +183,9 @@ const isSpendingLimitMethod = (methodId: string): boolean => {
return !!SPENDING_LIMIT_METHOD_ID_TO_NAME[methodId]
}
export const decodeMethods = (data: string | null): DataDecoded | null => {
export const decodeMethods = (data: string | null): DataDecoded | undefined => {
if (!data?.length) {
return null
return
}
const [methodId, paramsHash] = [data.slice(0, 10), data.slice(10)]
@ -226,6 +226,6 @@ export const decodeMethods = (data: string | null): DataDecoded | null => {
}
default:
return null
return
}
}

View File

@ -1,7 +1,7 @@
import { createAction } from 'redux-actions'
import { OpenCookieBannerPayload } from 'src/logic/cookies/store/reducer/cookies'
export const OPEN_COOKIE_BANNER = 'OPEN_COOKIE_BANNER'
export const openCookieBanner = createAction(OPEN_COOKIE_BANNER, (cookieBannerOpen) => ({
cookieBannerOpen,
}))
export const openCookieBanner = createAction<OpenCookieBannerPayload>(OPEN_COOKIE_BANNER)

View File

@ -1,16 +1,17 @@
import { Map } from 'immutable'
import { handleActions } from 'redux-actions'
import { OPEN_COOKIE_BANNER } from 'src/logic/cookies/store/actions/openCookieBanner'
import { AppReduxState } from 'src/store'
export const COOKIES_REDUCER_ID = 'cookies'
export default handleActions(
export type OpenCookieBannerPayload = { cookieBannerOpen: boolean; intercomAlertDisplayed?: boolean }
export default handleActions<AppReduxState['cookies'], OpenCookieBannerPayload>(
{
[OPEN_COOKIE_BANNER]: (state, action) => {
const { cookieBannerOpen } = action.payload
return state.set('cookieBannerOpen', cookieBannerOpen)
const { intercomAlertDisplayed = false, cookieBannerOpen } = action.payload
return state.set('cookieBannerOpen', { intercomAlertDisplayed, cookieBannerOpen })
},
},
Map(),

View File

@ -1,13 +1,18 @@
import { Action } from 'redux-actions'
import { ThunkDispatch } from 'redux-thunk'
import fetchCurrenciesRates from 'src/logic/currencyValues/api/fetchCurrenciesRates'
import { setCurrencyRate } from 'src/logic/currencyValues/store/actions/setCurrencyRate'
import { AVAILABLE_CURRENCIES } from 'src/logic/currencyValues/store/model/currencyValues'
import { Dispatch } from 'redux'
import { CurrencyRatePayload } from 'src/logic/currencyValues/store/reducer/currencyValues'
import { AppReduxState } from 'src/store'
const fetchCurrencyRate = (safeAddress: string, selectedCurrency: string) => async (
dispatch: Dispatch<typeof setCurrencyRate>,
dispatch: ThunkDispatch<AppReduxState, undefined, Action<CurrencyRatePayload>>,
): Promise<void> => {
if (AVAILABLE_CURRENCIES.USD === selectedCurrency) {
return dispatch(setCurrencyRate(safeAddress, 1))
dispatch(setCurrencyRate(safeAddress, 1))
return
}
const selectedCurrencyRateInBaseCurrency: number = await fetchCurrenciesRates(

View File

@ -1,12 +1,14 @@
import { setCurrencyBalances } from 'src/logic/currencyValues/store/actions/setCurrencyBalances'
import { setCurrencyRate } from 'src/logic/currencyValues/store/actions/setCurrencyRate'
import { Action } from 'redux-actions'
import { ThunkDispatch } from 'redux-thunk'
import { setSelectedCurrency } from 'src/logic/currencyValues/store/actions/setSelectedCurrency'
import { AVAILABLE_CURRENCIES } from 'src/logic/currencyValues/store/model/currencyValues'
import { CurrentCurrencyPayload } from 'src/logic/currencyValues/store/reducer/currencyValues'
import { loadSelectedCurrency } from 'src/logic/currencyValues/store/utils/currencyValuesStorage'
import { Dispatch } from 'redux'
import { AppReduxState } from 'src/store'
export const fetchSelectedCurrency = (safeAddress: string) => async (
dispatch: Dispatch<typeof setCurrencyBalances | typeof setSelectedCurrency | typeof setCurrencyRate>,
dispatch: ThunkDispatch<AppReduxState, undefined, Action<CurrentCurrencyPayload>>,
): Promise<void> => {
try {
const storedSelectedCurrency = await loadSelectedCurrency()

View File

@ -1,6 +1,7 @@
import { createAction } from 'redux-actions'
import { Action, createAction } from 'redux-actions'
import { ThunkDispatch } from 'redux-thunk'
import { AnyAction } from 'redux'
import { CurrencyPayloads } from 'src/logic/currencyValues/store/reducer/currencyValues'
import { AppReduxState } from 'src/store'
import fetchCurrencyRate from 'src/logic/currencyValues/store/actions/fetchCurrencyRate'
@ -12,7 +13,7 @@ const setCurrentCurrency = createAction(SET_CURRENT_CURRENCY, (safeAddress: stri
}))
export const setSelectedCurrency = (safeAddress: string, selectedCurrency: string) => (
dispatch: ThunkDispatch<AppReduxState, undefined, AnyAction>,
dispatch: ThunkDispatch<AppReduxState, undefined, Action<CurrencyPayloads>>,
): void => {
dispatch(setCurrentCurrency(safeAddress, selectedCurrency))
dispatch(fetchCurrencyRate(safeAddress, selectedCurrency))

View File

@ -1,10 +1,10 @@
import { Map } from 'immutable'
import { handleActions } from 'redux-actions'
import { Action, handleActions } from 'redux-actions'
import { SET_CURRENCY_BALANCES } from 'src/logic/currencyValues/store/actions/setCurrencyBalances'
import { SET_CURRENCY_RATE } from 'src/logic/currencyValues/store/actions/setCurrencyRate'
import { SET_CURRENT_CURRENCY } from 'src/logic/currencyValues/store/actions/setSelectedCurrency'
import { CurrencyRateValue } from 'src/logic/currencyValues/store/model/currencyValues'
import { BalanceCurrencyList, CurrencyRateValue } from 'src/logic/currencyValues/store/model/currencyValues'
export const CURRENCY_VALUES_KEY = 'currencyValues'
@ -15,19 +15,26 @@ export interface CurrencyReducerMap extends Map<string, any> {
export type CurrencyValuesState = Map<string, CurrencyReducerMap>
export default handleActions(
type CurrencyBasePayload = { safeAddress: string }
export type CurrencyRatePayload = CurrencyBasePayload & { currencyRate: number }
export type CurrencyBalancesPayload = CurrencyBasePayload & { currencyBalances: BalanceCurrencyList }
export type CurrentCurrencyPayload = CurrencyBasePayload & { selectedCurrency: string }
export type CurrencyPayloads = CurrencyRatePayload | CurrencyBalancesPayload | CurrentCurrencyPayload
export default handleActions<CurrencyReducerMap, CurrencyPayloads>(
{
[SET_CURRENCY_RATE]: (state: CurrencyReducerMap, action) => {
[SET_CURRENCY_RATE]: (state, action: Action<CurrencyRatePayload>) => {
const { currencyRate, safeAddress } = action.payload
return state.setIn([safeAddress, 'currencyRate'], currencyRate)
},
[SET_CURRENCY_BALANCES]: (state: CurrencyReducerMap, action) => {
const { currencyBalances, safeAddress } = action.payload
[SET_CURRENCY_BALANCES]: (state, action: Action<CurrencyBalancesPayload>) => {
const { safeAddress, currencyBalances } = action.payload
return state.setIn([safeAddress, 'currencyBalances'], currencyBalances)
},
[SET_CURRENT_CURRENCY]: (state: CurrencyReducerMap, action) => {
[SET_CURRENT_CURRENCY]: (state, action: Action<CurrentCurrencyPayload>) => {
const { safeAddress, selectedCurrency } = action.payload
return state.setIn([safeAddress, 'selectedCurrency'], selectedCurrency)

View File

@ -1,8 +1,9 @@
import { handleActions } from 'redux-actions'
import { Action, handleActions } from 'redux-actions'
import { LOAD_CURRENT_SESSION } from 'src/logic/currentSession/store/actions/loadCurrentSession'
import { UPDATE_VIEWED_SAFES } from 'src/logic/currentSession/store/actions/updateViewedSafes'
import { saveCurrentSessionToStorage } from 'src/logic/currentSession/utils'
import { AppReduxState } from 'src/store'
export const CURRENT_SESSION_REDUCER_ID = 'currentSession'
@ -14,13 +15,15 @@ export const initialState = {
viewedSafes: [],
}
export default handleActions(
type CurrentSessionPayloads = CurrentSessionState | string
export default handleActions<AppReduxState['currentSession'], CurrentSessionPayloads>(
{
[LOAD_CURRENT_SESSION]: (state = initialState, action) => ({
[LOAD_CURRENT_SESSION]: (state = initialState, action: Action<CurrentSessionState>) => ({
...state,
...action.payload,
}),
[UPDATE_VIEWED_SAFES]: (state, action) => {
[UPDATE_VIEWED_SAFES]: (state, action: Action<string>) => {
const safeAddress = action.payload
const viewedSafes = state.viewedSafes
const newState = {

View File

@ -0,0 +1,191 @@
import {
checkIfTxIsApproveAndExecution,
checkIfTxIsCreation,
checkIfTxIsExecution,
} from 'src/logic/hooks/useEstimateTransactionGas'
describe('checkIfTxIsExecution', () => {
const mockedEthAccount = '0x29B1b813b6e84654Ca698ef5d7808E154364900B'
it(`should return true if the safe threshold is 1`, () => {
// given
const threshold = 1
const preApprovingOwner = undefined
const transactionConfirmations = 0
const transactionType = ''
// when
const result = checkIfTxIsExecution(threshold, preApprovingOwner, transactionConfirmations, transactionType)
// then
expect(result).toBe(true)
})
it(`should return true if the safe threshold is reached for the transaction`, () => {
// given
const threshold = 3
const preApprovingOwner = mockedEthAccount
const transactionConfirmations = 3
const transactionType = ''
// when
const result = checkIfTxIsExecution(threshold, preApprovingOwner, transactionConfirmations, transactionType)
// then
expect(result).toBe(true)
})
it(`should return true if the transaction is spendingLimit`, () => {
// given
const threshold = 5
const preApprovingOwner = undefined
const transactionConfirmations = 0
const transactionType = 'spendingLimit'
// when
const result = checkIfTxIsExecution(threshold, preApprovingOwner, transactionConfirmations, transactionType)
// then
expect(result).toBe(true)
})
it(`should return true if the number of confirmations is one bellow the threshold but there is a preApprovingOwner`, () => {
// given
const threshold = 5
const preApprovingOwner = mockedEthAccount
const transactionConfirmations = 4
const transactionType = undefined
// when
const result = checkIfTxIsExecution(threshold, preApprovingOwner, transactionConfirmations, transactionType)
// then
expect(result).toBe(true)
})
it(`should return false if the number of confirmations is one bellow the threshold and there is no preApprovingOwner`, () => {
// given
const threshold = 5
const preApprovingOwner = undefined
const transactionConfirmations = 4
const transactionType = undefined
// when
const result = checkIfTxIsExecution(threshold, preApprovingOwner, transactionConfirmations, transactionType)
// then
expect(result).toBe(false)
})
})
describe('checkIfTxIsCreation', () => {
it(`should return true if there are no confirmations for the transaction and the transaction is not spendingLimit`, () => {
// given
const transactionConfirmations = 0
const transactionType = ''
// when
const result = checkIfTxIsCreation(transactionConfirmations, transactionType)
// then
expect(result).toBe(true)
})
it(`should return false if there are no confirmations for the transaction and the transaction is spendingLimit`, () => {
// given
const transactionConfirmations = 0
const transactionType = 'spendingLimit'
// when
const result = checkIfTxIsCreation(transactionConfirmations, transactionType)
// then
expect(result).toBe(false)
})
it(`should return false if there are confirmations for the transaction`, () => {
// given
const transactionConfirmations = 2
const transactionType = ''
// when
const result = checkIfTxIsCreation(transactionConfirmations, transactionType)
// then
expect(result).toBe(false)
})
})
describe('checkIfTxIsApproveAndExecution', () => {
const mockedEthAccount = '0x29B1b813b6e84654Ca698ef5d7808E154364900B'
it(`should return true if there is only one confirmation left to reach the safe threshold and there is a preApproving account`, () => {
// given
const transactionConfirmations = 2
const safeThreshold = 3
const transactionType = ''
const preApprovingOwner = mockedEthAccount
// when
const result = checkIfTxIsApproveAndExecution(
safeThreshold,
transactionConfirmations,
transactionType,
preApprovingOwner,
)
// then
expect(result).toBe(true)
})
it(`should return false if there is only one confirmation left to reach the safe threshold and but there is no preApproving account`, () => {
// given
const transactionConfirmations = 2
const safeThreshold = 3
const transactionType = ''
// when
const result = checkIfTxIsApproveAndExecution(safeThreshold, transactionConfirmations, transactionType)
// then
expect(result).toBe(false)
})
it(`should return true if the transaction is spendingLimit and there is a preApproving account`, () => {
// given
const transactionConfirmations = 0
const transactionType = 'spendingLimit'
const safeThreshold = 3
const preApprovingOwner = mockedEthAccount
// when
const result = checkIfTxIsApproveAndExecution(
safeThreshold,
transactionConfirmations,
transactionType,
preApprovingOwner,
)
// then
expect(result).toBe(true)
})
it(`should return false if the transaction is spendingLimit and there is no preApproving account`, () => {
// given
const transactionConfirmations = 0
const transactionType = 'spendingLimit'
const safeThreshold = 3
const preApprovingOwner = mockedEthAccount
// when
const result = checkIfTxIsApproveAndExecution(
safeThreshold,
transactionConfirmations,
transactionType,
preApprovingOwner,
)
// then
expect(result).toBe(true)
})
it(`should return false if the are missing more than one confirmations to reach the safe threshold and the transaction is not spendingLimit`, () => {
// given
const transactionConfirmations = 0
const transactionType = ''
const safeThreshold = 3
// when
const result = checkIfTxIsApproveAndExecution(safeThreshold, transactionConfirmations, transactionType)
// then
expect(result).toBe(false)
})
})

View File

@ -1,8 +1,10 @@
import { useEffect, useState } from 'react'
import {
estimateGasForTransactionApproval,
estimateGasForTransactionCreation,
estimateGasForTransactionExecution,
MINIMUM_TRANSACTION_GAS,
} from 'src/logic/safe/transactions/gas'
import { fromTokenUnit } from 'src/logic/tokens/utils/humanReadableValue'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
@ -15,14 +17,14 @@ import {
safeThresholdSelector,
} from 'src/logic/safe/store/selectors'
import { CALL } from 'src/logic/safe/transactions'
import { providerSelector } from '../wallets/store/selectors'
import { web3ReadOnly as web3 } from 'src/logic/wallets/getWeb3'
import { providerSelector } from 'src/logic/wallets/store/selectors'
import { List } from 'immutable'
import { Confirmation } from 'src/logic/safe/store/models/types/confirmation'
import { checkIfOffChainSignatureIsPossible } from 'src/logic/safe/safeTxSigner'
import { ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses'
import { sameString } from 'src/utils/strings'
import { WALLETS } from 'src/config/networks/network.d'
export enum EstimationStatus {
LOADING = 'LOADING',
@ -30,18 +32,37 @@ export enum EstimationStatus {
SUCCESS = 'SUCCESS',
}
const checkIfTxIsExecution = (
export const checkIfTxIsExecution = (
threshold: number,
preApprovingOwner?: string,
txConfirmations?: number,
txType?: string,
): boolean =>
txConfirmations === threshold || !!preApprovingOwner || threshold === 1 || sameString(txType, 'spendingLimit')
): boolean => {
if (threshold === 1 || sameString(txType, 'spendingLimit') || txConfirmations === threshold) {
return true
}
const checkIfTxIsApproveAndExecution = (threshold: number, txConfirmations: number, txType?: string): boolean =>
txConfirmations + 1 === threshold || sameString(txType, 'spendingLimit')
if (preApprovingOwner && txConfirmations) {
return txConfirmations + 1 === threshold
}
const checkIfTxIsCreation = (txConfirmations: number, txType?: string): boolean =>
return false
}
export const checkIfTxIsApproveAndExecution = (
threshold: number,
txConfirmations: number,
txType?: string,
preApprovingOwner?: string,
): boolean => {
if (preApprovingOwner) {
return txConfirmations + 1 === threshold || sameString(txType, 'spendingLimit')
}
return false
}
export const checkIfTxIsCreation = (txConfirmations: number, txType?: string): boolean =>
txConfirmations === 0 && !sameString(txType, 'spendingLimit')
type TransactionEstimationProps = {
@ -80,7 +101,14 @@ const estimateTransactionGas = async ({
approvalAndExecution,
}: TransactionEstimationProps): Promise<number> => {
if (isCreation) {
return estimateGasForTransactionCreation(safeAddress, txData, txRecipient, txAmount || '0', operation || CALL)
return estimateGasForTransactionCreation(
safeAddress,
txData,
txRecipient,
txAmount || '0',
operation || CALL,
safeTxGas,
)
}
if (!from) {
@ -124,14 +152,17 @@ type UseEstimateTransactionGasProps = {
operation?: number
safeTxGas?: number
txType?: string
manualGasPrice?: string
}
type TransactionGasEstimationResult = {
export type TransactionGasEstimationResult = {
txEstimationExecutionStatus: EstimationStatus
gasEstimation: number // Amount of gas needed for execute or approve the transaction
gasCost: string // Cost of gas in raw format (estimatedGas * gasPrice)
gasCostFormatted: string // Cost of gas in format '< | > 100'
gasPrice: string // Current price of gas unit
gasPriceFormatted: string // Current gas price formatted
gasLimit: string // Minimum gas requited to execute the Tx
isExecution: boolean // Returns true if the user will execute the tx or false if it just signs it
isCreation: boolean // Returns true if the transaction is a creation transaction
isOffChainSignature: boolean // Returns true if offChainSignature is available
@ -146,6 +177,7 @@ export const useEstimateTransactionGas = ({
operation,
safeTxGas,
txType,
manualGasPrice,
}: UseEstimateTransactionGasProps): TransactionGasEstimationResult => {
const [gasEstimation, setGasEstimation] = useState<TransactionGasEstimationResult>({
txEstimationExecutionStatus: EstimationStatus.LOADING,
@ -153,6 +185,8 @@ export const useEstimateTransactionGas = ({
gasCost: '0',
gasCostFormatted: '< 0.001',
gasPrice: '0',
gasPriceFormatted: '0',
gasLimit: '0',
isExecution: false,
isCreation: false,
isOffChainSignature: false,
@ -168,14 +202,15 @@ export const useEstimateTransactionGas = ({
if (!txData.length) {
return
}
// FIXME this should be removed when estimating with WalletConnect correctly
if (!providerName || sameString(providerName, WALLETS.WALLET_CONNECT)) {
return null
}
const isExecution = checkIfTxIsExecution(Number(threshold), preApprovingOwner, txConfirmations?.size, txType)
const isCreation = checkIfTxIsCreation(txConfirmations?.size || 0, txType)
const approvalAndExecution = checkIfTxIsApproveAndExecution(Number(threshold), txConfirmations?.size || 0, txType)
const approvalAndExecution = checkIfTxIsApproveAndExecution(
Number(threshold),
txConfirmations?.size || 0,
txType,
preApprovingOwner,
)
try {
const isOffChainSignature = checkIfOffChainSignatureIsPossible(isExecution, smartContractWallet, safeVersion)
@ -194,10 +229,12 @@ export const useEstimateTransactionGas = ({
safeTxGas,
approvalAndExecution,
})
const gasPrice = await calculateGasPrice()
const gasPrice = manualGasPrice ? web3.utils.toWei(manualGasPrice, 'gwei') : await calculateGasPrice()
const gasPriceFormatted = web3.utils.fromWei(gasPrice, 'gwei')
const estimatedGasCosts = gasEstimation * parseInt(gasPrice, 10)
const gasCost = fromTokenUnit(estimatedGasCosts, nativeCoin.decimals)
const gasCostFormatted = formatAmount(gasCost)
const gasLimit = (gasEstimation * 2 + MINIMUM_TRANSACTION_GAS).toString()
let txEstimationExecutionStatus = EstimationStatus.SUCCESS
@ -211,6 +248,8 @@ export const useEstimateTransactionGas = ({
gasCost,
gasCostFormatted,
gasPrice,
gasPriceFormatted,
gasLimit,
isExecution,
isCreation,
isOffChainSignature,
@ -218,7 +257,7 @@ export const useEstimateTransactionGas = ({
} catch (error) {
console.warn(error.message)
// We put a fixed the amount of gas to let the user try to execute the tx, but it's not accurate so it will probably fail
const gasEstimation = 10000
const gasEstimation = MINIMUM_TRANSACTION_GAS
const gasCost = fromTokenUnit(gasEstimation, nativeCoin.decimals)
const gasCostFormatted = formatAmount(gasCost)
setGasEstimation({
@ -227,6 +266,8 @@ export const useEstimateTransactionGas = ({
gasCost,
gasCostFormatted,
gasPrice: '1',
gasPriceFormatted: '1',
gasLimit: '0',
isExecution,
isCreation,
isOffChainSignature: false,
@ -251,6 +292,7 @@ export const useEstimateTransactionGas = ({
safeTxGas,
txType,
providerName,
manualGasPrice,
])
return gasEstimation

View File

@ -22,18 +22,15 @@ const getStandardTxNotificationsQueue = (
origin: string,
): Record<string, Record<string, Notification> | Notification> => ({
beforeExecution: setNotificationOrigin(NOTIFICATIONS.SIGN_TX_MSG, origin),
pendingExecution: setNotificationOrigin(NOTIFICATIONS.TX_PENDING_MSG, origin),
afterRejection: setNotificationOrigin(NOTIFICATIONS.TX_REJECTED_MSG, origin),
afterExecution: {
noMoreConfirmationsNeeded: setNotificationOrigin(NOTIFICATIONS.TX_EXECUTED_MSG, origin),
moreConfirmationsNeeded: setNotificationOrigin(NOTIFICATIONS.TX_EXECUTED_MORE_CONFIRMATIONS_MSG, origin),
},
afterExecutionError: setNotificationOrigin(NOTIFICATIONS.TX_FAILED_MSG, origin),
})
const waitingTransactionNotificationsQueue = {
beforeExecution: null,
pendingExecution: null,
afterRejection: null,
waitingConfirmation: NOTIFICATIONS.TX_WAITING_MSG,
afterExecution: null,
@ -43,7 +40,6 @@ const waitingTransactionNotificationsQueue = {
const getConfirmationTxNotificationsQueue = (origin: string) => {
return {
beforeExecution: setNotificationOrigin(NOTIFICATIONS.SIGN_TX_MSG, origin),
pendingExecution: setNotificationOrigin(NOTIFICATIONS.TX_CONFIRMATION_PENDING_MSG, origin),
afterRejection: setNotificationOrigin(NOTIFICATIONS.TX_REJECTED_MSG, origin),
afterExecution: {
noMoreConfirmationsNeeded: setNotificationOrigin(NOTIFICATIONS.TX_EXECUTED_MSG, origin),
@ -56,7 +52,6 @@ const getConfirmationTxNotificationsQueue = (origin: string) => {
const getCancellationTxNotificationsQueue = (origin: string) => {
return {
beforeExecution: setNotificationOrigin(NOTIFICATIONS.SIGN_TX_MSG, origin),
pendingExecution: setNotificationOrigin(NOTIFICATIONS.TX_PENDING_MSG, origin),
afterRejection: setNotificationOrigin(NOTIFICATIONS.TX_REJECTED_MSG, origin),
afterExecution: {
noMoreConfirmationsNeeded: setNotificationOrigin(NOTIFICATIONS.TX_EXECUTED_MSG, origin),
@ -68,7 +63,6 @@ const getCancellationTxNotificationsQueue = (origin: string) => {
const safeNameChangeNotificationsQueue = {
beforeExecution: null,
pendingExecution: null,
afterRejection: null,
afterExecution: {
noMoreConfirmationsNeeded: NOTIFICATIONS.SAFE_NAME_CHANGED_MSG,
@ -79,7 +73,6 @@ const safeNameChangeNotificationsQueue = {
const ownerNameChangeNotificationsQueue = {
beforeExecution: null,
pendingExecution: null,
afterRejection: null,
afterExecution: {
noMoreConfirmationsNeeded: NOTIFICATIONS.OWNER_NAME_CHANGE_EXECUTED_MSG,
@ -90,7 +83,6 @@ const ownerNameChangeNotificationsQueue = {
const settingsChangeTxNotificationsQueue = {
beforeExecution: NOTIFICATIONS.SIGN_SETTINGS_CHANGE_MSG,
pendingExecution: NOTIFICATIONS.SETTINGS_CHANGE_PENDING_MSG,
afterRejection: NOTIFICATIONS.SETTINGS_CHANGE_REJECTED_MSG,
afterExecution: {
noMoreConfirmationsNeeded: NOTIFICATIONS.SETTINGS_CHANGE_EXECUTED_MSG,
@ -101,7 +93,6 @@ const settingsChangeTxNotificationsQueue = {
const newSpendingLimitTxNotificationsQueue = {
beforeExecution: NOTIFICATIONS.SIGN_NEW_SPENDING_LIMIT_MSG,
pendingExecution: NOTIFICATIONS.NEW_SPENDING_LIMIT_PENDING_MSG,
afterRejection: NOTIFICATIONS.NEW_SPENDING_LIMIT_REJECTED_MSG,
afterExecution: {
noMoreConfirmationsNeeded: NOTIFICATIONS.NEW_SPENDING_LIMIT_EXECUTED_MSG,
@ -112,7 +103,6 @@ const newSpendingLimitTxNotificationsQueue = {
const removeSpendingLimitTxNotificationsQueue = {
beforeExecution: NOTIFICATIONS.SIGN_REMOVE_SPENDING_LIMIT_MSG,
pendingExecution: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_PENDING_MSG,
afterRejection: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_REJECTED_MSG,
afterExecution: {
noMoreConfirmationsNeeded: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_EXECUTED_MSG,
@ -123,18 +113,15 @@ const removeSpendingLimitTxNotificationsQueue = {
const defaultNotificationsQueue = {
beforeExecution: NOTIFICATIONS.SIGN_TX_MSG,
pendingExecution: NOTIFICATIONS.TX_PENDING_MSG,
afterRejection: NOTIFICATIONS.TX_REJECTED_MSG,
afterExecution: {
noMoreConfirmationsNeeded: NOTIFICATIONS.TX_EXECUTED_MSG,
moreConfirmationsNeeded: NOTIFICATIONS.TX_EXECUTED_MORE_CONFIRMATIONS_MSG,
},
afterExecutionError: NOTIFICATIONS.TX_FAILED_MSG,
}
const addressBookNewEntry = {
beforeExecution: null,
pendingExecution: null,
afterRejection: null,
waitingConfirmation: null,
afterExecution: {
@ -146,7 +133,6 @@ const addressBookNewEntry = {
const addressBookEditEntry = {
beforeExecution: null,
pendingExecution: null,
afterRejection: null,
waitingConfirmation: null,
afterExecution: {
@ -158,7 +144,6 @@ const addressBookEditEntry = {
const addressBookDeleteEntry = {
beforeExecution: null,
pendingExecution: null,
afterRejection: null,
waitingConfirmation: null,
afterExecution: {
@ -231,7 +216,7 @@ export const getNotificationsFromTxType: any = (txType, origin) => {
export const enhanceSnackbarForAction = (
notification: Notification,
key?: number | string,
key?: string,
onClick?: () => void,
): Notification => ({
...notification,

View File

@ -15,45 +15,35 @@ export type NotificationId = keyof typeof NOTIFICATION_IDS
export type Notification = {
message: string
options: OptionsObject
key?: number | string
key?: string
dismissed?: boolean
}
const NOTIFICATION_IDS = {
CONNECT_WALLET_MSG: 'CONNECT_WALLET_MSG',
CONNECT_WALLET_READ_MODE_MSG: 'CONNECT_WALLET_READ_MODE_MSG',
WALLET_CONNECTED_MSG: 'WALLET_CONNECTED_MSG',
WALLET_DISCONNECTED_MSG: 'WALLET_DISCONNECTED_MSG',
UNLOCK_WALLET_MSG: 'UNLOCK_WALLET_MSG',
CONNECT_WALLET_ERROR_MSG: 'CONNECT_WALLET_ERROR_MSG',
SIGN_TX_MSG: 'SIGN_TX_MSG',
TX_PENDING_MSG: 'TX_PENDING_MSG',
TX_REJECTED_MSG: 'TX_REJECTED_MSG',
TX_EXECUTED_MSG: 'TX_EXECUTED_MSG',
TX_CANCELLATION_EXECUTED_MSG: 'TX_CANCELLATION_EXECUTED_MSG',
TX_FAILED_MSG: 'TX_FAILED_MSG',
TX_EXECUTED_MORE_CONFIRMATIONS_MSG: 'TX_EXECUTED_MORE_CONFIRMATIONS_MSG',
TX_WAITING_MSG: 'TX_WAITING_MSG',
TX_INCOMING_MSG: 'TX_INCOMING_MSG',
TX_CONFIRMATION_PENDING_MSG: 'TX_CONFIRMATION_PENDING_MSG',
TX_CONFIRMATION_EXECUTED_MSG: 'TX_CONFIRMATION_EXECUTED_MSG',
TX_CONFIRMATION_FAILED_MSG: 'TX_CONFIRMATION_FAILED_MSG',
SAFE_NAME_CHANGED_MSG: 'SAFE_NAME_CHANGED_MSG',
OWNER_NAME_CHANGE_EXECUTED_MSG: 'OWNER_NAME_CHANGE_EXECUTED_MSG',
SIGN_SETTINGS_CHANGE_MSG: 'SIGN_SETTINGS_CHANGE_MSG',
SETTINGS_CHANGE_PENDING_MSG: 'SETTINGS_CHANGE_PENDING_MSG',
SETTINGS_CHANGE_REJECTED_MSG: 'SETTINGS_CHANGE_REJECTED_MSG',
SETTINGS_CHANGE_EXECUTED_MSG: 'SETTINGS_CHANGE_EXECUTED_MSG',
SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG: 'SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG',
SETTINGS_CHANGE_FAILED_MSG: 'SETTINGS_CHANGE_FAILED_MSG',
TESTNET_VERSION_MSG: 'TESTNET_VERSION_MSG',
SIGN_NEW_SPENDING_LIMIT_MSG: 'SIGN_NEW_SPENDING_LIMIT_MSG',
NEW_SPENDING_LIMIT_PENDING_MSG: 'NEW_SPENDING_LIMIT_PENDING_MSG',
NEW_SPENDING_LIMIT_REJECTED_MSG: 'NEW_SPENDING_LIMIT_REJECTED_MSG',
NEW_SPENDING_LIMIT_EXECUTED_MSG: 'NEW_SPENDING_LIMIT_EXECUTED_MSG',
NEW_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG: 'NEW_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG',
NEW_SPENDING_LIMIT_FAILED_MSG: 'NEW_SPENDING_LIMIT_FAILED_MSG',
SIGN_REMOVE_SPENDING_LIMIT_MSG: 'SIGN_REMOVE_SPENDING_LIMIT_MSG',
REMOVE_SPENDING_LIMIT_PENDING_MSG: 'REMOVE_SPENDING_LIMIT_PENDING_MSG',
REMOVE_SPENDING_LIMIT_REJECTED_MSG: 'REMOVE_SPENDING_LIMIT_REJECTED_MSG',
REMOVE_SPENDING_LIMIT_EXECUTED_MSG: 'REMOVE_SPENDING_LIMIT_EXECUTED_MSG',
REMOVE_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG: 'REMOVE_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG',
@ -67,32 +57,6 @@ const NOTIFICATION_IDS = {
export const NOTIFICATIONS: Record<NotificationId, Notification> = {
// Wallet Connection
CONNECT_WALLET_MSG: {
message: 'Please connect wallet to continue',
options: { variant: WARNING, persist: true, preventDuplicate: true },
},
CONNECT_WALLET_READ_MODE_MSG: {
message: 'You are in read-only mode: Please connect wallet',
options: { variant: WARNING, persist: true, preventDuplicate: true },
},
WALLET_CONNECTED_MSG: {
message: 'Wallet connected',
options: {
variant: SUCCESS,
persist: false,
autoHideDuration: shortDuration,
},
},
WALLET_DISCONNECTED_MSG: {
message: 'Wallet disconnected',
key: 'WALLET_DISCONNECTED_MSG',
options: {
variant: SUCCESS,
persist: false,
autoHideDuration: shortDuration,
preventDuplicate: true,
},
},
UNLOCK_WALLET_MSG: {
message: 'Unlock your wallet to connect',
options: { variant: WARNING, persist: true, preventDuplicate: true },
@ -107,62 +71,40 @@ export const NOTIFICATIONS: Record<NotificationId, Notification> = {
message: 'Please sign the transaction',
options: { variant: INFO, persist: true },
},
TX_PENDING_MSG: {
message: 'Transaction pending',
options: { variant: INFO, persist: true },
},
TX_REJECTED_MSG: {
message: 'Transaction rejected',
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
options: { variant: ERROR, persist: false, autoHideDuration: shortDuration },
},
TX_EXECUTED_MSG: {
message: 'Transaction successfully executed',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
options: { variant: SUCCESS, persist: false, autoHideDuration: shortDuration },
},
TX_CANCELLATION_EXECUTED_MSG: {
message: 'Rejection successfully submitted',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
},
TX_EXECUTED_MORE_CONFIRMATIONS_MSG: {
message: 'Transaction successfully created. More confirmations needed to execute',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
options: { variant: SUCCESS, persist: false, autoHideDuration: shortDuration },
},
TX_FAILED_MSG: {
message: 'Transaction failed',
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
options: { variant: ERROR, persist: false, autoHideDuration: shortDuration },
},
TX_WAITING_MSG: {
message: 'A pending transaction requires your confirmation!',
message: 'A transaction requires your confirmation',
key: 'TX_WAITING_MSG',
options: {
variant: WARNING,
persist: true,
preventDuplicate: true,
},
},
TX_INCOMING_MSG: {
message: 'Incoming transfer: ',
key: 'TX_INCOMING_MSG',
options: {
variant: SUCCESS,
persist: false,
autoHideDuration: longDuration,
autoHideDuration: shortDuration,
preventDuplicate: true,
},
},
// Approval Transactions
TX_CONFIRMATION_PENDING_MSG: {
message: 'Confirmation transaction pending',
options: { variant: INFO, persist: true },
},
TX_CONFIRMATION_EXECUTED_MSG: {
message: 'Confirmation transaction was successful',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
options: { variant: SUCCESS, persist: false, autoHideDuration: shortDuration },
},
TX_CONFIRMATION_FAILED_MSG: {
message: 'Confirmation transaction failed',
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
options: { variant: ERROR, persist: false, autoHideDuration: shortDuration },
},
// Safe Name
@ -182,17 +124,13 @@ export const NOTIFICATIONS: Record<NotificationId, Notification> = {
message: 'Please sign the settings change',
options: { variant: INFO, persist: true },
},
SETTINGS_CHANGE_PENDING_MSG: {
message: 'Settings change pending',
options: { variant: INFO, persist: true },
},
SETTINGS_CHANGE_REJECTED_MSG: {
message: 'Settings change rejected',
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
options: { variant: ERROR, persist: false, autoHideDuration: shortDuration },
},
SETTINGS_CHANGE_EXECUTED_MSG: {
message: 'Settings change successfully executed',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
options: { variant: SUCCESS, persist: false, autoHideDuration: shortDuration },
},
SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG: {
message: 'Settings change successfully created. More confirmations needed to execute',
@ -200,7 +138,7 @@ export const NOTIFICATIONS: Record<NotificationId, Notification> = {
},
SETTINGS_CHANGE_FAILED_MSG: {
message: 'Settings change failed',
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
options: { variant: ERROR, persist: false, autoHideDuration: shortDuration },
},
// Spending Limit
@ -208,10 +146,6 @@ export const NOTIFICATIONS: Record<NotificationId, Notification> = {
message: 'Please sign the new Spending Limit',
options: { variant: INFO, persist: true },
},
NEW_SPENDING_LIMIT_PENDING_MSG: {
message: 'New Spending Limit pending',
options: { variant: INFO, persist: true },
},
NEW_SPENDING_LIMIT_REJECTED_MSG: {
message: 'New Spending Limit rejected',
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
@ -232,10 +166,6 @@ export const NOTIFICATIONS: Record<NotificationId, Notification> = {
message: 'Please sign the remove Spending Limit',
options: { variant: INFO, persist: true },
},
REMOVE_SPENDING_LIMIT_PENDING_MSG: {
message: 'Remove Spending Limit pending',
options: { variant: INFO, persist: true },
},
REMOVE_SPENDING_LIMIT_REJECTED_MSG: {
message: 'Remove Spending Limit rejected',
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
@ -256,7 +186,7 @@ export const NOTIFICATIONS: Record<NotificationId, Notification> = {
// Network
TESTNET_VERSION_MSG: {
message: "Testnet Version: Don't send production assets to this Safe",
options: { variant: WARNING, persist: true, preventDuplicate: true },
options: { variant: WARNING, persist: false, preventDuplicate: true, autoHideDuration: longDuration },
},
WRONG_NETWORK_MSG: {
message: `Wrong network: Please use ${getNetworkName()}`,

View File

@ -1,8 +0,0 @@
import { Record } from 'immutable'
export const makeNotification = Record({
key: 0,
message: '',
options: {},
dismissed: false,
})

View File

@ -1,38 +1,55 @@
import { Map } from 'immutable'
import { handleActions } from 'redux-actions'
import { Action, handleActions } from 'redux-actions'
import { Notification } from 'src/logic/notifications/notificationTypes'
import { AppReduxState } from 'src/store'
import { CLOSE_SNACKBAR } from '../actions/closeSnackbar'
import { ENQUEUE_SNACKBAR } from '../actions/enqueueSnackbar'
import { REMOVE_SNACKBAR } from '../actions/removeSnackbar'
import { makeNotification } from 'src/logic/notifications/store/models/notification'
export const NOTIFICATIONS_REDUCER_ID = 'notifications'
export default handleActions(
type CloseSnackBarPayload = { key: string; dismissAll: boolean }
type Payloads = Notification | CloseSnackBarPayload | string
export default handleActions<AppReduxState['notifications'], Payloads>(
{
[ENQUEUE_SNACKBAR]: (state, action) => {
[ENQUEUE_SNACKBAR]: (state, action: Action<Notification>) => {
const notification = action.payload
return state.set(notification.key, makeNotification(notification))
if (!notification.key) {
return state
}
return state.set(notification.key, notification)
},
[CLOSE_SNACKBAR]: (state, action) => {
[CLOSE_SNACKBAR]: (state, action: Action<CloseSnackBarPayload>) => {
const { dismissAll, key } = action.payload
if (key) {
return state.update(key, (prev) => prev?.set('dismissed', true))
if (key && state.get(key)) {
return state.update(key, (notification) => {
if (notification) {
return {
...notification,
dismissed: true,
}
}
return notification
})
}
if (dismissAll) {
return state.withMutations((map) => {
map.forEach((notification, notificationKey) => {
map.set(notificationKey, notification.set('dismissed', true))
map.set(notificationKey, { ...notification, dismissed: true })
})
})
}
return state
},
[REMOVE_SNACKBAR]: (state, action) => {
[REMOVE_SNACKBAR]: (state, action: Action<string>) => {
const key = action.payload
return state.delete(key)

View File

@ -90,7 +90,7 @@ describe('isInnerTransaction', () => {
})
})
describe('isCancelTransaction', () => {
describe.skip('isCancelTransaction', () => {
const safeAddress = '0xdfA693da0D16F5E7E78FdCBeDe8FC6eBEa44f1Cf'
const mockedETHAccount = '0xd76e0B566e218a80F4c96458FE09a322EBAa9aF2'
it('It should return false if given a inner transaction with empty data', () => {

View File

@ -1,6 +1,8 @@
import { getNewTxNonce, shouldExecuteTransaction } from 'src/logic/safe/store/actions/utils'
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
import { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions'
import { getMockedSafeInstance } from 'src/test/utils/safeHelper'
import { NonPayableTransactionObject } from 'src/types/contracts/types'
describe('Store actions utils > getNewTxNonce', () => {
it(`Should return nonce of a last transaction + 1 if passed nonce is less than last transaction or invalid`, async () => {
@ -43,13 +45,12 @@ describe('Store actions utils > getNewTxNonce', () => {
describe('Store actions utils > shouldExecuteTransaction', () => {
it(`should return false if there's a previous tx pending to be executed`, async () => {
// Given
const safeInstance = {
methods: {
getThreshold: () => ({
call: () => Promise.resolve('1'),
}),
},
}
const safeInstance = getMockedSafeInstance({})
safeInstance.methods.getThreshold = () =>
({
call: () => Promise.resolve('1'),
} as NonPayableTransactionObject<string>)
const nonce = '1'
const lastTx = { isExecuted: false } as TxServiceModel
@ -62,13 +63,12 @@ describe('Store actions utils > shouldExecuteTransaction', () => {
it(`should return false if threshold is greater than 1`, async () => {
// Given
const safeInstance = {
methods: {
getThreshold: () => ({
call: () => Promise.resolve('2'),
}),
},
}
const safeInstance = getMockedSafeInstance({})
safeInstance.methods.getThreshold = () =>
({
call: () => Promise.resolve('2'),
} as NonPayableTransactionObject<string>)
const nonce = '1'
const lastTx = { isExecuted: true } as TxServiceModel
@ -81,13 +81,12 @@ describe('Store actions utils > shouldExecuteTransaction', () => {
it(`should return true is threshold is 1 and previous tx is executed`, async () => {
// Given
const safeInstance = {
methods: {
getThreshold: () => ({
call: () => Promise.resolve('1'),
}),
},
}
const safeInstance = getMockedSafeInstance({ nonce: '1' })
safeInstance.methods.getThreshold = () =>
({
call: () => Promise.resolve('1'),
} as NonPayableTransactionObject<string>)
const nonce = '1'
const lastTx = { isExecuted: true } as TxServiceModel

View File

@ -12,7 +12,6 @@ export const buildOwnersFrom = (names: string[], addresses: string[]): List<Safe
return List(owners)
}
export const addOrUpdateSafe = createAction(ADD_OR_UPDATE_SAFE, (safe: SafeRecordProps, loadedFromStorage = false) => ({
export const addOrUpdateSafe = createAction(ADD_OR_UPDATE_SAFE, (safe: SafeRecordProps) => ({
safe,
loadedFromStorage,
}))

View File

@ -2,7 +2,6 @@ import { push } from 'connected-react-router'
import { ThunkAction } from 'redux-thunk'
import { onboardUser } from 'src/components/ConnectButton'
import { decodeMethods } from 'src/logic/contracts/methodIds'
import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { getNotificationsFromTxType } from 'src/logic/notifications'
import {
@ -20,16 +19,7 @@ import { providerSelector } from 'src/logic/wallets/store/selectors'
import { SAFELIST_ADDRESS } from 'src/routes/routes'
import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar'
import closeSnackbarAction from 'src/logic/notifications/store/actions/closeSnackbar'
import {
removeTxFromStore,
storeSignedTx,
storeExecutedTx,
} from 'src/logic/safe/store/actions/transactions/pendingTransactions'
import {
generateSafeTxHash,
mockTransaction,
TxToMock,
} from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers'
import { generateSafeTxHash } from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers'
import { getLastTx, getNewTxNonce, shouldExecuteTransaction } from 'src/logic/safe/store/actions/utils'
import { getErrorMessage } from 'src/test/utils/ethereumErrors'
import fetchTransactions from './transactions/fetchTransactions'
@ -39,6 +29,7 @@ import { PayableTx } from 'src/types/contracts/types.d'
import { AppReduxState } from 'src/store'
import { Dispatch, DispatchReturn } from './types'
import { checkIfOffChainSignatureIsPossible, getPreValidatedSignatures } from 'src/logic/safe/safeTxSigner'
import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
export interface CreateTransactionArgs {
navigateToTransactionsTab?: boolean
@ -51,6 +42,7 @@ export interface CreateTransactionArgs {
txNonce?: number | string
valueInWei: string
safeTxGas?: number
ethParameters?: Pick<TxParameters, 'ethNonce' | 'ethGasLimit' | 'ethGasPriceInGWei'>
}
type CreateTransactionAction = ThunkAction<Promise<void | string>, AppReduxState, DispatchReturn, AnyAction>
@ -58,7 +50,7 @@ type ConfirmEventHandler = (safeTxHash: string) => void
type ErrorEventHandler = () => void
export const METAMASK_REJECT_CONFIRM_TX_ERROR_CODE = 4001
const createTransaction = (
export const createTransaction = (
{
safeAddress,
to,
@ -70,6 +62,7 @@ const createTransaction = (
navigateToTransactionsTab = true,
origin = null,
safeTxGas: safeTxGasArg,
ethParameters,
}: CreateTransactionArgs,
onUserConfirm?: ConfirmEventHandler,
onError?: ErrorEventHandler,
@ -86,7 +79,8 @@ const createTransaction = (
const { account: from, hardwareWallet, smartContractWallet } = providerSelector(state)
const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
const lastTx = await getLastTx(safeAddress)
const nonce = txNonce ? txNonce.toString() : await getNewTxNonce(lastTx, safeInstance)
const nextNonce = await getNewTxNonce(lastTx, safeInstance)
const nonce = txNonce ? txNonce.toString() : nextNonce
const isExecution = await shouldExecuteTransaction(safeInstance, nonce, lastTx)
const safeVersion = await getCurrentSafeVersion(safeInstance)
let safeTxGas
@ -101,8 +95,6 @@ const createTransaction = (
const notificationsQueue = getNotificationsFromTxType(notifiedTransaction, origin)
const beforeExecutionKey = dispatch(enqueueSnackbar(notificationsQueue.beforeExecution))
let pendingExecutionKey
let txHash
const txArgs: TxArgs = {
safeInstance,
@ -127,7 +119,6 @@ const createTransaction = (
if (signature) {
dispatch(closeSnackbarAction({ key: beforeExecutionKey }))
dispatch(enqueueSnackbar(notificationsQueue.afterExecution.moreConfirmationsNeeded))
dispatch(fetchTransactions(safeAddress))
await saveTxToHistory({ ...txArgs, signature, origin })
@ -137,64 +128,36 @@ const createTransaction = (
}
const tx = isExecution ? getExecutionTransaction(txArgs) : getApprovalTransaction(safeInstance, safeTxHash)
const sendParams: PayableTx = { from, value: 0 }
// if not set owner management tests will fail on ganache
if (process.env.NODE_ENV === 'test') {
sendParams.gas = '7000000'
const sendParams: PayableTx = {
from,
value: 0,
gas: ethParameters?.ethGasLimit,
gasPrice: ethParameters?.ethGasPriceInGWei,
nonce: ethParameters?.ethNonce,
}
const txToMock: TxToMock = {
...txArgs,
confirmations: [], // this is used to determine if a tx is pending or not. See `calculateTransactionStatus` helper
value: txArgs.valueInWei,
safeTxHash,
dataDecoded: decodeMethods(txArgs.data),
submissionDate: new Date().toISOString(),
}
const mockedTx = await mockTransaction(txToMock, safeAddress, state)
await tx
.send(sendParams)
.once('transactionHash', async (hash) => {
onUserConfirm?.(safeTxHash)
try {
txHash = hash
dispatch(closeSnackbarAction({ key: beforeExecutionKey }))
pendingExecutionKey = dispatch(enqueueSnackbar(notificationsQueue.pendingExecution))
txHash = hash
dispatch(closeSnackbarAction({ key: beforeExecutionKey }))
await Promise.all([
saveTxToHistory({ ...txArgs, txHash, origin }),
storeSignedTx({ transaction: mockedTx, from, isExecution, safeAddress, dispatch, state }),
])
dispatch(fetchTransactions(safeAddress))
} catch (e) {
removeTxFromStore(mockedTx, safeAddress, dispatch, state)
}
await saveTxToHistory({ ...txArgs, txHash, origin })
dispatch(fetchTransactions(safeAddress))
})
.on('error', (error) => {
dispatch(closeSnackbarAction({ key: pendingExecutionKey }))
removeTxFromStore(mockedTx, safeAddress, dispatch, state)
console.error('Tx error: ', error)
onError?.()
})
.then(async (receipt) => {
if (pendingExecutionKey) {
dispatch(closeSnackbarAction({ key: pendingExecutionKey }))
if (isExecution) {
dispatch(enqueueSnackbar(notificationsQueue.afterExecution.noMoreConfirmationsNeeded))
}
dispatch(
enqueueSnackbar(
isExecution
? notificationsQueue.afterExecution.noMoreConfirmationsNeeded
: notificationsQueue.afterExecution.moreConfirmationsNeeded,
),
)
await storeExecutedTx({ transaction: mockedTx, from, safeAddress, isExecution, receipt, dispatch, state })
dispatch(fetchTransactions(safeAddress))
return receipt.transactionHash
@ -206,10 +169,6 @@ const createTransaction = (
dispatch(closeSnackbarAction({ key: beforeExecutionKey }))
if (pendingExecutionKey) {
dispatch(closeSnackbarAction({ key: pendingExecutionKey }))
}
dispatch(enqueueSnackbar({ key: err.code, message: errorMsg, options: { persist: true, variant: 'error' } }))
if (err.code !== METAMASK_REJECT_CONFIRM_TX_ERROR_CODE) {
@ -223,5 +182,3 @@ const createTransaction = (
return txHash
}
export default createTransaction

View File

@ -0,0 +1,41 @@
import axios, { AxiosResponse } from 'axios'
import { createAction } from 'redux-actions'
import { getTxDetailsUrl } from 'src/config'
import { Dispatch } from 'src/logic/safe/store/actions/types'
import { ExpandedTxDetails, Transaction, TxLocation } from 'src/logic/safe/store/models/types/gateway.d'
import { TransactionDetailsPayload } from 'src/logic/safe/store/reducer/gatewayTransactions'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { getTransactionDetails } from 'src/logic/safe/store/selectors/gatewayTransactions'
import { AppReduxState } from 'src/store'
export const UPDATE_TRANSACTION_DETAILS = 'UPDATE_TRANSACTION_DETAILS'
const updateTransactionDetails = createAction<TransactionDetailsPayload>(UPDATE_TRANSACTION_DETAILS)
export const fetchTransactionDetails = ({
transactionId,
txLocation,
}: {
transactionId: Transaction['id']
txLocation: TxLocation
}) => async (dispatch: Dispatch, getState: () => AppReduxState): Promise<Transaction['txDetails']> => {
const txDetails = getTransactionDetails(getState())({
attributeValue: transactionId,
attributeName: 'id',
txLocation,
})
const safeAddress = safeParamAddressFromStateSelector(getState())
if (txDetails) {
return
}
try {
const url = getTxDetailsUrl(transactionId)
const { data: transactionDetails } = await axios.get<ExpandedTxDetails, AxiosResponse<ExpandedTxDetails>>(url)
dispatch(updateTransactionDetails({ transactionId, txLocation, safeAddress, value: transactionDetails }))
} catch (error) {
console.error(`Failed to retrieve transaction ${transactionId} details`, error.message)
}
}

View File

@ -13,7 +13,7 @@ const loadSafesFromStorage = () => async (dispatch: Dispatch): Promise<void> =>
if (safes) {
Object.values(safes).forEach((safeProps) => {
dispatch(addOrUpdateSafe(buildSafe(safeProps), true))
dispatch(addOrUpdateSafe(buildSafe(safeProps)))
})
}
} catch (err) {

View File

@ -1,3 +1,4 @@
import { List } from 'immutable'
import { AnyAction } from 'redux'
import { ThunkAction } from 'redux-thunk'
@ -17,21 +18,40 @@ import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackb
import closeSnackbarAction from 'src/logic/notifications/store/actions/closeSnackbar'
import fetchSafe from 'src/logic/safe/store/actions/fetchSafe'
import fetchTransactions from 'src/logic/safe/store/actions/transactions/fetchTransactions'
import { mockTransaction, TxToMock } from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers'
import { getLastTx, getNewTxNonce, shouldExecuteTransaction } from 'src/logic/safe/store/actions/utils'
import { AppReduxState } from 'src/store'
import { getErrorMessage } from 'src/test/utils/ethereumErrors'
import { storeExecutedTx, storeSignedTx, storeTx } from 'src/logic/safe/store/actions/transactions/pendingTransactions'
import { Transaction } from 'src/logic/safe/store/models/types/transaction'
import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
import { Dispatch, DispatchReturn } from './types'
import { PayableTx } from 'src/types/contracts/types'
import { updateTransactionStatus } from 'src/logic/safe/store/actions/updateTransactionStatus'
import { Confirmation } from 'src/logic/safe/store/models/types/confirmation'
import { Operation } from 'src/logic/safe/store/models/types/gateway.d'
interface ProcessTransactionArgs {
approveAndExecute: boolean
notifiedTransaction: string
safeAddress: string
tx: Transaction
tx: {
id: string
confirmations: List<Confirmation>
origin: string // json.stringified url, name
to: string
value: string
data: string
operation: Operation
nonce: number
safeTxGas: number
safeTxHash: string
baseGas: number
gasPrice: string
gasToken: string
refundReceiver: string
}
userAddress: string
ethParameters?: Pick<TxParameters, 'ethNonce' | 'ethGasLimit' | 'ethGasPriceInGWei'>
thresholdReached: boolean
}
@ -43,6 +63,7 @@ export const processTransaction = ({
safeAddress,
tx,
userAddress,
ethParameters,
thresholdReached,
}: ProcessTransactionArgs): ProcessTransactionAction => async (
dispatch: Dispatch,
@ -67,14 +88,13 @@ export const processTransaction = ({
const notificationsQueue = getNotificationsFromTxType(notifiedTransaction, tx.origin)
const beforeExecutionKey = dispatch(enqueueSnackbar(notificationsQueue.beforeExecution))
let pendingExecutionKey
let txHash
let transaction
const txArgs = {
...tx.toJS(), // merge the previous tx with new data
...tx, // merge the previous tx with new data
safeInstance,
to: tx.recipient,
to: tx.to,
valueInWei: tx.value,
data: tx.data ?? EMPTY_DATA,
operation: tx.operation,
@ -95,10 +115,10 @@ export const processTransaction = ({
if (signature) {
dispatch(closeSnackbarAction({ key: beforeExecutionKey }))
dispatch(updateTransactionStatus({ txStatus: 'PENDING', safeAddress, nonce: tx.nonce, id: tx.id }))
await saveTxToHistory({ ...txArgs, signature })
// TODO: while we wait for the tx to be stored in the service and later update the tx info
// we should update the tx status in the store to disable owners' action buttons
dispatch(enqueueSnackbar(notificationsQueue.afterExecution.moreConfirmationsNeeded))
dispatch(fetchTransactions(safeAddress))
return
@ -107,59 +127,55 @@ export const processTransaction = ({
transaction = isExecution ? getExecutionTransaction(txArgs) : getApprovalTransaction(safeInstance, tx.safeTxHash)
const sendParams: any = { from, value: 0 }
// if not set owner management tests will fail on ganache
if (process.env.NODE_ENV === 'test') {
sendParams.gas = '7000000'
const sendParams: PayableTx = {
from,
value: 0,
gas: ethParameters?.ethGasLimit,
gasPrice: ethParameters?.ethGasPriceInGWei,
nonce: ethParameters?.ethNonce,
}
const txToMock: TxToMock = {
...txArgs,
value: txArgs.valueInWei,
}
const mockedTx = await mockTransaction(txToMock, safeAddress, state)
await transaction
.send(sendParams)
.once('transactionHash', async (hash: string) => {
txHash = hash
dispatch(closeSnackbarAction({ key: beforeExecutionKey }))
pendingExecutionKey = dispatch(enqueueSnackbar(notificationsQueue.pendingExecution))
dispatch(
updateTransactionStatus({
txStatus: 'PENDING',
safeAddress,
nonce: tx.nonce,
// if we provide the tx ID that sole tx will have the _pending_ status.
// if not, all the txs that share the same nonce will have the _pending_ status.
id: tx.id,
}),
)
try {
await Promise.all([
saveTxToHistory({ ...txArgs, txHash }),
storeSignedTx({ transaction: mockedTx, from, isExecution, safeAddress, dispatch, state }),
])
await saveTxToHistory({ ...txArgs, txHash })
dispatch(fetchTransactions(safeAddress))
} catch (e) {
dispatch(closeSnackbarAction({ key: pendingExecutionKey }))
await storeTx({ transaction: tx, safeAddress, dispatch, state })
console.error(e)
}
})
.on('error', (error) => {
dispatch(closeSnackbarAction({ key: pendingExecutionKey }))
storeTx({ transaction: tx, safeAddress, dispatch, state })
dispatch(
updateTransactionStatus({
txStatus: 'PENDING_FAILED',
safeAddress,
nonce: tx.nonce,
id: tx.id,
}),
)
console.error('Processing transaction error: ', error)
})
.then(async (receipt) => {
if (pendingExecutionKey) {
dispatch(closeSnackbarAction({ key: pendingExecutionKey }))
if (isExecution) {
dispatch(enqueueSnackbar(notificationsQueue.afterExecution.noMoreConfirmationsNeeded))
}
dispatch(
enqueueSnackbar(
isExecution
? notificationsQueue.afterExecution.noMoreConfirmationsNeeded
: notificationsQueue.afterExecution.moreConfirmationsNeeded,
),
)
await storeExecutedTx({ transaction: mockedTx, from, safeAddress, isExecution, receipt, dispatch, state })
dispatch(fetchTransactions(safeAddress))
if (isExecution) {
@ -175,10 +191,14 @@ export const processTransaction = ({
dispatch(closeSnackbarAction({ key: beforeExecutionKey }))
if (pendingExecutionKey) {
dispatch(closeSnackbarAction({ key: pendingExecutionKey }))
}
dispatch(
updateTransactionStatus({
txStatus: 'PENDING_FAILED',
safeAddress,
nonce: tx.nonce,
id: tx.id,
}),
)
dispatch(enqueueSnackbar({ key: err.code, message: errorMsg, options: { persist: true, variant: 'error' } }))
if (txHash) {

View File

@ -1,58 +1,35 @@
import { batch } from 'react-redux'
import { ThunkAction, ThunkDispatch } from 'redux-thunk'
import { ThunkDispatch } from 'redux-thunk'
import { AnyAction } from 'redux'
import { backOff } from 'exponential-backoff'
import { addIncomingTransactions } from 'src/logic/safe/store/actions/addIncomingTransactions'
import { addModuleTransactions } from 'src/logic/safe/store/actions/addModuleTransactions'
import { loadIncomingTransactions } from './loadIncomingTransactions'
import { loadModuleTransactions } from './loadModuleTransactions'
import { loadOutgoingTransactions } from './loadOutgoingTransactions'
import { addOrUpdateCancellationTransactions } from 'src/logic/safe/store/actions/transactions/addOrUpdateCancellationTransactions'
import { addOrUpdateTransactions } from 'src/logic/safe/store/actions/transactions/addOrUpdateTransactions'
import {
addHistoryTransactions,
addQueuedTransactions,
} from 'src/logic/safe/store/actions/transactions/gatewayTransactions'
import { loadHistoryTransactions, loadQueuedTransactions } from './loadGatewayTransactions'
import { AppReduxState } from 'src/store'
const noFunc = () => {}
export default (safeAddress: string): ThunkAction<Promise<void>, AppReduxState, undefined, AnyAction> => async (
export default (safeAddress: string) => async (
dispatch: ThunkDispatch<AppReduxState, undefined, AnyAction>,
): Promise<void> => {
try {
const transactions = await backOff(() => loadOutgoingTransactions(safeAddress))
const [history, queued] = await Promise.allSettled([
loadHistoryTransactions(safeAddress),
loadQueuedTransactions(safeAddress),
])
if (transactions) {
const { cancel, outgoing } = transactions
const updateCancellationTxs = cancel.size
? addOrUpdateCancellationTransactions({ safeAddress, transactions: cancel })
: noFunc
const updateOutgoingTxs = outgoing.size
? addOrUpdateTransactions({
safeAddress,
transactions: outgoing,
})
: noFunc
if (history.status === 'fulfilled') {
const values = history.value
batch(() => {
dispatch(updateCancellationTxs)
dispatch(updateOutgoingTxs)
})
if (values.length) {
dispatch(addHistoryTransactions({ safeAddress, values }))
}
} else {
console.error('Failed to load history transactions', history.reason)
}
const incomingTransactions = await loadIncomingTransactions(safeAddress)
const safeIncomingTxs = incomingTransactions.get(safeAddress)
if (safeIncomingTxs?.size) {
dispatch(addIncomingTransactions(incomingTransactions))
}
const moduleTransactions = await loadModuleTransactions(safeAddress)
if (moduleTransactions.length) {
dispatch(addModuleTransactions({ modules: moduleTransactions, safeAddress }))
}
} catch (error) {
console.log('Error fetching transactions:', error)
if (queued.status === 'fulfilled') {
const values = queued.value
dispatch(addQueuedTransactions({ safeAddress, values }))
} else {
console.error('Failed to load queued transactions', queued.reason)
}
}

View File

@ -0,0 +1,103 @@
import axios, { AxiosResponse } from 'axios'
import { getSafeClientGatewayBaseUrl } from 'src/config'
import { HistoryGatewayResponse, QueuedGatewayResponse } from 'src/logic/safe/store/models/types/gateway'
import { checksumAddress } from 'src/utils/checksumAddress'
/*************/
/* HISTORY */
/*************/
const getHistoryTransactionsUrl = (safeAddress: string): string => {
const address = checksumAddress(safeAddress)
return `${getSafeClientGatewayBaseUrl(address)}/transactions/history/`
}
const historyPointers: { [safeAddress: string]: { next: string | null; previous: string | null } } = {}
/**
* Fetch next page if there is a next pointer for the safeAddress.
* If the fetch was success, updates the pointers.
* @param {string} safeAddress
*/
export const loadPagedHistoryTransactions = async (
safeAddress: string,
): Promise<{ values: HistoryGatewayResponse['results']; next: string | null } | undefined> => {
// if `historyPointers[safeAddress] is `undefined` it means `loadHistoryTransactions` wasn't called
// if `historyPointers[safeAddress].next is `null`, it means it reached the last page in gateway-client
if (!historyPointers[safeAddress]?.next) {
return
}
const {
data: { results, ...pointers },
} = await axios.get<HistoryGatewayResponse, AxiosResponse<HistoryGatewayResponse>>(
historyPointers[safeAddress].next as string,
)
historyPointers[safeAddress] = pointers
return { values: results, next: historyPointers[safeAddress].next }
}
export const loadHistoryTransactions = async (safeAddress: string): Promise<HistoryGatewayResponse['results']> => {
const historyTransactionsUrl = getHistoryTransactionsUrl(safeAddress)
const {
data: { results, ...pointers },
} = await axios.get<HistoryGatewayResponse, AxiosResponse<HistoryGatewayResponse>>(historyTransactionsUrl)
if (!historyPointers[safeAddress]) {
historyPointers[safeAddress] = pointers
}
return results
}
/************/
/* QUEUED */
/************/
const getQueuedTransactionsUrl = (safeAddress: string): string => {
const address = checksumAddress(safeAddress)
return `${getSafeClientGatewayBaseUrl(address)}/transactions/queued/`
}
const queuedPointers: { [safeAddress: string]: { next: string | null; previous: string | null } } = {}
/**
* Fetch next page if there is a next pointer for the safeAddress.
* If the fetch was success, updates the pointers.
* @param {string} safeAddress
*/
export const loadPagedQueuedTransactions = async (
safeAddress: string,
): Promise<{ values: QueuedGatewayResponse['results']; next: string | null } | undefined> => {
// if `queuedPointers[safeAddress] is `undefined` it means `loadHistoryTransactions` wasn't called
// if `queuedPointers[safeAddress].next is `null`, it means it reached the last page in gateway-client
if (!queuedPointers[safeAddress]?.next) {
return
}
const {
data: { results, ...pointers },
} = await axios.get<QueuedGatewayResponse, AxiosResponse<QueuedGatewayResponse>>(
queuedPointers[safeAddress].next as string,
)
queuedPointers[safeAddress] = pointers
return { values: results, next: queuedPointers[safeAddress].next }
}
export const loadQueuedTransactions = async (safeAddress: string): Promise<QueuedGatewayResponse['results']> => {
const queuedTransactionsUrl = getQueuedTransactionsUrl(safeAddress)
const {
data: { results, ...pointers },
} = await axios.get<QueuedGatewayResponse, AxiosResponse<QueuedGatewayResponse>>(queuedTransactionsUrl)
if (!queuedPointers[safeAddress] || queuedPointers[safeAddress].next === null) {
queuedPointers[safeAddress] = pointers
}
return results
}

View File

@ -1,6 +1,7 @@
import { fromJS, List, Map } from 'immutable'
import generateBatchRequests from 'src/logic/contracts/generateBatchRequests'
import { CancellationTransactions } from 'src/logic/safe/store/reducer/cancellationTransactions'
import { web3ReadOnly } from 'src/logic/wallets/getWeb3'
import { PROVIDER_REDUCER_ID } from 'src/logic/wallets/store/reducer/provider'
import { buildTx, isCancelTransaction } from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers'
@ -57,8 +58,8 @@ export type SafeTransactionsType = {
}
export type OutgoingTxs = {
cancellationTxs: Record<number, TxServiceModel>
outgoingTxs: TxServiceModel[]
cancellationTxs: Record<number, TxServiceModel> | CancellationTransactions
outgoingTxs: TxServiceModel[] | List<Transaction>
}
export type BatchProcessTxsProps = OutgoingTxs & {
@ -94,21 +95,25 @@ const extractCancelAndOutgoingTxs = (safeAddress: string, outgoingTxs: TxService
)
}
type BatchRequestReturnValues = [TxServiceModel, string | undefined]
type BatchRequestReturnValues = [TxServiceModel | Transaction, string | undefined]
/**
* Requests Contract's code for all the Contracts the Safe has interacted with
* @param transactions
* @returns {Promise<[Promise<*[]>, Promise<*[]>, Promise<*[]>, Promise<*[]>, Promise<*[]>, Promise<*[]>, Promise<*[]>, Promise<*[]>, Promise<*[]>, Promise<*[]>]>}
*/
const batchRequestContractCode = (transactions: TxServiceModel[]): Promise<BatchRequestReturnValues[]> => {
const batchRequestContractCode = (
transactions: (TxServiceModel | Transaction)[],
): Promise<BatchRequestReturnValues[]> => {
if (!transactions || !Array.isArray(transactions)) {
throw new Error('`transactions` must be provided in order to lookup information')
}
const batch = new web3ReadOnly.BatchRequest()
const whenTxsValues = transactions.map((tx) => {
// this will no longer be used when txs-list-v2 feature is finished
// that's why I'm doing this to move forward
const whenTxsValues = (transactions as any[]).map((tx) => {
return generateBatchRequests<BatchRequestReturnValues>({
abi: [],
address: tx.to,
@ -142,8 +147,8 @@ const batchProcessOutgoingTransactions = async ({
outgoing: Transaction[]
}> => {
// cancellation transactions
const cancelTxsValues = Object.values(cancellationTxs)
const cancellationTxsWithData = cancelTxsValues.length ? await batchRequestContractCode(cancelTxsValues) : []
const cancelTxsValues = List(Object.values(cancellationTxs))
const cancellationTxsWithData = cancelTxsValues.size ? await batchRequestContractCode(cancelTxsValues.toArray()) : []
const cancel = {}
for (const [tx] of cancellationTxsWithData) {
@ -157,7 +162,11 @@ const batchProcessOutgoingTransactions = async ({
}
// outgoing transactions
const outgoingTxsWithData = outgoingTxs.length ? await batchRequestContractCode(outgoingTxs) : []
const outgoingTxsList: List<Transaction | TxServiceModel> =
(outgoingTxs as TxServiceModel[]).length !== undefined
? List(outgoingTxs as TxServiceModel[])
: (outgoingTxs as List<Transaction>)
const outgoingTxsWithData = outgoingTxsList.size ? await batchRequestContractCode(outgoingTxsList.toArray()) : []
const outgoing: Transaction[] = []
for (const [tx] of outgoingTxsWithData) {

View File

@ -0,0 +1,9 @@
import { createAction } from 'redux-actions'
import { HistoryPayload, QueuedPayload } from 'src/logic/safe/store/reducer/gatewayTransactions'
export const ADD_HISTORY_TRANSACTIONS = 'ADD_HISTORY_TRANSACTIONS'
export const addHistoryTransactions = createAction<HistoryPayload>(ADD_HISTORY_TRANSACTIONS)
export const ADD_QUEUED_TRANSACTIONS = 'ADD_QUEUED_TRANSACTIONS'
export const addQueuedTransactions = createAction<QueuedPayload>(ADD_QUEUED_TRANSACTIONS)

View File

@ -15,6 +15,7 @@ import {
TransactionTypeValues,
TxArgs,
RefundParams,
isStoredTransaction,
} from 'src/logic/safe/store/models/types/transaction'
import { AppReduxState, store } from 'src/store'
import {
@ -30,14 +31,14 @@ import {
import { TypedDataUtils } from 'eth-sig-util'
import { ProviderRecord } from 'src/logic/wallets/store/model/provider'
import { SafeRecord } from 'src/logic/safe/store/models/safe'
import { DataDecoded, DecodedParams } from 'src/routes/safe/store/models/types/transactions.d'
import { DecodedParams } from 'src/routes/safe/store/models/types/transactions.d'
import { CALL } from 'src/logic/safe/transactions'
export const isEmptyData = (data?: string | null): boolean => {
return !data || data === EMPTY_DATA
}
export const isInnerTransaction = (tx: TxServiceModel | Transaction, safeAddress: string): boolean => {
export const isInnerTransaction = (tx: BuildTx['tx'] | Transaction, safeAddress: string): boolean => {
let isSameAddress = false
if ((tx as TxServiceModel).to !== undefined) {
@ -49,9 +50,15 @@ export const isInnerTransaction = (tx: TxServiceModel | Transaction, safeAddress
return isSameAddress && Number(tx.value) === 0
}
export const isCancelTransaction = (tx: TxServiceModel, safeAddress: string): boolean => {
if (!sameAddress(tx.to, safeAddress)) {
return false
export const isCancelTransaction = (tx: BuildTx['tx'], safeAddress: string): boolean => {
if (isStoredTransaction(tx)) {
if (!sameAddress(tx.recipient, safeAddress)) {
return false
}
} else {
if (!sameAddress(tx.to, safeAddress)) {
return false
}
}
if (Number(tx.value)) {
@ -89,15 +96,15 @@ export const isPendingTransaction = (tx: Transaction, cancelTx: Transaction): bo
return (!!cancelTx && cancelTx.status === 'pending') || tx.status === 'pending'
}
export const isModifySettingsTransaction = (tx: TxServiceModel, safeAddress: string): boolean => {
export const isModifySettingsTransaction = (tx: BuildTx['tx'], safeAddress: string): boolean => {
return isInnerTransaction(tx, safeAddress) && !isEmptyData(tx.data)
}
export const isMultiSendTransaction = (tx: TxServiceModel): boolean => {
export const isMultiSendTransaction = (tx: BuildTx['tx']): boolean => {
return !isEmptyData(tx.data) && tx.data?.substring(0, 10) === '0x8d80ff0a' && Number(tx.value) === 0
}
export const isUpgradeTransaction = (tx: TxServiceModel): boolean => {
export const isUpgradeTransaction = (tx: BuildTx['tx']): boolean => {
return (
!isEmptyData(tx.data) &&
isMultiSendTransaction(tx) &&
@ -106,11 +113,11 @@ export const isUpgradeTransaction = (tx: TxServiceModel): boolean => {
)
}
export const isOutgoingTransaction = (tx: TxServiceModel, safeAddress: string): boolean => {
return !sameAddress(tx.to, safeAddress) && !isEmptyData(tx.data)
export const isOutgoingTransaction = (tx: BuildTx['tx'], safeAddress?: string): boolean => {
return !sameAddress((tx as ServiceTx).to, safeAddress) && !isEmptyData(tx.data)
}
export const isCustomTransaction = async (tx: TxServiceModel, safeAddress: string): Promise<boolean> => {
export const isCustomTransaction = async (tx: BuildTx['tx'], safeAddress?: string): Promise<boolean> => {
const isOutgoing = isOutgoingTransaction(tx, safeAddress)
const isErc20 = await isSendERC20Transaction(tx)
const isUpgrade = isUpgradeTransaction(tx)
@ -120,7 +127,7 @@ export const isCustomTransaction = async (tx: TxServiceModel, safeAddress: strin
}
export const getRefundParams = async (
tx: TxServiceModel,
tx: BuildTx['tx'],
tokenInfo: (string) => Promise<{ decimals: number; symbol: string } | null>,
): Promise<RefundParams | null> => {
const { nativeCoin } = getNetworkInfo()
@ -155,7 +162,7 @@ export const getRefundParams = async (
return refundParams
}
export const getDecodedParams = (tx: TxServiceModel): DecodedParams | null => {
export const getDecodedParams = (tx: BuildTx['tx']): DecodedParams | null => {
if (tx.dataDecoded) {
return {
[tx.dataDecoded.method]: tx.dataDecoded.parameters.reduce(
@ -170,22 +177,22 @@ export const getDecodedParams = (tx: TxServiceModel): DecodedParams | null => {
return null
}
export const getConfirmations = (tx: TxServiceModel): List<Confirmation> => {
export const getConfirmations = (tx: BuildTx['tx']): List<Confirmation> => {
return List(
tx.confirmations.map((conf) =>
(tx.confirmations as ServiceTx['confirmations'])?.map((conf) =>
makeConfirmation({
owner: conf.owner,
hash: conf.transactionHash,
signature: conf.signature,
}),
),
) ?? [],
)
}
export const isTransactionCancelled = (
tx: TxServiceModel,
outgoingTxs: Array<TxServiceModel>,
cancellationTxs: Record<string, TxServiceModel>,
tx: BuildTx['tx'],
outgoingTxs: BuildTx['outgoingTxs'],
cancellationTxs: BuildTx['cancellationTxs'],
): boolean => {
return (
// not executed
@ -252,8 +259,10 @@ export const calculateTransactionType = (tx: Transaction): TransactionTypeValues
return txType
}
export type ServiceTx = TxServiceModel | TxToMock
export type BuildTx = BatchProcessTxsProps & {
tx: TxServiceModel
tx: ServiceTx | Transaction
}
export const buildTx = async ({
@ -281,19 +290,19 @@ export const buildTx = async ({
let tokenSymbol = nativeCoin.symbol
try {
if (isSendERC20Tx) {
const { decimals, symbol } = await getERC20DecimalsAndSymbol(tx.to)
const { decimals, symbol } = await getERC20DecimalsAndSymbol((tx as ServiceTx).to)
tokenDecimals = decimals
tokenSymbol = symbol
} else if (isSendERC721Tx) {
tokenSymbol = await getERC721Symbol(tx.to)
tokenSymbol = await getERC721Symbol((tx as ServiceTx).to)
}
} catch (err) {
console.log(`Failed to retrieve token data from ${tx.to}`)
console.log(`Failed to retrieve token data from ${(tx as ServiceTx).to}`)
}
const txToStore = makeTransaction({
baseGas: tx.baseGas,
blockNumber: tx.blockNumber,
blockNumber: (tx as ServiceTx).blockNumber,
cancelled: isTxCancelled,
confirmations,
customTx: isCustomTx,
@ -301,23 +310,23 @@ export const buildTx = async ({
dataDecoded: tx.dataDecoded,
decimals: tokenDecimals,
decodedParams,
executionDate: tx.executionDate,
executionTxHash: tx.transactionHash,
executor: tx.executor,
fee: tx.fee,
executionDate: (tx as ServiceTx).executionDate,
executionTxHash: (tx as ServiceTx).transactionHash,
executor: (tx as ServiceTx).executor,
fee: (tx as ServiceTx).fee,
gasPrice: tx.gasPrice,
gasToken: tx.gasToken || ZERO_ADDRESS,
isCancellationTx,
isCollectibleTransfer: isSendERC721Tx,
isExecuted: tx.isExecuted,
isSuccessful: tx.isSuccessful,
isSuccessful: (tx as ServiceTx).isSuccessful,
isTokenTransfer: isSendERC20Tx,
modifySettingsTx: isModifySettingsTx,
multiSendTx: isMultiSendTx,
nonce: tx.nonce,
operation: tx.operation,
origin: tx.origin,
recipient: tx.to,
origin: (tx as ServiceTx).origin,
recipient: (tx as ServiceTx).to,
refundParams,
refundReceiver: tx.refundReceiver || ZERO_ADDRESS,
safeTxGas: tx.safeTxGas,
@ -325,7 +334,7 @@ export const buildTx = async ({
submissionDate: tx.submissionDate,
symbol: tokenSymbol,
upgradeTx: isUpgradeTx,
value: tx.value.toString(),
value: tx.value?.toString(),
})
return txToStore
@ -333,13 +342,7 @@ export const buildTx = async ({
.set('type', calculateTransactionType(txToStore))
}
export type TxToMock = TxArgs & {
confirmations: []
safeTxHash: string
value: string
submissionDate: string
dataDecoded: DataDecoded | null
}
export type TxToMock = TxArgs & Partial<TxServiceModel>
export const mockTransaction = (tx: TxToMock, safeAddress: string, state: AppReduxState): Promise<Transaction> => {
const safe = safeSelector(state)
@ -355,7 +358,7 @@ export const mockTransaction = (tx: TxToMock, safeAddress: string, state: AppRed
currentUser: undefined,
outgoingTxs,
safe,
tx: (tx as unknown) as TxServiceModel,
tx,
})
}
@ -369,7 +372,7 @@ export const updateStoredTransactionsStatus = (dispatch: (any) => void, walletRe
dispatch(
addOrUpdateTransactions({
safeAddress,
transactions: transactions.withMutations((list: any[]) =>
transactions: transactions.withMutations((list) =>
list.map((tx) => tx.set('status', calculateTransactionStatus(tx, safe, walletRecord.account))),
),
}),

View File

@ -0,0 +1,6 @@
import { createAction } from 'redux-actions'
import { TransactionStatusPayload } from 'src/logic/safe/store/reducer/gatewayTransactions'
export const UPDATE_TRANSACTION_STATUS = 'UPDATE_TRANSACTION_STATUS'
export const updateTransactionStatus = createAction<TransactionStatusPayload>(UPDATE_TRANSACTION_STATUS)

View File

@ -26,17 +26,31 @@ export const shouldExecuteTransaction = async (
nonce: string,
lastTx: TxServiceModel | null,
): Promise<boolean> => {
const threshold = await safeInstance.methods.getThreshold().call()
const safeNonce = (await safeInstance.methods.nonce().call()).toString()
const thresholdAsString = await safeInstance.methods.getThreshold().call()
const threshold = Number(thresholdAsString)
// Tx will automatically be executed if and only if the threshold is 1
if (Number.parseInt(threshold) === 1) {
const isFirstTransaction = Number.parseInt(nonce) === 0
// if the previous tx is not executed, it's delayed using the approval mechanisms,
// once the previous tx is executed, the current tx will be available to be executed
// by the user using the exec button.
const canExecuteCurrentTransaction = lastTx && lastTx.isExecuted
// Needs to collect owners signatures
if (threshold > 1) {
return false
}
return isFirstTransaction || !!canExecuteCurrentTransaction
// Allow first tx.
if (Number(nonce) === 0) {
return true
}
// Allow if nonce === safeNonce and threshold === 1
if (nonce === safeNonce) {
return true
}
// If the previous tx is not executed or the different between lastTx.nonce and nonce is > 1
// it's delayed using the approval mechanisms.
// Once the previous tx is executed, the current tx will be available to be executed
// by the user using the exec button.
if (lastTx) {
return lastTx.isExecuted && lastTx.nonce + 1 === Number(nonce)
}
return false

View File

@ -3,14 +3,17 @@ import { push } from 'connected-react-router'
import { NOTIFICATIONS, enhanceSnackbarForAction } from 'src/logic/notifications'
import closeSnackbarAction from 'src/logic/notifications/store/actions/closeSnackbar'
import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar'
import { getAwaitingTransactions } from 'src/logic/safe/transactions/awaitingTransactions'
import {
getAwaitingTransactions,
getAwaitingGatewayTransactions,
} from 'src/logic/safe/transactions/awaitingTransactions'
import { getSafeVersionInfo } from 'src/logic/safe/utils/safeVersion'
import { isUserAnOwner } from 'src/logic/wallets/ethAddresses'
import { userAccountSelector } from 'src/logic/wallets/store/selectors'
import { getIncomingTxAmount } from 'src/routes/safe/components/Transactions/TxsTable/columns'
import { grantedSelector } from 'src/routes/safe/container/selector'
import { ADD_INCOMING_TRANSACTIONS } from 'src/logic/safe/store/actions/addIncomingTransactions'
import { ADD_OR_UPDATE_TRANSACTIONS } from 'src/logic/safe/store/actions/transactions/addOrUpdateTransactions'
import { ADD_QUEUED_TRANSACTIONS } from 'src/logic/safe/store/actions/transactions/gatewayTransactions'
import updateSafe from 'src/logic/safe/store/actions/updateSafe'
import {
safeParamAddressFromStateSelector,
@ -18,10 +21,16 @@ import {
safeCancellationTransactionsSelector,
} from 'src/logic/safe/store/selectors'
import { isTransactionSummary } from 'src/logic/safe/store/models/types/gateway.d'
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
import { ADD_OR_UPDATE_SAFE } from '../actions/addOrUpdateSafe'
const watchedActions = [ADD_OR_UPDATE_TRANSACTIONS, ADD_INCOMING_TRANSACTIONS, ADD_OR_UPDATE_SAFE]
const watchedActions = [
ADD_OR_UPDATE_TRANSACTIONS,
ADD_INCOMING_TRANSACTIONS,
ADD_OR_UPDATE_SAFE,
ADD_QUEUED_TRANSACTIONS,
]
const sendAwaitingTransactionNotification = async (
dispatch,
@ -34,7 +43,7 @@ const sendAwaitingTransactionNotification = async (
if (!dispatch || !safeAddress || !awaitingTxsSubmissionDateList || !notificationKey) {
return
}
if (awaitingTxsSubmissionDateList.size === 0) {
if (awaitingTxsSubmissionDateList.length === 0) {
return
}
@ -48,7 +57,7 @@ const sendAwaitingTransactionNotification = async (
return lastTimeUserLoggedIn ? new Date(submissionDate) > new Date(lastTimeUserLoggedIn) : true
})
if (filteredDuplicatedAwaitingTxList.size === 0) {
if (filteredDuplicatedAwaitingTxList.length === 0) {
return
}
dispatch(
@ -101,40 +110,39 @@ const notificationsMiddleware = (store) => (next) => async (action) => {
break
}
case ADD_QUEUED_TRANSACTIONS: {
const { safeAddress, values } = action.payload
const transactions = values.filter((tx) => isTransactionSummary(tx)).map((item) => item.transaction)
const userAddress: string = userAccountSelector(state)
const awaitingTransactions = getAwaitingGatewayTransactions(transactions, userAddress)
const awaitingTxsSubmissionDateList = awaitingTransactions.map((tx) => tx.timestamp)
const safes = safesMapSelector(state)
const currentSafe = safes.get(safeAddress)
if (!currentSafe || !isUserAnOwner(currentSafe, userAddress) || awaitingTransactions.length === 0) {
break
}
const notificationKey = `${safeAddress}-awaiting`
await sendAwaitingTransactionNotification(
dispatch,
safeAddress,
awaitingTxsSubmissionDateList,
notificationKey,
onNotificationClicked(dispatch, notificationKey, safeAddress),
)
break
}
case ADD_INCOMING_TRANSACTIONS: {
action.payload.forEach((incomingTransactions, safeAddress) => {
const { latestIncomingTxBlock } = state.safes.get('safes').get(safeAddress, {})
const viewedSafes = state.currentSession['viewedSafes']
const recurringUser = viewedSafes?.includes(safeAddress)
const newIncomingTransactions = incomingTransactions.filter((tx) => tx.blockNumber > latestIncomingTxBlock)
const { message, ...TX_INCOMING_MSG } = NOTIFICATIONS.TX_INCOMING_MSG
if (recurringUser) {
if (newIncomingTransactions.size > 3) {
dispatch(
enqueueSnackbar(
enhanceSnackbarForAction({
...TX_INCOMING_MSG,
message: 'Multiple incoming transfers',
}),
),
)
} else {
newIncomingTransactions.forEach((tx) => {
dispatch(
enqueueSnackbar(
enhanceSnackbarForAction({
...TX_INCOMING_MSG,
message: `${message}${getIncomingTxAmount(tx)}`,
}),
),
)
})
}
}
dispatch(
updateSafe({
address: safeAddress,

View File

@ -0,0 +1,384 @@
type TransferDirection = 'INCOMING' | 'OUTGOING'
type Erc20Transfer = {
type: 'ERC20'
tokenAddress: string
tokenName: string | null
tokenSymbol: string | null
logoUri: string | null
decimals: number | null
value: string
}
type Erc721Transfer = {
type: 'ERC721'
tokenAddress: string
tokenId: string
tokenName: string | null
tokenSymbol: string | null
logoUri: string | null
decimals: number | null
value: string
}
type NativeTransfer = {
type: 'ETHER'
value: string
tokenSymbol: string | null
decimals: number | null
}
type TransferInfo = Erc20Transfer | Erc721Transfer | NativeTransfer
type Transfer = {
type: 'Transfer'
sender: string
recipient: string
direction?: TransferDirection
transferInfo: TransferInfo // Polymorphic: Erc20, Erc721, Ether
}
export enum Operation {
CALL,
DELEGATE,
}
type InternalTransaction = {
operation: Operation
to: string
value: number | null
data: string | null
dataDecoded: DataDecoded | null
}
type Parameter = {
name: string
type: string
value: string
valueDecoded: InternalTransaction[] | null
}
type DataDecoded = {
method: string
parameters: Parameter[] | null
}
type SetFallbackHandler = {
type: 'SET_FALLBACK_HANDLER'
handler: string
}
type AddOwner = {
type: 'ADD_OWNER'
owner: string
threshold: number
}
type RemoveOwner = {
type: 'REMOVE_OWNER'
owner: string
threshold: number
}
type SwapOwner = {
type: 'SWAP_OWNER'
oldOwner: string
newOwner: string
}
type ChangeThreshold = {
type: 'CHANGE_THRESHOLD'
threshold: number
}
type ChangeImplementation = {
type: 'CHANGE_IMPLEMENTATION'
implementation: string
}
type EnableModule = {
type: 'ENABLE_MODULE'
module: string
}
type DisableModule = {
type: 'DISABLE_MODULE'
module: string
}
type SettingsInfo =
| SetFallbackHandler
| AddOwner
| RemoveOwner
| SwapOwner
| ChangeThreshold
| ChangeImplementation
| EnableModule
| DisableModule
type SettingsChange = {
type: 'SettingsChange'
dataDecoded: DataDecoded
settingsInfo: SettingsInfo | null
}
type AddressInfo = {
name: string
logoUri: string | null
}
type BaseCustom = {
type: 'Custom'
to: string
dataSize: string
value: string
isCancellation: boolean
toInfo: AddressInfo
}
type Custom = BaseCustom & {
methodName: string | null
}
type MultiSend = BaseCustom & {
methodName: 'multiSend'
actionCount: number
}
type Creation = {
type: 'Creation'
creator: string
transactionHash: string
implementation: string | null
factory: string | null
}
type TransactionStatus =
| 'AWAITING_CONFIRMATIONS'
| 'AWAITING_EXECUTION'
| 'CANCELLED'
| 'FAILED'
| 'SUCCESS'
| 'PENDING'
| 'PENDING_FAILED'
| 'WILL_BE_REPLACED'
type TransactionInfo = Transfer | SettingsChange | Custom | MultiSend | Creation
type ExecutionInfo = {
nonce: number
confirmationsRequired: number
confirmationsSubmitted: number
missingSigners?: string[]
}
type SafeAppInfo = {
name: string
url: string
logoUrl: string
}
type TransactionSummary = {
id: string
timestamp: number
txStatus: TransactionStatus
txInfo: TransactionInfo // Polymorphic: Transfer, SettingsChange, Custom, Creation
executionInfo: ExecutionInfo | null
safeAppInfo: SafeAppInfo | null
}
type TransactionData = {
hexData: string | null
dataDecoded: DataDecoded | null
to: string
value: string | null
operation: Operation
}
type ModuleExecutionDetails = {
type: 'MODULE'
address: string
}
type MultiSigConfirmations = {
signer: string
signature: string | null
}
type TokenType = 'ERC721' | 'ERC20' | 'ETHER'
type TokenInfo = {
tokenType: TokenType
address: string
decimals: number | null
symbol: string
name: string
logoUri: string | null
}
type MultiSigExecutionDetails = {
type: 'MULTISIG'
submittedAt: number
nonce: number
safeTxGas: number
baseGas: number
gasPrice: string
gasToken: string
refundReceiver: string
safeTxHash: string
executor: string | null
signers: string[]
confirmationsRequired: number
confirmations: MultiSigConfirmations[]
gasTokenInfo: TokenInfo | null
}
type DetailedExecutionInfo = ModuleExecutionDetails | MultiSigExecutionDetails
type ExpandedTxDetails = {
executedAt: number
txStatus: TransactionStatus
txInfo: TransactionInfo
txData: TransactionData | null
detailedExecutionInfo: DetailedExecutionInfo | null
txHash: string | null
}
type Transaction = TransactionSummary & {
txDetails?: ExpandedTxDetails
}
type StoreStructure = {
queued: {
next: { [nonce: number]: Transaction[] } // 1 Transaction element
queued: { [nonce: number]: Transaction[] } // n Transaction elements
}
history: { [timestamp: number]: Transaction[] } // n Transaction elements
}
type TxQueuedLocation = 'queued.next' | 'queued.queued'
type TxHistoryLocation = 'history'
type TxLocation = TxHistoryLocation | TxQueuedLocation
type Label = {
type: 'LABEL'
label: 'Next' | 'Queued'
}
type DateLabel = {
type: 'DATE_LABEL'
timestamp: number
}
type ConflictHeader = {
type: 'CONFLICT_HEADER'
nonce: number
}
type TransactionGatewayResult = {
type: 'TRANSACTION'
transaction: TransactionSummary
conflictType: 'HasNext' | 'End' | 'None'
}
type GatewayResponse = {
next: string | null
previous: string | null
}
type HistoryGatewayResult = DateLabel | TransactionGatewayResult
type HistoryGatewayResponse = GatewayResponse & {
results: HistoryGatewayResult[]
}
type QueuedGatewayResult = Label | ConflictHeader | TransactionGatewayResult
type QueuedGatewayResponse = GatewayResponse & {
results: QueuedGatewayResult[]
}
export type TransactionDetails = {
count: number
transactions: Array<[nonce: string, transactions: Transaction[]]>
}
/**
* Helper functions
*/
export const isDateLabel = (value: HistoryGatewayResult): value is DateLabel => {
return value.type === 'DATE_LABEL'
}
export const isLabel = (value: QueuedGatewayResult): value is Label => {
return value.type === 'LABEL'
}
export const isConflictHeader = (value: QueuedGatewayResult): value is ConflictHeader => {
return value.type === 'CONFLICT_HEADER'
}
export const isTransactionSummary = (
value: HistoryGatewayResult | QueuedGatewayResult,
): value is TransactionGatewayResult => {
return value.type === 'TRANSACTION'
}
export const isTransferTxInfo = (value: TransactionInfo): value is Transfer => {
return value.type === 'Transfer'
}
export const isSettingsChangeTxInfo = (value: TransactionInfo): value is SettingsChange => {
return value.type === 'SettingsChange'
}
export const isCustomTxInfo = (value: TransactionInfo): value is Custom => {
return value.type === 'Custom'
}
export const isMultiSendTxInfo = (value: TransactionInfo): value is MultiSend => {
return isCustomTxInfo(value) && value.methodName === 'multiSend'
}
export const isCreationTxInfo = (value: TransactionInfo): value is Creation => {
return value.type === 'Creation'
}
export const isStatusSuccess = (value: Transaction['txStatus']): value is 'SUCCESS' => {
return value === 'SUCCESS'
}
export const isStatusFailed = (value: Transaction['txStatus']): value is 'FAILED' => {
return value === 'FAILED'
}
export const isStatusCancelled = (value: Transaction['txStatus']): value is 'CANCELLED' => {
return value === 'CANCELLED'
}
export const isStatusPending = (value: Transaction['txStatus']): value is 'PENDING' => {
return value === 'PENDING'
}
export const isStatusAwaitingConfirmation = (value: Transaction['txStatus']): value is 'AWAITING_CONFIRMATIONS' => {
return value === 'AWAITING_CONFIRMATIONS'
}
export const isStatusWillBeReplaced = (value: Transaction['txStatus']): value is 'WILL_BE_REPLACED' => {
return value === 'WILL_BE_REPLACED'
}
export const isMultiSigExecutionDetails = (
value: ExpandedTxDetails['detailedExecutionInfo'],
): value is MultiSigExecutionDetails => {
return value?.type === 'MULTISIG'
}
export const isModuleExecutionDetails = (
value: ExpandedTxDetails['detailedExecutionInfo'],
): value is ModuleExecutionDetails => {
return value?.type === 'MODULE'
}

View File

@ -6,6 +6,7 @@ import { Confirmation } from './confirmation'
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
import { DataDecoded, Transfer } from './transactions'
import { DecodedParams } from 'src/routes/safe/store/models/types/transactions.d'
import { BuildTx } from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers'
export enum TransactionTypes {
INCOMING = 'incoming',
@ -89,7 +90,11 @@ export type TransactionProps = {
value: string
}
export type Transaction = RecordOf<TransactionProps>
export type Transaction = RecordOf<TransactionProps> & Readonly<TransactionProps>
export const isStoredTransaction = (tx: BuildTx['tx']): tx is Transaction => {
return typeof (tx as Transaction).recipient !== 'undefined'
}
export type TxArgs = {
baseGas: number

View File

@ -1,18 +1,25 @@
import { Map } from 'immutable'
import { handleActions } from 'redux-actions'
import { Action, handleActions } from 'redux-actions'
import { Transaction } from 'src/logic/safe/store/models/types/transaction'
import { ADD_OR_UPDATE_CANCELLATION_TRANSACTIONS } from 'src/logic/safe/store/actions/transactions/addOrUpdateCancellationTransactions'
import { REMOVE_CANCELLATION_TRANSACTION } from 'src/logic/safe/store/actions/transactions/removeCancellationTransaction'
import { AppReduxState } from 'src/store'
export const CANCELLATION_TRANSACTIONS_REDUCER_ID = 'cancellationTransactions'
export type CancellationTransactions = Map<string, Transaction>
export type CancellationTxState = Map<string, CancellationTransactions>
export default handleActions(
type CancellationTransactionsPayload = { safeAddress: string; transactions: CancellationTransactions }
type CancellationTransactionPayload = { safeAddress: string; transaction: Transaction }
export default handleActions<
AppReduxState['cancellationTransactions'],
CancellationTransactionsPayload | CancellationTransactionPayload
>(
{
[ADD_OR_UPDATE_CANCELLATION_TRANSACTIONS]: (state, action) => {
[ADD_OR_UPDATE_CANCELLATION_TRANSACTIONS]: (state, action: Action<CancellationTransactionsPayload>) => {
const { safeAddress, transactions } = action.payload
if (!safeAddress || !transactions || !transactions.size) {
@ -41,7 +48,7 @@ export default handleActions(
}
})
},
[REMOVE_CANCELLATION_TRANSACTION]: (state, action) => {
[REMOVE_CANCELLATION_TRANSACTION]: (state, action: Action<CancellationTransactionPayload>) => {
const { safeAddress, transaction } = action.payload
if (!safeAddress || !transaction) {

View File

@ -0,0 +1,368 @@
import get from 'lodash.get'
import merge from 'lodash.merge'
import { Action, handleActions } from 'redux-actions'
import {
ADD_HISTORY_TRANSACTIONS,
ADD_QUEUED_TRANSACTIONS,
} from 'src/logic/safe/store/actions/transactions/gatewayTransactions'
import { UPDATE_TRANSACTION_STATUS } from 'src/logic/safe/store/actions/updateTransactionStatus'
import {
HistoryGatewayResponse,
isConflictHeader,
isDateLabel,
isLabel,
isTransactionSummary,
QueuedGatewayResponse,
StoreStructure,
Transaction,
TransactionStatus,
TxLocation,
} from 'src/logic/safe/store/models/types/gateway.d'
import { UPDATE_TRANSACTION_DETAILS } from 'src/logic/safe/store/actions/fetchTransactionDetails'
import { AppReduxState } from 'src/store'
import { getUTCStartOfDate } from 'src/utils/date'
import { sameString } from 'src/utils/strings'
import { sortObject } from 'src/utils/objects'
export const GATEWAY_TRANSACTIONS_ID = 'gatewayTransactions'
type BasePayload = { safeAddress: string; isTail?: boolean }
export type HistoryPayload = BasePayload & { values: HistoryGatewayResponse['results'] }
export type QueuedPayload = BasePayload & { values: QueuedGatewayResponse['results'] }
export type TransactionDetailsPayload = {
safeAddress: string
txLocation: TxLocation
transactionId: string
value: Transaction['txDetails']
}
export type TransactionStatusPayload = {
safeAddress: string
nonce: number
id?: string
txStatus: TransactionStatus
}
type Payload = HistoryPayload | QueuedPayload | TransactionDetailsPayload | TransactionStatusPayload
const findTransactionLocation = (
transactionsGroup: { [p: number]: Transaction[] },
transactionId: string,
): { key: string; index: number } => {
let key
let index
let transactions
for ([key, transactions] of Object.entries(transactionsGroup)) {
index = transactions.findIndex(({ id }) => sameString(id, transactionId))
if (index !== -1) {
break
}
}
return { key, index }
}
export const gatewayTransactions = handleActions<AppReduxState['gatewayTransactions'], Payload>(
{
[ADD_HISTORY_TRANSACTIONS]: (state, action: Action<HistoryPayload>) => {
const { safeAddress, values, isTail = false } = action.payload
const history: StoreStructure['history'] = Object.assign({}, state[safeAddress]?.history)
values.forEach((value) => {
if (isDateLabel(value)) {
// DATE_LABEL is discarded as it's not needed for the current implementation
return
}
if (isTransactionSummary(value)) {
const startOfDate = getUTCStartOfDate(value.transaction.timestamp)
if (typeof history[startOfDate] === 'undefined') {
history[startOfDate] = []
}
const txExist = history[startOfDate].some(({ id }) => sameString(id, value.transaction.id))
if (!txExist) {
history[startOfDate].push(value.transaction)
// pushing a newer transaction to the existing list messes the transactions order
// this happens when most recent transactions are added to the existing txs in the store
history[startOfDate] = history[startOfDate].sort((a, b) => b.timestamp - a.timestamp)
}
return
}
})
return {
// all the safes with their respective states
...state,
// current safe
[safeAddress]: {
// keep queued list
...state[safeAddress],
// extend history list
history: isTail ? history : sortObject(history, 'desc'),
},
}
},
[ADD_QUEUED_TRANSACTIONS]: (state, action: Action<QueuedPayload>) => {
// we're assuming that `next` and `queued` labels will be provided in the first page
// as for usage experience there were no more than 5 transactions competing for the same nonce.
// Thus, given the client-gateway page size of 20, we have plenty of "room" to be provided with
// `next` and `queued` transactions in the first page.
const { safeAddress, values } = action.payload
let next = Object.assign({}, state[safeAddress]?.queued?.next)
const queued = Object.assign({}, state[safeAddress]?.queued?.queued)
let label: 'next' | 'queued' | undefined
values.forEach((value) => {
if (isLabel(value)) {
// we're assuming that the first page will always provide `next` and `queued` labels
label = value.label.toLowerCase() as 'next' | 'queued'
return
}
if (isConflictHeader(value)) {
// conflict header is discarded as it's not needed for the current implementation
return
}
if (isTransactionSummary(value)) {
const txNonce = value.transaction.executionInfo?.nonce
if (typeof txNonce === 'undefined') {
console.warn('A transaction without nonce was provided by client-gateway:', JSON.stringify(value))
return
}
if (typeof label === 'undefined') {
label = next[txNonce] ? 'next' : 'queued'
}
switch (label) {
case 'next': {
if (next[txNonce]) {
const txIndex = next[txNonce].findIndex(({ id }) => sameString(id, value.transaction.id))
if (txIndex !== -1) {
const storedTransaction = next[txNonce][txIndex]
const updateFromService =
storedTransaction.executionInfo?.confirmationsSubmitted !==
value.transaction.executionInfo?.confirmationsSubmitted
if (storedTransaction.txStatus === 'PENDING' && !updateFromService) {
// we're waiting for a tx resolution. Thus, we'll prioritize 'PENDING' status
value.transaction.txStatus = 'PENDING'
}
next[txNonce][txIndex] = updateFromService
? // by replacing the current transaction with the one returned by the service
// we remove the `txDetails`, so this will force a re-request of the data
value.transaction
: // we merge, to keep the current unchanged information
merge(storedTransaction, value.transaction)
break
}
// we add the transaction returned by the service to the list of transactions
next[txNonce] = [...next[txNonce], value.transaction]
break
}
// a new tx has arrived to the `next` queue
// we re-create the `next` object with the new transaction
next = { [txNonce]: [value.transaction] }
// we remove the new `next` transaction from the `queue` list, if it exist
queued[txNonce] && delete queued[txNonce]
break
}
case 'queued': {
if (queued[txNonce]) {
const txIndex = queued[txNonce].findIndex(({ id }) => sameString(id, value.transaction.id))
if (txIndex !== -1) {
const storedTransaction = queued[txNonce][txIndex]
const updateFromService =
storedTransaction.executionInfo?.confirmationsSubmitted !==
value.transaction.executionInfo?.confirmationsSubmitted
if (storedTransaction.txStatus === 'PENDING' && !updateFromService) {
// we're waiting for a tx resolution. Thus, we'll prioritize 'PENDING' status
value.transaction.txStatus = 'PENDING'
}
queued[txNonce][txIndex] = updateFromService
? // by replacing the current transaction with the one returned by the service
// we remove the `txDetails`, so this will force a re-request of the data
value.transaction
: // we merge, to keep the current unchanged information
merge(storedTransaction, value.transaction)
break
}
// we add the transaction returned by the service to the list of transactions
queued[txNonce] = [...queued[txNonce], value.transaction]
break
}
queued[txNonce] = [value.transaction]
break
}
}
return
}
})
// no new transactions
if (!values.length) {
// queued list already empty
if (!Object.keys(queued).length) {
// there was an existing next transaction
if (Object.keys(next).length === 1) {
// we cleanup the next queue
next = {}
}
}
}
return {
// all the safes with their respective states
...state,
// current safe
[safeAddress]: {
// keep history list
...state[safeAddress],
// overwrites queued lists
queued: {
next,
queued,
},
},
}
},
[UPDATE_TRANSACTION_DETAILS]: (state, action: Action<TransactionDetailsPayload>) => {
const { safeAddress, transactionId, txLocation, value } = action.payload
const storedTransactions = Object.assign({}, state[safeAddress])
const { queued } = storedTransactions
let { history } = storedTransactions
// get the tx group (it will be `queued.next`, `queued.queued` or `history`)
const txGroup: StoreStructure['queued']['next' | 'queued'] | StoreStructure['history'] = get(
storedTransactions,
txLocation,
)
// find the transaction location
const { key, index } = findTransactionLocation(txGroup, transactionId)
// add details to tx object
txGroup[key][index]['txDetails'] = value
// replace the updated group in its corresponding location
switch (txLocation) {
case 'history':
history = txGroup
break
case 'queued.next':
queued['next'] = txGroup
break
case 'queued.queued':
queued['queued'] = txGroup
break
}
// update state
return {
// all the safes with their respective states
...state,
// current safe
[safeAddress]: {
history,
queued,
},
}
},
[UPDATE_TRANSACTION_STATUS]: (state, action: Action<TransactionStatusPayload>) => {
// if we provide the tx ID that sole tx will have the _pending_ status.
// if not, all the txs that share the same nonce will have the _pending_ status.
const { nonce, id, safeAddress, txStatus } = action.payload
const storedTransactions = Object.assign({}, state[safeAddress])
const { queued } = storedTransactions
const { history } = storedTransactions
let txLocation: TxLocation | undefined
let historyLocation: string | undefined
if (queued.next[nonce]) {
txLocation = 'queued.next'
} else if (queued.queued[nonce]) {
txLocation = 'queued.queued'
} else {
Object.entries(history).forEach(([timestamp, transactions]) => {
const txIndex = transactions.findIndex((transaction) => Number(transaction.executionInfo?.nonce) === nonce)
if (txIndex !== -1) {
txLocation = 'history'
historyLocation = `${timestamp}[${txIndex}]`
}
})
}
if (!txLocation) {
return state
}
switch (txLocation) {
case 'history': {
if (historyLocation) {
const txToUpdate = get(history, historyLocation)
txToUpdate.txStatus = txStatus
}
break
}
case 'queued.next': {
queued.next[nonce] = queued.next[nonce].map((txToUpdate) => {
if (typeof id !== 'undefined') {
if (sameString(txToUpdate.id, id)) {
txToUpdate.txStatus = txStatus
}
} else {
txToUpdate.txStatus = txStatus
}
return txToUpdate
})
break
}
case 'queued.queued': {
queued.queued[nonce] = queued.queued[nonce].map((txToUpdate) => {
if (typeof id !== 'undefined') {
if (sameString(txToUpdate.id, id)) {
txToUpdate.txStatus = txStatus
}
} else {
txToUpdate.txStatus = txStatus
}
return txToUpdate
})
break
}
}
// update state
return {
// all the safes with their respective states
...state,
// current safe
[safeAddress]: {
history,
queued,
},
}
},
},
{},
)

View File

@ -2,10 +2,11 @@ import { Map } from 'immutable'
import { handleActions } from 'redux-actions'
import { ADD_INCOMING_TRANSACTIONS } from 'src/logic/safe/store/actions/addIncomingTransactions'
import { AppReduxState } from 'src/store'
export const INCOMING_TRANSACTIONS_REDUCER_ID = 'incomingTransactions'
export default handleActions(
export default handleActions<AppReduxState['incomingTransactions']>(
{
[ADD_INCOMING_TRANSACTIONS]: (state, action) => action.payload,
},

View File

@ -1,5 +1,5 @@
import { Map, Set, List } from 'immutable'
import { handleActions } from 'redux-actions'
import { Action, handleActions } from 'redux-actions'
import { ACTIVATE_TOKEN_FOR_ALL_SAFES } from 'src/logic/safe/store/actions/activateTokenForAllSafes'
import { ADD_SAFE_OWNER } from 'src/logic/safe/store/actions/addSafeOwner'
@ -13,9 +13,9 @@ import { UPDATE_SAFE } from 'src/logic/safe/store/actions/updateSafe'
import { UPDATE_TOKENS_LIST } from 'src/logic/safe/store/actions/updateTokensList'
import { UPDATE_ASSETS_LIST } from 'src/logic/safe/store/actions/updateAssetsList'
import { makeOwner } from 'src/logic/safe/store/models/owner'
import makeSafe, { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import makeSafe, { SafeRecord, SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { AppReduxState } from 'src/store'
import { checksumAddress } from 'src/utils/checksumAddress'
import { SafeReducerMap } from 'src/routes/safe/store/reducer/types/safe'
import { ADD_OR_UPDATE_SAFE, buildOwnersFrom } from 'src/logic/safe/store/actions/addOrUpdateSafe'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { shouldSafeStoreBeUpdated } from 'src/logic/safe/utils/shouldSafeStoreBeUpdated'
@ -73,9 +73,22 @@ const updateSafeProps = (prevSafe, safe) => {
})
}
export default handleActions(
export type SafePayload = { safe: SafeRecord }
type SafePayloads = SafeRecord | SafePayload | string
type BaseOwnerPayload = { safeAddress: string; ownerAddress: string }
type FullOwnerPayload = BaseOwnerPayload & { ownerName: string }
type ReplaceOwnerPayload = FullOwnerPayload & { oldOwnerAddress: string }
type OwnerPayloads = BaseOwnerPayload | FullOwnerPayload | ReplaceOwnerPayload
type SafeWithAddressPayload = SafeRecord & { safeAddress: string }
type Payloads = SafePayloads | OwnerPayloads | SafeWithAddressPayload
export default handleActions<AppReduxState['safes'], Payloads>(
{
[UPDATE_SAFE]: (state: SafeReducerMap, action) => {
[UPDATE_SAFE]: (state, action: Action<SafeRecord>) => {
const safe = action.payload
const safeAddress = safe.address
@ -89,7 +102,7 @@ export default handleActions(
)
: state
},
[ACTIVATE_TOKEN_FOR_ALL_SAFES]: (state: SafeReducerMap, action) => {
[ACTIVATE_TOKEN_FOR_ALL_SAFES]: (state, action: Action<SafeRecord>) => {
const tokenAddress = action.payload
return state.withMutations((map) => {
@ -104,8 +117,7 @@ export default handleActions(
})
})
},
[ADD_OR_UPDATE_SAFE]: (state: SafeReducerMap, action) => {
[ADD_OR_UPDATE_SAFE]: (state, action: Action<SafePayload>) => {
const { safe } = action.payload
const safeAddress = safe.address
@ -123,7 +135,7 @@ export default handleActions(
)
: state
},
[REMOVE_SAFE]: (state: SafeReducerMap, action) => {
[REMOVE_SAFE]: (state, action: Action<string>) => {
const safeAddress = action.payload
const currentDefaultSafe = state.get('defaultSafe')
@ -135,7 +147,7 @@ export default handleActions(
return newState
},
[ADD_SAFE_OWNER]: (state: SafeReducerMap, action) => {
[ADD_SAFE_OWNER]: (state, action: Action<FullOwnerPayload>) => {
const { ownerAddress, ownerName, safeAddress } = action.payload
const addressFound = state
@ -152,7 +164,7 @@ export default handleActions(
}),
)
},
[REMOVE_SAFE_OWNER]: (state: SafeReducerMap, action) => {
[REMOVE_SAFE_OWNER]: (state, action: Action<BaseOwnerPayload>) => {
const { ownerAddress, safeAddress } = action.payload
return state.updateIn(['safes', safeAddress], (prevSafe) =>
@ -161,7 +173,7 @@ export default handleActions(
}),
)
},
[REPLACE_SAFE_OWNER]: (state: SafeReducerMap, action) => {
[REPLACE_SAFE_OWNER]: (state, action: Action<ReplaceOwnerPayload>) => {
const { oldOwnerAddress, ownerAddress, ownerName, safeAddress } = action.payload
return state.updateIn(['safes', safeAddress], (prevSafe) =>
@ -172,7 +184,7 @@ export default handleActions(
}),
)
},
[EDIT_SAFE_OWNER]: (state: SafeReducerMap, action) => {
[EDIT_SAFE_OWNER]: (state, action: Action<FullOwnerPayload>) => {
const { ownerAddress, ownerName, safeAddress } = action.payload
return state.updateIn(['safes', safeAddress], (prevSafe) => {
@ -183,7 +195,7 @@ export default handleActions(
return prevSafe.merge({ owners: updatedOwners })
})
},
[UPDATE_TOKENS_LIST]: (state: SafeReducerMap, action) => {
[UPDATE_TOKENS_LIST]: (state, action: Action<SafeWithAddressPayload>) => {
// Only activeTokens or blackListedTokens is required
const { safeAddress, activeTokens, blacklistedTokens } = action.payload
@ -192,7 +204,7 @@ export default handleActions(
return state.updateIn(['safes', safeAddress], (prevSafe) => prevSafe.set(key, list))
},
[UPDATE_ASSETS_LIST]: (state: SafeReducerMap, action) => {
[UPDATE_ASSETS_LIST]: (state, action: Action<SafeWithAddressPayload>) => {
// Only activeAssets or blackListedAssets is required
const { safeAddress, activeAssets, blacklistedAssets } = action.payload
@ -201,13 +213,13 @@ export default handleActions(
return state.updateIn(['safes', safeAddress], (prevSafe) => prevSafe.set(key, list))
},
[SET_DEFAULT_SAFE]: (state: SafeReducerMap, action) => state.set('defaultSafe', action.payload),
[SET_LATEST_MASTER_CONTRACT_VERSION]: (state: SafeReducerMap, action) =>
[SET_DEFAULT_SAFE]: (state, action: Action<SafeRecord>) => state.set('defaultSafe', action.payload),
[SET_LATEST_MASTER_CONTRACT_VERSION]: (state, action: Action<SafeRecord>) =>
state.set('latestMasterContractVersion', action.payload),
},
Map({
defaultSafe: DEFAULT_SAFE_INITIAL_STATE,
safes: Map(),
latestMasterContractVersion: '',
}),
}) as AppReduxState['safes'],
)

View File

@ -1,14 +1,22 @@
import { Map } from 'immutable'
import { handleActions } from 'redux-actions'
import { List, Map } from 'immutable'
import { Action, handleActions } from 'redux-actions'
import { ADD_OR_UPDATE_TRANSACTIONS } from 'src/logic/safe/store/actions/transactions/addOrUpdateTransactions'
import { REMOVE_TRANSACTION } from 'src/logic/safe/store/actions/transactions/removeTransaction'
import { Transaction } from 'src/logic/safe/store/models/types/transaction'
import { AppReduxState } from 'src/store'
export const TRANSACTIONS_REDUCER_ID = 'transactions'
export default handleActions(
type TransactionBasePayload = { safeAddress: string }
type TransactionsPayload = TransactionBasePayload & { transactions: List<Transaction> }
type TransactionPayload = TransactionBasePayload & { transaction: Transaction }
type Payload = TransactionsPayload | TransactionPayload
export default handleActions<AppReduxState['transactions'], Payload>(
{
[ADD_OR_UPDATE_TRANSACTIONS]: (state, action) => {
[ADD_OR_UPDATE_TRANSACTIONS]: (state, action: Action<TransactionsPayload>) => {
const { safeAddress, transactions } = action.payload
if (!safeAddress || !transactions || !transactions.size) {
@ -46,7 +54,7 @@ export default handleActions(
}
})
},
[REMOVE_TRANSACTION]: (state, action) => {
[REMOVE_TRANSACTION]: (state, action: Action<TransactionPayload>) => {
const { safeAddress, transaction } = action.payload
if (!safeAddress || !transaction) {

View File

@ -0,0 +1,98 @@
import get from 'lodash.get'
import { createSelector } from 'reselect'
import { StoreStructure, Transaction, TxLocation } from 'src/logic/safe/store/models/types/gateway.d'
import { GATEWAY_TRANSACTIONS_ID } from 'src/logic/safe/store/reducer/gatewayTransactions'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { createHashBasedSelector } from 'src/logic/safe/store/selectors/utils'
import { AppReduxState } from 'src/store'
export const gatewayTransactions = (state: AppReduxState): AppReduxState['gatewayTransactions'] => {
return state[GATEWAY_TRANSACTIONS_ID]
}
export const historyTransactions = createSelector(
gatewayTransactions,
safeParamAddressFromStateSelector,
(gatewayTransactions, safeAddress): StoreStructure['history'] | undefined => {
return gatewayTransactions[safeAddress]?.history
},
)
export const pendingTransactions = createSelector(
gatewayTransactions,
safeParamAddressFromStateSelector,
(gatewayTransactions, safeAddress): StoreStructure['queued'] | undefined => {
return gatewayTransactions[safeAddress]?.queued
},
)
export const nextTransactions = createSelector(pendingTransactions, (pendingTransactions):
| StoreStructure['queued']['next']
| undefined => {
return pendingTransactions?.next
})
export const queuedTransactions = createSelector(pendingTransactions, (pendingTransactions):
| StoreStructure['queued']['queued']
| undefined => {
return pendingTransactions?.queued
})
type TxByLocationAttr = { attributeName: string; attributeValue: string | number; txLocation: TxLocation }
type TxByLocation = {
attributeName: string
attributeValue: string | number
transactions: StoreStructure['history'] | StoreStructure['queued']['queued' | 'next']
}
const getTransactionsByLocation = createHashBasedSelector(
gatewayTransactions,
safeParamAddressFromStateSelector,
(gatewayTransactions, safeAddress) => (rest: TxByLocationAttr): TxByLocation => ({
attributeName: rest.attributeName,
attributeValue: rest.attributeValue,
transactions: get(gatewayTransactions[safeAddress], rest.txLocation),
}),
)
export const getTransactionByAttribute = createSelector(
getTransactionsByLocation,
(fn: (r: TxByLocationAttr) => TxByLocation) => (rest: TxByLocationAttr): Transaction | undefined => {
const { attributeName, attributeValue, transactions } = fn(rest)
if (transactions && attributeValue) {
for (const [, txs] of Object.entries(transactions)) {
const foundTx = txs.find((transaction) => transaction[attributeName] === attributeValue)
if (foundTx) {
return foundTx
}
}
}
},
)
export const getTransactionDetails = createSelector(
getTransactionByAttribute,
(fn: (rest: TxByLocationAttr) => Transaction | undefined) => (
rest: TxByLocationAttr,
): Transaction['txDetails'] | undefined => {
const transaction = fn(rest)
return transaction?.txDetails
},
)
export const getQueuedTransactionsByNonce = createSelector(
getTransactionsByLocation,
(fn: (r: TxByLocationAttr) => TxByLocation) => (rest: TxByLocationAttr): Transaction[] => {
const { attributeValue, attributeName, transactions } = fn(rest)
if (attributeName === 'nonce') {
return transactions?.[attributeValue] ?? []
}
return []
},
)

View File

@ -93,7 +93,7 @@ export const safeCancellationTransactionsSelector = createSelector(
return Map()
}
return cancellationTransactions.get(address, Map({}))
return cancellationTransactions.get(address, Map())
},
)
@ -109,7 +109,7 @@ export const safeIncomingTransactionsSelector = createSelector(
return List([])
}
return incomingTransactions.get(address, List([]))
return incomingTransactions.get(address, List())
},
)

View File

@ -1,14 +1,18 @@
import { List } from 'immutable'
import { createSelector } from 'reselect'
// import { List } from 'immutable'
// import { createSelector } from 'reselect'
//
// import { safeIncomingTransactionsSelector, safeTransactionsSelector } from 'src/logic/safe/store/selectors'
// import { Transaction, SafeModuleTransaction } from 'src/logic/safe/store/models/types/transaction'
// import { safeModuleTransactionsSelector } from 'src/routes/safe/container/selector'
import { safeIncomingTransactionsSelector, safeTransactionsSelector } from 'src/logic/safe/store/selectors'
import { Transaction, SafeModuleTransaction } from 'src/logic/safe/store/models/types/transaction'
import { safeModuleTransactionsSelector } from 'src/routes/safe/container/selector'
// export const extendedTransactionsSelector = createSelector(
// safeTransactionsSelector,
// safeIncomingTransactionsSelector,
// safeModuleTransactionsSelector,
// (transactions, incomingTransactions, moduleTransactions): List<Transaction | SafeModuleTransaction> =>
// List().withMutations((list) => {
// list.concat(transactions).concat(incomingTransactions).concat(moduleTransactions)
// }),
// )
export const extendedTransactionsSelector = createSelector(
safeTransactionsSelector,
safeIncomingTransactionsSelector,
safeModuleTransactionsSelector,
(transactions, incomingTransactions, moduleTransactions): List<Transaction | SafeModuleTransaction> =>
List([...transactions, ...incomingTransactions, ...moduleTransactions]),
)
export {}

View File

@ -0,0 +1,13 @@
import hash from 'object-hash'
import isEqual from 'lodash.isequal'
import memoize from 'lodash.memoize'
import { createSelectorCreator, defaultMemoize } from 'reselect'
import { AppReduxState } from 'src/store'
export const createIsEqualSelector = createSelectorCreator(defaultMemoize, isEqual)
const hashFn = (gatewayTransactions: AppReduxState['gatewayTransactions'], safeAddress: string): string =>
hash(gatewayTransactions[safeAddress])
export const createHashBasedSelector = createSelectorCreator(memoize as any, hashFn)

View File

@ -1,18 +1,22 @@
import { List } from 'immutable'
import { isPendingTransaction } from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers'
import { isStatusAwaitingConfirmation } from 'src/logic/safe/store/models/types/gateway.d'
import { Transaction } from 'src/logic/safe/store/models/types/transaction'
import { Transaction as GatewayTransaction } from 'src/logic/safe/store/models/types/gateway'
import { addressInList } from 'src/routes/safe/components/Transactions/GatewayTransactions/utils'
import { CancellationTransactions } from 'src/logic/safe/store/reducer/cancellationTransactions'
export const getAwaitingTransactions = (
allTransactions: List<Transaction>,
cancellationTxs,
cancellationTxs: CancellationTransactions,
userAccount: string,
): List<Transaction> => {
return allTransactions.filter((tx) => {
const cancelTx = !!tx.nonce && !isNaN(Number(tx.nonce)) ? cancellationTxs.get(`${tx.nonce}`) : null
// The transaction is not executed and is not cancelled, nor pending, so it's still waiting confirmations
if (!tx.executionTxHash && !tx.cancelled && !isPendingTransaction(tx, cancelTx)) {
if (!tx.executionTxHash && !tx.cancelled && cancelTx && !isPendingTransaction(tx, cancelTx)) {
// Then we check if the waiting confirmations are not from the current user, otherwise, filters this transaction
const transactionWaitingUser = tx.confirmations.filter(({ owner }) => owner !== userAccount)
return transactionWaitingUser.size > 0
@ -21,3 +25,18 @@ export const getAwaitingTransactions = (
return false
})
}
export const getAwaitingGatewayTransactions = (
allTransactions: GatewayTransaction[],
userAccount: string,
): GatewayTransaction[] => {
return allTransactions.filter((tx) => {
// The transaction is not executed and is not cancelled, nor pending, so it's still waiting confirmations
if (isStatusAwaitingConfirmation(tx.txStatus)) {
// Then we check if the waiting confirmations are not from the current user, otherwise, filters this transaction
return addressInList(tx.executionInfo?.missingSigners)(userAccount)
}
return false
})
}

View File

@ -1,12 +1,17 @@
import { BigNumber } from 'bignumber.js'
import { getGnosisSafeInstanceAt } from 'src/logic/contracts/safeContracts'
import { calculateGasOf, EMPTY_DATA } from 'src/logic/wallets/ethTransactions'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { sameString } from 'src/utils/strings'
import { getWeb3, web3ReadOnly } from 'src/logic/wallets/getWeb3'
import { ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses'
import { generateSignaturesFromTxConfirmations } from 'src/logic/safe/safeTxSigner'
import { List } from 'immutable'
import { Confirmation } from 'src/logic/safe/store/models/types/confirmation'
import axios from 'axios'
import { getRpcServiceUrl, usesInfuraRPC } from 'src/config'
import { sameString } from 'src/utils/strings'
// 21000 - additional gas costs (e.g. base tx costs, transfer costs)
export const MINIMUM_TRANSACTION_GAS = 21000
// Receives the response data of the safe method requiredTxGas() and parses it to get the gas amount
const parseRequiredTxGasResponse = (data: string): number => {
@ -88,7 +93,7 @@ export const getDataFromNodeErrorMessage = (errorMessage: string): string | unde
}
}
export const getGasEstimationTxResponse = async (txConfig: {
const estimateGasWithWeb3Provider = async (txConfig: {
to: string
from: string
data: string
@ -116,12 +121,56 @@ export const getGasEstimationTxResponse = async (txConfig: {
return new BigNumber(estimationData.substring(138), 16).toNumber()
}
// This will fail in case that we receive an EMPTY_DATA on the GETH node gas estimation (for version < v1.9.24 of geth nodes)
// We cannot throw this error above because it will be captured again on the catch block bellow
throw new Error('Error while estimating the gas required for tx')
}
const estimateGasWithRPCCall = async (txConfig: {
to: string
from: string
data: string
gasPrice?: number
gas?: number
}): Promise<number> => {
try {
const { data } = await axios.post(getRpcServiceUrl(), {
jsonrpc: '2.0',
method: 'eth_call',
id: 1,
params: [
{
...txConfig,
gasPrice: web3ReadOnly.utils.toHex(txConfig.gasPrice || 0),
gas: txConfig.gas ? web3ReadOnly.utils.toHex(txConfig.gas) : undefined,
},
'latest',
],
})
const { error } = data
if (error?.data) {
return new BigNumber(data.error.data.substring(138), 16).toNumber()
}
} catch (error) {
console.log('Gas estimation endpoint errored: ', error.message)
}
throw new Error('Error while estimating the gas required for tx')
}
export const getGasEstimationTxResponse = async (txConfig: {
to: string
from: string
data: string
gasPrice?: number
gas?: number
}): Promise<number> => {
// If we are in a infura supported network we estimate using infura
if (usesInfuraRPC) {
return estimateGasWithRPCCall(txConfig)
}
// Otherwise we estimate using the current connected provider
return estimateGasWithWeb3Provider(txConfig)
}
const calculateMinimumGasForTransaction = async (
additionalGasBatches: number[],
safeAddress: string,
@ -131,15 +180,19 @@ const calculateMinimumGasForTransaction = async (
): Promise<number> => {
for (const additionalGas of additionalGasBatches) {
const amountOfGasToTryTx = txGasEstimation + dataGasEstimation + additionalGas
console.info(`Estimating transaction creation with gas amount: ${amountOfGasToTryTx}`)
try {
await getGasEstimationTxResponse({
const estimation = await getGasEstimationTxResponse({
to: safeAddress,
from: safeAddress,
data: estimateData,
gasPrice: 0,
gas: amountOfGasToTryTx,
})
return txGasEstimation + additionalGas
if (estimation > 0) {
console.info(`Gas estimation successfully finished with gas amount: ${amountOfGasToTryTx}`)
return amountOfGasToTryTx
}
} catch (error) {
console.log(`Error trying to estimate gas with amount: ${amountOfGasToTryTx}`)
}
@ -154,6 +207,7 @@ export const estimateGasForTransactionCreation = async (
to: string,
valueInWei: string,
operation: number,
safeTxGas?: number,
): Promise<number> => {
try {
const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
@ -163,19 +217,21 @@ export const estimateGasForTransactionCreation = async (
to: safeAddress,
from: safeAddress,
data: estimateData,
gas: safeTxGas ? safeTxGas : undefined,
})
const txGasEstimation = gasEstimationResponse + 10000
if (safeTxGas) {
return gasEstimationResponse
}
// 21000 - additional gas costs (e.g. base tx costs, transfer costs)
const dataGasEstimation = parseRequiredTxGasResponse(estimateData) + 21000
const dataGasEstimation = parseRequiredTxGasResponse(estimateData)
const additionalGasBatches = [0, 10000, 20000, 40000, 80000, 160000, 320000, 640000, 1280000, 2560000, 5120000]
return await calculateMinimumGasForTransaction(
additionalGasBatches,
safeAddress,
estimateData,
txGasEstimation,
gasEstimationResponse,
dataGasEstimation,
)
} catch (error) {

View File

@ -1,4 +1,4 @@
import { Map } from 'immutable'
import isEqual from 'lodash.isequal'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
@ -6,9 +6,9 @@ import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
const isStateSubset = (superObj, subObj) => {
return Object.keys(subObj).every((key) => {
if (subObj[key] && typeof subObj[key] == 'object') {
if (Map.isMap(subObj[key]) || subObj[key].size >= 0) {
if (typeof subObj[key] === 'object' || subObj[key].length >= 0) {
// If type is Immutable Map, List or Object we use Immutable equals
return superObj[key].equals(subObj[key])
return isEqual(superObj[key], subObj[key])
}
return isStateSubset(superObj[key], subObj[key])
}

View File

@ -180,7 +180,7 @@ type SpendingLimitTxParams = {
resetTimeMin: number
resetBaseMin: number
}
safeAddress
safeAddress: string
}
export const setSpendingLimitTx = ({
@ -190,7 +190,7 @@ export const setSpendingLimitTx = ({
const spendingLimitContract = getSpendingLimitContract()
const { nativeCoin } = getNetworkInfo()
return {
const txArgs: CreateTransactionArgs = {
safeAddress,
to: SPENDING_LIMIT_MODULE_ADDRESS,
valueInWei: ZERO_VALUE,
@ -206,6 +206,8 @@ export const setSpendingLimitTx = ({
operation: CALL,
notifiedTransaction: TX_NOTIFICATION_TYPES.NEW_SPENDING_LIMIT_TX,
}
return txArgs
}
export const setSpendingLimitMultiSendTx = (args: SpendingLimitTxParams): MultiSendTx => {

View File

@ -15,21 +15,8 @@ import { TokenState } from 'src/logic/tokens/store/reducer/tokens'
import updateSafe from 'src/logic/safe/store/actions/updateSafe'
import { AppReduxState } from 'src/store'
import { humanReadableValue } from 'src/logic/tokens/utils/humanReadableValue'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import {
safeActiveTokensSelector,
safeBalancesSelector,
safeBlacklistedTokensSelector,
safeEthBalanceSelector,
safeSelector,
} from 'src/logic/safe/store/selectors'
import { safeActiveTokensSelector, safeBlacklistedTokensSelector, safeSelector } from 'src/logic/safe/store/selectors'
import { tokensSelector } from 'src/logic/tokens/store/selectors'
import { currencyValuesSelector } from 'src/logic/currencyValues/store/selectors'
const noFunc = (): void => {}
const updateSafeValue = (address: string) => (valueToUpdate: Partial<SafeRecordProps>) =>
updateSafe({ address, ...valueToUpdate })
interface ExtractedData {
balances: Map<string, string>
@ -78,11 +65,8 @@ const fetchSafeTokens = (safeAddress: string) => async (
}
const tokenCurrenciesBalances = await backOff(() => fetchTokenCurrenciesBalances(safeAddress))
const currentEthBalance = safeEthBalanceSelector(state)
const safeBalances = safeBalancesSelector(state)
const alreadyActiveTokens = safeActiveTokensSelector(state)
const blacklistedTokens = safeBlacklistedTokensSelector(state)
const currencyValues = currencyValuesSelector(state)
const { balances, currencyList, ethBalance, tokens } = tokenCurrenciesBalances.reduce<ExtractedData>(
extractDataFromResult(currentTokens),
@ -100,24 +84,10 @@ const fetchSafeTokens = (safeAddress: string) => async (
balances.keySeq().toSet().subtract(blacklistedTokens),
)
const update = updateSafeValue(safeAddress)
const updateActiveTokens = activeTokens.equals(alreadyActiveTokens) ? noFunc : update({ activeTokens })
const updateBalances = balances.equals(safeBalances) ? noFunc : update({ balances })
const updateEthBalance = ethBalance === currentEthBalance ? noFunc : update({ ethBalance })
const storedCurrencyBalances = currencyValues?.get(safeAddress)?.get('currencyBalances')
const updateCurrencies = currencyList.equals(storedCurrencyBalances)
? noFunc
: setCurrencyBalances(safeAddress, currencyList)
const updateTokens = tokens.size === 0 ? noFunc : addTokens(tokens)
batch(() => {
dispatch(updateActiveTokens)
dispatch(updateBalances)
dispatch(updateEthBalance)
dispatch(updateCurrencies)
dispatch(updateTokens)
dispatch(updateSafe({ address: safeAddress, activeTokens, balances, ethBalance }))
dispatch(setCurrencyBalances(safeAddress, currencyList))
dispatch(addTokens(tokens))
})
} catch (err) {
console.error('Error fetching active token list', err)

View File

@ -1,17 +1,22 @@
import { Map } from 'immutable'
import { handleActions } from 'redux-actions'
import { List, Map } from 'immutable'
import { Action, handleActions } from 'redux-actions'
import { ADD_TOKEN } from 'src/logic/tokens/store/actions/addToken'
import { ADD_TOKENS } from 'src/logic/tokens/store/actions/saveTokens'
import { makeToken, Token } from 'src/logic/tokens/store/model/token'
import { AppReduxState } from 'src/store'
export const TOKEN_REDUCER_ID = 'tokens'
export type TokenState = Map<string, Token>
export default handleActions(
type TokensPayload = { tokens: List<Token> }
type TokenPayload = { token: Token }
type Payloads = TokensPayload | TokenPayload
export default handleActions<AppReduxState['tokens'], Payloads>(
{
[ADD_TOKENS]: (state: TokenState, action) => {
[ADD_TOKENS]: (state: TokenState, action: Action<TokensPayload>) => {
const { tokens } = action.payload
return state.withMutations((map) => {
@ -20,7 +25,7 @@ export default handleActions(
})
})
},
[ADD_TOKEN]: (state: TokenState, action) => {
[ADD_TOKEN]: (state: TokenState, action: Action<TokenPayload>) => {
const { token } = action.payload
const { address: tokenAddress } = token

View File

@ -8,8 +8,7 @@ import { isSendERC721Transaction } from 'src/logic/collectibles/utils'
import { makeToken, Token } from 'src/logic/tokens/store/model/token'
import { ALTERNATIVE_TOKEN_ABI } from 'src/logic/tokens/utils/alternativeAbi'
import { web3ReadOnly as web3 } from 'src/logic/wallets/getWeb3'
import { isEmptyData } from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers'
import { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions'
import { BuildTx, isEmptyData, ServiceTx } from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers'
import { CALL } from 'src/logic/safe/transactions'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
@ -35,7 +34,7 @@ export const isAddressAToken = async (tokenAddress: string): Promise<boolean> =>
return call !== '0x'
}
export const isTokenTransfer = (tx: TxServiceModel): boolean => {
export const isTokenTransfer = (tx: BuildTx['tx']): boolean => {
return (
!isEmptyData(tx.data) &&
// Check if contains 'transfer' method code
@ -70,11 +69,11 @@ export const getERC20DecimalsAndSymbol = async (
return tokenInfo
}
export const isSendERC20Transaction = async (tx: TxServiceModel): Promise<boolean> => {
export const isSendERC20Transaction = async (tx: BuildTx['tx']): Promise<boolean> => {
let isSendTokenTx = !isSendERC721Transaction(tx) && isTokenTransfer(tx)
if (isSendTokenTx) {
const { decimals, symbol } = await getERC20DecimalsAndSymbol(tx.to)
const { decimals, symbol } = await getERC20DecimalsAndSymbol((tx as ServiceTx).to)
// some contracts may implement the same methods as in ERC20 standard
// we may falsely treat them as tokens, so in case we get any errors when getting token info

View File

@ -47,3 +47,5 @@ export const isUserAnOwnerOfAnySafe = (safes: List<SafeRecord> | SafeRecord[], u
safes.some((safe: SafeRecord) => isUserAnOwner(safe, userAccount))
export const isValidEnsName = (name: string): boolean => /^([\w-]+\.)+(eth|test|xyz|luxe|ewc)$/.test(name)
export const isValidCryptoDomainName = (name: string): boolean => /^([\w-]+\.)+(crypto)$/.test(name)

View File

@ -66,3 +66,12 @@ export const calculateGasOf = async (txConfig: {
return Promise.reject(err)
}
}
export const getUserNonce = async (userAddress: string): Promise<number> => {
const web3 = getWeb3()
try {
return await web3.eth.getTransactionCount(userAddress, 'pending')
} catch (error) {
return Promise.reject(error)
}
}

View File

@ -1,12 +1,13 @@
import Web3 from 'web3'
import { provider as Provider } from 'web3-core'
import { ContentHash } from 'web3-eth-ens'
import { sameAddress } from './ethAddresses'
import { EMPTY_DATA } from './ethTransactions'
import { ProviderProps } from './store/model/provider'
import { NODE_ENV } from 'src/utils/constants'
import { getRpcServiceUrl } from 'src/config'
import { isValidCryptoDomainName } from 'src/logic/wallets/ethAddresses'
import { getAddressFromUnstoppableDomain } from './utils/unstoppableDomains'
export const WALLET_PROVIDER = {
SAFE: 'SAFE',
@ -85,7 +86,12 @@ export const getProviderInfo = async (web3Instance: Web3, providerName = 'Wallet
}
}
export const getAddressFromENS = (name: string): Promise<string> => web3.eth.ens.getAddress(name)
export const getAddressFromDomain = (name: string): Promise<string> => {
if (isValidCryptoDomainName(name)) {
return getAddressFromUnstoppableDomain(name)
}
return web3.eth.ens.getAddress(name)
}
export const getContentFromENS = (name: string): Promise<ContentHash> => web3.eth.ens.getContenthash(name)

View File

@ -1,4 +1,5 @@
import ReactGA from 'react-ga'
import { Dispatch } from 'redux'
import addProvider from './addProvider'
@ -8,7 +9,6 @@ import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackb
import { getProviderInfo, getWeb3 } from 'src/logic/wallets/getWeb3'
import { makeProvider } from 'src/logic/wallets/store/model/provider'
import { updateStoredTransactionsStatus } from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers'
import { Dispatch } from 'redux'
export const processProviderResponse = (dispatch, provider) => {
const walletRecord = makeProvider(provider)
@ -44,7 +44,6 @@ const handleProviderNotification = (provider, dispatch) => {
action: 'Connect a wallet',
label: provider.name,
})
dispatch(enqueueSnackbar(enhanceSnackbarForAction(NOTIFICATIONS.WALLET_CONNECTED_MSG)))
} else {
dispatch(enqueueSnackbar(enhanceSnackbarForAction(NOTIFICATIONS.UNLOCK_WALLET_MSG)))
}

View File

@ -2,8 +2,6 @@ import { Dispatch } from 'src/logic/safe/store/actions/types.d'
import { createAction } from 'redux-actions'
import { onboard } from 'src/components/ConnectButton'
import { NOTIFICATIONS, enhanceSnackbarForAction } from 'src/logic/notifications'
import enqueueSnackbar from 'src/logic/notifications/store/actions/enqueueSnackbar'
import { resetWeb3 } from 'src/logic/wallets/getWeb3'
export const REMOVE_PROVIDER = 'REMOVE_PROVIDER'
@ -15,9 +13,4 @@ export default () => (dispatch: Dispatch): void => {
resetWeb3()
dispatch(removeProvider())
dispatch(
enqueueSnackbar(
enhanceSnackbarForAction(NOTIFICATIONS.WALLET_DISCONNECTED_MSG, NOTIFICATIONS.WALLET_DISCONNECTED_MSG.key),
),
)
}

View File

@ -0,0 +1,18 @@
import UnstoppableResolution from '@unstoppabledomains/resolution'
import { getRpcServiceUrl } from 'src/config'
let unstoppableResolver
export const getAddressFromUnstoppableDomain = (name: string) => {
if (!unstoppableResolver) {
unstoppableResolver = new UnstoppableResolution({
blockchain: {
cns: {
url: getRpcServiceUrl(),
},
},
})
}
return unstoppableResolver.addr(name, 'ETH')
}

View File

@ -24,19 +24,13 @@ import { networkSelector, providerNameSelector, userAccountSelector } from 'src/
import { useSelector } from 'react-redux'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { getNameFromAddressBook } from 'src/logic/addressBook/utils'
import { SafeProps } from 'src/routes/open/container/Open'
const { useEffect } = React
const getSteps = () => ['Name', 'Owners and confirmations', 'Review']
type SafeProps = {
name: string
ownerAddresses: any
ownerNames: string
threshold: string
}
type InitialValuesForm = {
export type InitialValuesForm = {
owner0Address?: string
owner0Name?: string
confirmations: string

View File

@ -157,8 +157,8 @@ const SafeOwnersForm = (props): React.ReactElement => {
<br />
<br />
Add additional owners (e.g. wallets of your teammates) and specify how many of them have to confirm a
transaction before it gets executed. In general, the more confirmations required, the more secure is your
Safe.
transaction before it gets executed. You can also add/remove owners and change the signature threshold after
your Safe is created.
</Paragraph>
</Block>
<Hairline />

View File

@ -1,10 +1,12 @@
import { Loader } from '@gnosis.pm/safe-react-components'
import queryString from 'query-string'
import React, { useEffect, useState } from 'react'
import ReactGA from 'react-ga'
import { useDispatch, useSelector } from 'react-redux'
import Opening from 'src/routes/opening'
import { Layout } from 'src/routes/open/components/Layout'
import { useLocation } from 'react-router-dom'
import { PromiEvent, TransactionReceipt } from 'web3-core'
import { SafeDeployment } from 'src/routes/opening'
import { InitialValuesForm, Layout } from 'src/routes/open/components/Layout'
import Page from 'src/components/layout/Page'
import { getSafeDeploymentTransaction } from 'src/logic/contracts/safeContracts'
import { checkReceiptStatus } from 'src/logic/wallets/ethTransactions'
@ -23,22 +25,51 @@ import { loadFromStorage, removeFromStorage, saveToStorage } from 'src/utils/sto
import { userAccountSelector } from 'src/logic/wallets/store/selectors'
import { SafeRecordProps } from 'src/logic/safe/store/models/safe'
import { addOrUpdateSafe } from 'src/logic/safe/store/actions/addOrUpdateSafe'
import { PromiEvent, TransactionReceipt } from 'web3-core'
import { useAnalytics } from 'src/utils/googleAnalytics'
const SAFE_PENDING_CREATION_STORAGE_KEY = 'SAFE_PENDING_CREATION_STORAGE_KEY'
const validateQueryParams = (ownerAddresses, ownerNames, threshold, safeName) => {
interface SafeCreationQueryParams {
ownerAddresses: string | string[] | null
ownerNames: string | string[] | null
threshold: number | null
safeName: string | null
}
export interface SafeProps {
name: string
ownerAddresses: string[]
ownerNames: string[]
threshold: string
}
const validateQueryParams = (queryParams: SafeCreationQueryParams): boolean => {
const { ownerAddresses, ownerNames, threshold, safeName } = queryParams
if (!ownerAddresses || !ownerNames || !threshold || !safeName) {
return false
}
if (!ownerAddresses.length || ownerNames.length === 0) {
if (Number.isNaN(threshold)) {
return false
}
if (Number.isNaN(Number(threshold))) {
return false
return threshold > 0 && threshold <= ownerAddresses.length
}
const getSafePropsValuesFromQueryParams = (queryParams: SafeCreationQueryParams): SafeProps | undefined => {
if (!validateQueryParams(queryParams)) {
return
}
const { threshold, safeName, ownerAddresses, ownerNames } = queryParams
return {
name: safeName as string,
threshold: (threshold as number).toString(),
ownerAddresses: Array.isArray(ownerAddresses) ? ownerAddresses : [ownerAddresses as string],
ownerNames: Array.isArray(ownerNames) ? ownerNames : [ownerNames as string],
}
return threshold <= ownerAddresses.length
}
export const getSafeProps = async (
@ -54,7 +85,7 @@ export const getSafeProps = async (
return safeProps
}
export const createSafe = (values, userAccount) => {
export const createSafe = (values: InitialValuesForm, userAccount: string): PromiEvent<TransactionReceipt> => {
const confirmations = getThresholdFrom(values)
const name = getSafeNameFrom(values)
const ownersNames = getNamesFrom(values)
@ -86,24 +117,27 @@ const Open = (): React.ReactElement => {
const [loading, setLoading] = useState(false)
const [showProgress, setShowProgress] = useState(false)
const [creationTxPromise, setCreationTxPromise] = useState<PromiEvent<TransactionReceipt>>()
const [safeCreationPendingInfo, setSafeCreationPendingInfo] = useState<any>()
const [safePropsFromUrl, setSafePropsFromUrl] = useState()
const [safeCreationPendingInfo, setSafeCreationPendingInfo] = useState<{ txHash?: string } | undefined>()
const [safePropsFromUrl, setSafePropsFromUrl] = useState<SafeProps | undefined>()
const userAccount = useSelector(userAccountSelector)
const dispatch = useDispatch()
const location = useLocation()
const { trackEvent } = useAnalytics()
useEffect(() => {
// #122: Allow to migrate an old Multisig by passing the parameters to the URL.
const query = queryString.parse(window.location.search, { arrayFormat: 'comma' })
const query = queryString.parse(location.search, { arrayFormat: 'comma' })
const { name, owneraddresses, ownernames, threshold } = query
if (validateQueryParams(owneraddresses, ownernames, threshold, name)) {
setSafePropsFromUrl({
name,
ownerAddresses: owneraddresses,
ownerNames: ownernames,
threshold,
} as any)
}
}, [])
const safeProps = getSafePropsValuesFromQueryParams({
ownerAddresses: owneraddresses,
ownerNames: ownernames,
threshold: Number(threshold),
safeName: name as string | null,
})
setSafePropsFromUrl(safeProps)
}, [location])
// check if there is a safe being created
useEffect(() => {
@ -121,7 +155,7 @@ const Open = (): React.ReactElement => {
load()
}, [])
const createSafeProxy = async (formValues?: any) => {
const createSafeProxy = async (formValues?: InitialValuesForm) => {
let values = formValues
// save form values, used when the user rejects the TX and wants to retry
@ -132,7 +166,7 @@ const Open = (): React.ReactElement => {
values = await loadFromStorage(SAFE_PENDING_CREATION_STORAGE_KEY)
}
const promiEvent = createSafe(values, userAccount)
const promiEvent = createSafe(values as InitialValuesForm, userAccount)
setCreationTxPromise(promiEvent)
setShowProgress(true)
}
@ -147,7 +181,7 @@ const Open = (): React.ReactElement => {
await dispatch(addOrUpdateSafe(safeProps))
ReactGA.event({
trackEvent({
category: 'User',
action: 'Created a safe',
})
@ -186,7 +220,7 @@ const Open = (): React.ReactElement => {
return (
<Page>
{showProgress ? (
<Opening
<SafeDeployment
creationTxHash={safeCreationPendingInfo?.txHash}
onCancel={onCancel}
onRetry={onRetry}

View File

@ -20,6 +20,7 @@ import LoaderDotsSvg from './assets/loader-dots.svg'
import SuccessSvg from './assets/success.svg'
import VaultErrorSvg from './assets/vault-error.svg'
import VaultSvg from './assets/vault.svg'
import { PromiEvent, TransactionReceipt } from 'web3-core'
const Wrapper = styled.div`
display: grid;
@ -56,13 +57,17 @@ const Body = styled.div`
const CardTitle = styled.div`
font-size: 20px;
`
const FullParagraph = styled(Paragraph)`
background-color: ${(p) => (p.inverseColors ? connected : background)};
color: ${(p) => (p.inverseColors ? background : connected)};
interface FullParagraphProps {
inversecolors: string
}
const FullParagraph = styled(Paragraph)<FullParagraphProps>`
background-color: ${(p) => (p.inversecolors ? connected : background)};
color: ${(p) => (p.inversecolors ? background : connected)};
padding: 24px;
font-size: 16px;
margin-bottom: 16px;
transition: color 0.3s ease-in-out, background-color 0.3s ease-in-out;
`
@ -95,16 +100,21 @@ const BackButton = styled(Button)`
margin: 20px auto 0;
`
// type Props = {
// provider: string
// creationTxHash: Promise<any>
// submittedPromise: Promise<any>
// onRetry: () => void
// onSuccess: () => void
// onCancel: () => void
// }
type Props = {
creationTxHash?: string
submittedPromise?: PromiEvent<TransactionReceipt>
onRetry: () => void
onSuccess: (createdSafeAddress: string) => void
onCancel: () => void
}
const SafeDeployment = ({ creationTxHash, onCancel, onRetry, onSuccess, submittedPromise }): React.ReactElement => {
export const SafeDeployment = ({
creationTxHash,
onCancel,
onRetry,
onSuccess,
submittedPromise,
}: Props): React.ReactElement => {
const [loading, setLoading] = useState(true)
const [stepIndex, setStepIndex] = useState(0)
const [safeCreationTxHash, setSafeCreationTxHash] = useState('')
@ -326,7 +336,7 @@ const SafeDeployment = ({ creationTxHash, onCancel, onRetry, onSuccess, submitte
<BodyLoader>{!error && stepIndex <= 4 && <Img alt="Loader dots" src={LoaderDotsSvg} />}</BodyLoader>
<BodyInstruction>
<FullParagraph color="primary" inverseColors={confirmationStep} noMargin size="md">
<FullParagraph color="primary" inversecolors={confirmationStep.toString()} noMargin size="md">
{error ? 'You can Cancel or Retry the Safe creation process.' : steps[stepIndex].instruction}
</FullParagraph>
</BodyInstruction>
@ -350,5 +360,3 @@ const SafeDeployment = ({ creationTxHash, onCancel, onRetry, onSuccess, submitte
</Wrapper>
)
}
export default SafeDeployment

View File

@ -10,6 +10,7 @@ import { useDispatch, useSelector } from 'react-redux'
import { SAFELIST_ADDRESS } from 'src/routes/routes'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { xs } from 'src/theme/variables'
import { grantedSelector } from 'src/routes/safe/container/selector'
const useStyles = makeStyles(
createStyles({
@ -48,6 +49,7 @@ export const EllipsisTransactionDetails = ({
const dispatch = useDispatch()
const currentSafeAddress = useSelector(safeParamAddressFromStateSelector)
const isOwnerConnected = useSelector(grantedSelector)
const handleClick = (event) => setAnchorEl(event.currentTarget)
@ -65,7 +67,7 @@ export const EllipsisTransactionDetails = ({
<Menu anchorEl={anchorEl} id="simple-menu" keepMounted onClose={closeMenuHandler} open={Boolean(anchorEl)}>
{sendModalOpenHandler
? [
<MenuItem key="send-again-button" onClick={sendModalOpenHandler}>
<MenuItem key="send-again-button" onClick={sendModalOpenHandler} disabled={!isOwnerConnected}>
Send Again
</MenuItem>,
<Divider key="divider" />,

View File

@ -1,58 +0,0 @@
import React, { useEffect, useState } from 'react'
import { useTransactions } from 'src/routes/safe/container/hooks/useTransactions'
import { ButtonLink, Loader } from '@gnosis.pm/safe-react-components'
import { Transaction } from 'src/logic/safe/store/models/types/transactions.d'
const Transactions = (): React.ReactElement => {
const [currentPage, setCurrentPage] = useState(0)
const [limit] = useState(50)
const [offset, setOffset] = useState(0)
const [maxPages, setMaxPages] = useState(0)
const { transactions, totalTransactionsCount } = useTransactions({ offset, limit })
const [transactionsByPage, setTransactionsByPage] = useState(transactions)
useEffect(() => {
const currentPage = Math.floor(offset / limit) + 1
const maxPages = Math.ceil(totalTransactionsCount / limit)
setCurrentPage(currentPage)
setMaxPages(maxPages)
const newTransactionsByPage = transactions ? transactions.slice(offset, offset * 2 || limit) : []
setTransactionsByPage(newTransactionsByPage)
}, [offset, limit, totalTransactionsCount, transactions])
// TODO: Remove this once we implement infinite scroll
const nextPageButtonHandler = () => {
setOffset(offset + limit)
}
const previousPageButtonHandler = () => {
setOffset(offset > 0 ? offset - limit : offset)
}
if (!transactionsByPage) return <div>No txs available for safe</div>
if (!transactionsByPage.length) return <Loader size="lg" />
return (
<>
{transactionsByPage.map((tx: Transaction, index) => {
let txHash = ''
if ('transactionHash' in tx) {
txHash = tx.transactionHash as string
}
if ('txHash' in tx) {
txHash = tx.txHash
}
return <div key={txHash || tx.executionDate || index}>Tx hash: {txHash}</div>
})}
<ButtonLink color="primary" onClick={previousPageButtonHandler} disabled={currentPage === 1}>
Previous Page
</ButtonLink>
<ButtonLink color="primary" onClick={nextPageButtonHandler} disabled={currentPage >= maxPages}>
Next Page
</ButtonLink>
</>
)
}
export default Transactions

View File

@ -1,8 +1,8 @@
import React, { useEffect, useState } from 'react'
import { GenericModal, Icon, ModalFooterConfirmation, Text, Title } from '@gnosis.pm/safe-react-components'
import React, { useEffect, useMemo, useState } from 'react'
import { Icon, ModalFooterConfirmation, Text, Title } from '@gnosis.pm/safe-react-components'
import { Transaction } from '@gnosis.pm/safe-apps-sdk-v1'
import styled from 'styled-components'
import { useDispatch } from 'react-redux'
import { useDispatch, useSelector } from 'react-redux'
import AddressInfo from 'src/components/AddressInfo'
import DividerLine from 'src/components/DividerLine'
@ -16,17 +16,24 @@ import Img from 'src/components/layout/Img'
import { getEthAsToken } from 'src/logic/tokens/utils/tokenHelpers'
import { SafeApp } from 'src/routes/safe/components/Apps/types.d'
import { fromTokenUnit } from 'src/logic/tokens/utils/humanReadableValue'
import createTransaction from 'src/logic/safe/store/actions/createTransaction'
import { createTransaction } from 'src/logic/safe/store/actions/createTransaction'
import { MULTI_SEND_ADDRESS } from 'src/logic/contracts/safeContracts'
import { DELEGATE_CALL, TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { DELEGATE_CALL, TX_NOTIFICATION_TYPES, CALL } from 'src/logic/safe/transactions'
import { encodeMultiSendCall } from 'src/logic/safe/transactions/multisend'
import GasEstimationInfo from './GasEstimationInfo'
import { getNetworkInfo } from 'src/config'
import { TransactionParams } from './AppFrame'
import { EstimationStatus, useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas'
import { safeThresholdSelector } from 'src/logic/safe/store/selectors'
import Modal from 'src/components/Modal'
import Row from 'src/components/layout/Row'
import Hairline from 'src/components/layout/Hairline'
import { TransactionFees } from 'src/components/TransactionsFees'
import { EditableTxParameters } from 'src/routes/safe/components/Transactions/helpers/EditableTxParameters'
import { TxParametersDetail } from 'src/routes/safe/components/Transactions/helpers/TxParametersDetail'
import { md, lg, sm } from 'src/theme/variables'
import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
const isTxValid = (t: Transaction): boolean => {
if (!['string', 'number'].includes(typeof t.value)) {
@ -71,6 +78,16 @@ const StyledTextBox = styled(TextBox)`
const Container = styled.div`
max-width: 480px;
padding: ${md} ${lg};
`
const ModalFooter = styled(Row)`
padding: ${md} ${lg};
justify-content: center;
`
const TransactionFeesWrapper = styled.div`
background-color: ${({ theme }) => theme.colors.background};
padding: ${sm} ${lg};
`
type OwnProps = {
@ -101,8 +118,18 @@ export const ConfirmTransactionModal = ({
onTxReject,
}: OwnProps): React.ReactElement | null => {
const [estimatedSafeTxGas, setEstimatedSafeTxGas] = useState(0)
const threshold = useSelector(safeThresholdSelector) || 1
const txRecipient: string | undefined = useMemo(() => (txs.length > 1 ? MULTI_SEND_ADDRESS : txs[0]?.to), [txs])
const txData: string | undefined = useMemo(() => (txs.length > 1 ? encodeMultiSendCall(txs) : txs[0]?.data), [txs])
const txValue: string | undefined = useMemo(() => (txs.length > 1 ? '0' : txs[0]?.value), [txs])
const operation = useMemo(() => (txs.length > 1 ? DELEGATE_CALL : CALL), [txs])
const [manualSafeTxGas, setManualSafeTxGas] = useState(0)
const [manualGasPrice, setManualGasPrice] = useState<string | undefined>()
const {
gasLimit,
gasPriceFormatted,
gasEstimation,
isOffChainSignature,
isCreation,
@ -110,9 +137,12 @@ export const ConfirmTransactionModal = ({
gasCostFormatted,
txEstimationExecutionStatus,
} = useEstimateTransactionGas({
txData: encodeMultiSendCall(txs),
txRecipient: MULTI_SEND_ADDRESS,
operation: DELEGATE_CALL,
txData: txData || '',
txRecipient,
operation,
txAmount: txValue,
safeTxGas: manualSafeTxGas,
manualGasPrice,
})
useEffect(() => {
@ -136,21 +166,25 @@ export const ConfirmTransactionModal = ({
onClose()
}
const confirmTransactions = async () => {
const txData = encodeMultiSendCall(txs)
const getParametersStatus = () => (threshold > 1 ? 'ETH_DISABLED' : 'ENABLED')
const confirmTransactions = async (txParameters: TxParameters) => {
await dispatch(
createTransaction(
{
safeAddress,
to: MULTI_SEND_ADDRESS,
valueInWei: '0',
to: txRecipient,
valueInWei: txValue,
txData,
operation: DELEGATE_CALL,
notifiedTransaction: TX_NOTIFICATION_TYPES.STANDARD_TX,
operation,
origin: app.id,
navigateToTransactionsTab: false,
safeTxGas: Math.max(params?.safeTxGas || 0, estimatedSafeTxGas),
txNonce: txParameters.safeNonce,
safeTxGas: txParameters.safeTxGas
? Number(txParameters.safeTxGas)
: Math.max(params?.safeTxGas || 0, estimatedSafeTxGas),
ethParameters: txParameters,
notifiedTransaction: TX_NOTIFICATION_TYPES.STANDARD_TX,
},
handleUserConfirmation,
handleTxRejection,
@ -158,82 +192,129 @@ export const ConfirmTransactionModal = ({
)
}
const closeEditModalCallback = (txParameters: TxParameters) => {
const oldGasPrice = Number(gasPriceFormatted)
const newGasPrice = Number(txParameters.ethGasPrice)
const oldSafeTxGas = Number(gasEstimation)
const newSafeTxGas = Number(txParameters.safeTxGas)
if (newGasPrice && oldGasPrice !== newGasPrice) {
setManualGasPrice(txParameters.ethGasPrice)
}
if (newSafeTxGas && oldSafeTxGas !== newSafeTxGas) {
setManualSafeTxGas(newSafeTxGas)
}
}
const areTxsMalformed = txs.some((t) => !isTxValid(t))
const body = areTxsMalformed ? (
<>
<IconText>
<Icon color="error" size="md" type="info" />
<Title size="xs">Transaction error</Title>
</IconText>
<Text size="lg">
This Safe App initiated a transaction which cannot be processed. Please get in touch with the developer of this
Safe App for more information.
</Text>
</>
) : (
<Container>
<AddressInfo ethBalance={ethBalance} safeAddress={safeAddress} safeName={safeName} />
<DividerLine withArrow />
{txs.map((tx, index) => (
<Wrapper key={index}>
<Collapse description={<AddressInfo safeAddress={tx.to} />} title={`Transaction ${index + 1}`}>
<CollapseContent>
<div className="section">
<Heading tag="h3">Value</Heading>
<div className="value-section">
<Img alt="Ether" height={40} src={getEthAsToken('0').logoUri} />
<Bold>
{fromTokenUnit(tx.value, nativeCoin.decimals)} {nativeCoin.name}
</Bold>
const body = areTxsMalformed
? () => (
<>
<IconText>
<Icon color="error" size="md" type="info" />
<Title size="xs">Transaction error</Title>
</IconText>
<Text size="lg">
This Safe App initiated a transaction which cannot be processed. Please get in touch with the developer of
this Safe App for more information.
</Text>
</>
)
: (txParameters, toggleEditMode) => {
return (
<>
<Container>
<AddressInfo ethBalance={ethBalance} safeAddress={safeAddress} safeName={safeName} />
<DividerLine withArrow />
{txs.map((tx, index) => (
<Wrapper key={index}>
<Collapse description={<AddressInfo safeAddress={tx.to} />} title={`Transaction ${index + 1}`}>
<CollapseContent>
<div className="section">
<Heading tag="h3">Value</Heading>
<div className="value-section">
<Img alt="Ether" height={40} src={getEthAsToken('0').logoUri} />
<Bold>
{fromTokenUnit(tx.value, nativeCoin.decimals)} {nativeCoin.name}
</Bold>
</div>
</div>
<div className="section">
<Heading tag="h3">Data (hex encoded)*</Heading>
<StyledTextBox>{tx.data}</StyledTextBox>
</div>
</CollapseContent>
</Collapse>
</Wrapper>
))}
<DividerLine withArrow={false} />
{params?.safeTxGas && (
<div className="section">
<Heading tag="h3">SafeTxGas</Heading>
<StyledTextBox>{params?.safeTxGas}</StyledTextBox>
<GasEstimationInfo
appEstimation={params.safeTxGas}
internalEstimation={estimatedSafeTxGas}
loading={txEstimationExecutionStatus === EstimationStatus.LOADING}
/>
</div>
</div>
<div className="section">
<Heading tag="h3">Data (hex encoded)*</Heading>
<StyledTextBox>{tx.data}</StyledTextBox>
</div>
</CollapseContent>
</Collapse>
</Wrapper>
))}
<DividerLine withArrow={false} />
{params?.safeTxGas && (
<div className="section">
<Heading tag="h3">SafeTxGas</Heading>
<StyledTextBox>{params?.safeTxGas}</StyledTextBox>
<GasEstimationInfo
appEstimation={params.safeTxGas}
internalEstimation={estimatedSafeTxGas}
loading={txEstimationExecutionStatus === EstimationStatus.LOADING}
/>
</div>
)}
<Row>
<TransactionFees
gasCostFormatted={gasCostFormatted}
isExecution={isExecution}
isCreation={isCreation}
isOffChainSignature={isOffChainSignature}
txEstimationExecutionStatus={txEstimationExecutionStatus}
/>
</Row>
</Container>
)
)}
{/* Tx Parameters */}
<TxParametersDetail
txParameters={txParameters}
onEdit={toggleEditMode}
parametersStatus={getParametersStatus()}
isTransactionCreation={isCreation}
isTransactionExecution={isExecution}
/>
</Container>
{txEstimationExecutionStatus === EstimationStatus.LOADING ? null : (
<TransactionFeesWrapper>
<TransactionFees
gasCostFormatted={gasCostFormatted}
isExecution={isExecution}
isCreation={isCreation}
isOffChainSignature={isOffChainSignature}
txEstimationExecutionStatus={txEstimationExecutionStatus}
/>
</TransactionFeesWrapper>
)}
</>
)
}
return (
<GenericModal
title={<ModalTitle title={app.name} iconUrl={app.iconUrl} />}
body={body}
footer={
<ModalFooterConfirmation
cancelText="Cancel"
handleCancel={handleTxRejection}
handleOk={confirmTransactions}
okDisabled={areTxsMalformed}
okText="Submit"
/>
}
onClose={handleTxRejection}
/>
<Modal description="Safe App transaction" title="Safe App transaction" open>
<EditableTxParameters
ethGasLimit={gasLimit}
ethGasPrice={gasPriceFormatted}
safeTxGas={gasEstimation.toString()}
parametersStatus={getParametersStatus()}
closeEditModalCallback={closeEditModalCallback}
>
{(txParameters, toggleEditMode) => (
<>
<ModalTitle title={app.name} iconUrl={app.iconUrl} onClose={handleTxRejection} />
<Hairline />
{body(txParameters, toggleEditMode)}
<ModalFooter align="center" grow>
<ModalFooterConfirmation
cancelText="Cancel"
handleCancel={handleTxRejection}
handleOk={() => confirmTransactions(txParameters)}
okDisabled={areTxsMalformed}
okText="Submit"
/>
</ModalFooter>
</>
)}
</EditableTxParameters>
</Modal>
)
}

View File

@ -26,7 +26,7 @@ export type StaticAppInfo = {
export const staticAppsList: Array<StaticAppInfo> = [
// 1inch
{
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmUDTSghr154kCCGguyA3cbG5HRVd2tQgNR7yD69bcsjm5`,
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmRWtuktjfU6WMAEJFgzBC4cUfqp3FF5uN9QoWb55SdGG5`,
disabled: false,
networks: [ETHEREUM_NETWORK.MAINNET],
},
@ -38,13 +38,13 @@ export const staticAppsList: Array<StaticAppInfo> = [
},
//Balancer Exchange
{
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmfPLXne1UrY399RQAcjD1dmBhQrPGZWgp311CDLLW3VTn`,
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmRb2VfPVYBrv6gi2zDywgVgTg3A19ZCRMqwL13Ez5f5AS`,
disabled: false,
networks: [ETHEREUM_NETWORK.MAINNET],
},
// Balancer Pool
{
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmaTucdZYLKTqaewwJduVMM8qfCDhyaEqjd8tBNae26K1J`,
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmVaxypk2FTyfcTS9oZKxmpQziPUTu2VRhhW7sso1mGysf`,
disabled: false,
networks: [ETHEREUM_NETWORK.MAINNET],
},
@ -57,9 +57,15 @@ export const staticAppsList: Array<StaticAppInfo> = [
},
// Compound
{ url: `${gnosisAppsUrl}/compound`, disabled: false, networks: [ETHEREUM_NETWORK.MAINNET, ETHEREUM_NETWORK.RINKEBY] },
// dHedge
{
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmaiemnumMaaK9wE1pbMfm3YSBUpcFNgDh3Bf6VZCZq57Q`,
disabled: false,
networks: [ETHEREUM_NETWORK.MAINNET],
},
// Idle
{
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmZ3oug89a3BaVqdJrJEA8CKmLF4M8snuAnphR6z1yq8V8`,
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmVkGHm6gfQumJhnRfFCh7m2oSYwLXb51EKHzChpcV9J3N`,
disabled: false,
networks: [ETHEREUM_NETWORK.MAINNET, ETHEREUM_NETWORK.RINKEBY],
},
@ -107,7 +113,7 @@ export const staticAppsList: Array<StaticAppInfo> = [
},
// Wallet-Connect
{
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmWwSuByB3B3hLU5ita3RQgiSEDYtBr5LjjDCRGb8YqLKF`,
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmT3VxxfFtfEcvq8AeaoFAyUGxePRa2zisvnxLTrQXU5Uf`,
disabled: false,
networks: [
ETHEREUM_NETWORK.MAINNET,

View File

@ -1,12 +1,49 @@
import React from 'react'
import { useSelector } from 'react-redux'
import { EthHashInfo } from '@gnosis.pm/safe-react-components'
import styled from 'styled-components'
import AddressInfo from 'src/components/AddressInfo'
import { getExplorerInfo, getNetworkInfo } from 'src/config'
import { safeSelector } from 'src/logic/safe/store/selectors'
import Paragraph from 'src/components/layout/Paragraph'
import Bold from 'src/components/layout/Bold'
import { border, xs } from 'src/theme/variables'
import Block from 'src/components/layout/Block'
const { nativeCoin } = getNetworkInfo()
const SafeInfo = () => {
const StyledBlock = styled(Block)`
font-size: 12px;
line-height: 1.08;
letter-spacing: -0.5px;
background-color: ${border};
width: fit-content;
padding: 5px 10px;
margin-top: ${xs};
margin-left: 40px;
border-radius: 3px;
`
const SafeInfo = (): React.ReactElement => {
const { address: safeAddress = '', ethBalance, name: safeName } = useSelector(safeSelector) || {}
return <AddressInfo ethBalance={ethBalance} safeAddress={safeAddress} safeName={safeName} />
return (
<>
<EthHashInfo
hash={safeAddress}
name={safeName}
explorerUrl={getExplorerInfo(safeAddress)}
showIdenticon
showCopyBtn
/>
{ethBalance && (
<StyledBlock>
<Paragraph noMargin>
Balance: <Bold data-testid="current-eth-balance">{`${ethBalance} ${nativeCoin.symbol}`}</Bold>
</Paragraph>
</StyledBlock>
)}
</>
)
}
export default SafeInfo

View File

@ -4,11 +4,12 @@ import cn from 'classnames'
import React, { Suspense, useEffect, useState } from 'react'
import Modal from 'src/components/Modal'
import { Erc721Transfer } from 'src/logic/safe/store/models/types/gateway'
import { CollectibleTx } from './screens/ReviewCollectible'
import { CustomTx } from './screens/ContractInteraction/ReviewCustomTx'
import { ContractInteractionTx } from './screens/ContractInteraction'
import { CustomTxProps } from './screens/ContractInteraction/SendCustomTx'
import { ReviewTxProp } from './screens/ReviewTx'
import { ReviewTxProp } from './screens/ReviewSendFundsTx'
import { NFTToken } from 'src/logic/collectibles/sources/collectibles.d'
import { SendCollectibleTxInfo } from './screens/SendCollectible'
@ -20,7 +21,7 @@ const SendCollectible = React.lazy(() => import('./screens/SendCollectible'))
const ReviewCollectible = React.lazy(() => import('./screens/ReviewCollectible'))
const ReviewTx = React.lazy(() => import('./screens/ReviewTx'))
const ReviewSendFundsTx = React.lazy(() => import('./screens/ReviewSendFundsTx'))
const ContractInteraction = React.lazy(() => import('./screens/ContractInteraction'))
@ -46,12 +47,23 @@ const useStyles = makeStyles({
},
})
export type TxType =
| 'chooseTxType'
| 'sendFunds'
| 'sendFundsReviewTx'
| 'contractInteraction'
| 'contractInteractionReview'
| 'reviewCustomTx'
| 'sendCollectible'
| 'reviewCollectible'
| ''
type Props = {
activeScreenType: string
activeScreenType: TxType
isOpen: boolean
onClose: () => void
recipientAddress?: string
selectedToken?: string | NFTToken
selectedToken?: string | NFTToken | Erc721Transfer
tokenAmount?: string
}
@ -64,7 +76,7 @@ const SendModal = ({
tokenAmount,
}: Props): React.ReactElement => {
const classes = useStyles()
const [activeScreen, setActiveScreen] = useState(activeScreenType || 'chooseTxType')
const [activeScreen, setActiveScreen] = useState<TxType>(activeScreenType || 'chooseTxType')
const [tx, setTx] = useState<unknown>({})
const [isABI, setIsABI] = useState(true)
@ -77,7 +89,7 @@ const SendModal = ({
const scalableModalSize = activeScreen === 'chooseTxType'
const handleTxCreation = (txInfo: SendCollectibleTxInfo) => {
setActiveScreen('reviewTx')
setActiveScreen('sendFundsReviewTx')
setTx(txInfo)
}
@ -118,18 +130,21 @@ const SendModal = ({
{activeScreen === 'chooseTxType' && (
<ChooseTxType onClose={onClose} recipientAddress={recipientAddress} setActiveScreen={setActiveScreen} />
)}
{activeScreen === 'sendFunds' && (
<SendFunds
onClose={onClose}
onNext={handleTxCreation}
onReview={handleTxCreation}
recipientAddress={recipientAddress}
selectedToken={selectedToken as string}
amount={tokenAmount}
/>
)}
{activeScreen === 'reviewTx' && (
<ReviewTx onClose={onClose} onPrev={() => setActiveScreen('sendFunds')} tx={tx as ReviewTxProp} />
{activeScreen === 'sendFundsReviewTx' && (
<ReviewSendFundsTx onClose={onClose} onPrev={() => setActiveScreen('sendFunds')} tx={tx as ReviewTxProp} />
)}
{activeScreen === 'contractInteraction' && isABI && (
<ContractInteraction
isABI={isABI}
@ -140,9 +155,11 @@ const SendModal = ({
onNext={handleContractInteractionCreation}
/>
)}
{activeScreen === 'contractInteractionReview' && isABI && tx && (
<ContractInteractionReview onClose={onClose} onPrev={() => setActiveScreen('contractInteraction')} tx={tx} />
)}
{activeScreen === 'contractInteraction' && !isABI && (
<SendCustomTx
initialValues={tx as CustomTxProps}
@ -153,9 +170,11 @@ const SendModal = ({
contractAddress={recipientAddress}
/>
)}
{activeScreen === 'reviewCustomTx' && (
<ReviewCustomTx onClose={onClose} onPrev={() => setActiveScreen('contractInteraction')} tx={tx as CustomTx} />
)}
{activeScreen === 'sendCollectible' && (
<SendCollectible
initialValues={tx}
@ -165,6 +184,7 @@ const SendModal = ({
selectedToken={selectedToken as NFTToken | undefined}
/>
)}
{activeScreen === 'reviewCollectible' && (
<ReviewCollectible
onClose={onClose}

View File

@ -10,8 +10,8 @@ import { FEATURES } from 'src/config/networks/network.d'
import { AddressBookEntry } from 'src/logic/addressBook/model/addressBook'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { filterContractAddressBookEntries, filterAddressEntries } from 'src/logic/addressBook/utils'
import { isValidEnsName } from 'src/logic/wallets/ethAddresses'
import { getAddressFromENS } from 'src/logic/wallets/getWeb3'
import { isValidEnsName, isValidCryptoDomainName } from 'src/logic/wallets/ethAddresses'
import { getAddressFromDomain } from 'src/logic/wallets/getWeb3'
import {
useTextFieldInputStyle,
useTextFieldLabelStyle,
@ -85,8 +85,11 @@ const BaseAddressBookInput = ({
}
// ENS-enabled resolve/validation
if (isFeatureEnabled(FEATURES.ENS_LOOKUP) && isValidEnsName(normalizedValue)) {
const address = await getAddressFromENS(normalizedValue).catch(() => normalizedValue)
if (
isFeatureEnabled(FEATURES.DOMAIN_LOOKUP) &&
(isValidEnsName(normalizedValue) || isValidCryptoDomainName(normalizedValue))
) {
const address = await getAddressFromDomain(normalizedValue)
const validatedAddress = validateAddress(address)

View File

@ -1,7 +1,7 @@
import IconButton from '@material-ui/core/IconButton'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import React from 'react'
import React, { ReactElement } from 'react'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
@ -15,7 +15,7 @@ interface HeaderProps {
title: string
}
const Header = ({ onClose, subTitle, title }: HeaderProps) => {
export const Header = ({ onClose, subTitle, title }: HeaderProps): ReactElement => {
const classes = useStyles()
return (
@ -30,5 +30,3 @@ const Header = ({ onClose, subTitle, title }: HeaderProps) => {
</Row>
)
}
export default Header

View File

@ -6,7 +6,7 @@ import MenuItem from '@material-ui/core/MenuItem'
import { MuiThemeProvider } from '@material-ui/core/styles'
import SearchIcon from '@material-ui/icons/Search'
import classNames from 'classnames'
import React from 'react'
import React, { ReactElement, useEffect, useState } from 'react'
import { useField, useFormState } from 'react-final-form'
import { AbiItem } from 'web3-utils'
@ -24,7 +24,7 @@ interface MethodsDropdownProps {
onChange: (method: AbiItem) => void
}
const MethodsDropdown = ({ onChange }: MethodsDropdownProps): React.ReactElement | null => {
export const MethodsDropdown = ({ onChange }: MethodsDropdownProps): ReactElement | null => {
const classes = useDropdownStyles({ buttonWidth: MENU_WIDTH })
const {
input: { value: abi },
@ -33,13 +33,14 @@ const MethodsDropdown = ({ onChange }: MethodsDropdownProps): React.ReactElement
const {
initialValues: { selectedMethod: selectedMethodByDefault },
} = useFormState({ subscription: { initialValues: true } })
const [selectedMethod, setSelectedMethod] = React.useState(selectedMethodByDefault ? selectedMethodByDefault : {})
const [methodsList, setMethodsList] = React.useState<AbiItemExtended[]>([])
const [methodsListFiltered, setMethodsListFiltered] = React.useState<AbiItemExtended[]>([])
const [anchorEl, setAnchorEl] = React.useState(null)
const [searchParams, setSearchParams] = React.useState('')
const [selectedMethod, setSelectedMethod] = useState(selectedMethodByDefault ? selectedMethodByDefault : {})
const [methodsList, setMethodsList] = useState<AbiItemExtended[]>([])
const [methodsListFiltered, setMethodsListFiltered] = useState<AbiItemExtended[]>([])
React.useEffect(() => {
const [anchorEl, setAnchorEl] = useState(null)
const [searchParams, setSearchParams] = useState('')
useEffect(() => {
if (abi) {
try {
setMethodsList(extractUsefulMethods(JSON.parse(abi)))
@ -49,7 +50,7 @@ const MethodsDropdown = ({ onChange }: MethodsDropdownProps): React.ReactElement
}
}, [abi])
React.useEffect(() => {
useEffect(() => {
setMethodsListFiltered(methodsList.filter(({ name }) => name?.toLowerCase().includes(searchParams.toLowerCase())))
}, [methodsList, searchParams])
@ -67,7 +68,11 @@ const MethodsDropdown = ({ onChange }: MethodsDropdownProps): React.ReactElement
handleClose()
}
return !valid || !abi || abi === NO_CONTRACT ? null : (
if (!valid || !abi || abi === NO_CONTRACT) {
return null
}
return (
<Row margin="sm">
<Col>
<MuiThemeProvider theme={DropdownListTheme}>
@ -145,5 +150,3 @@ const MethodsDropdown = ({ onChange }: MethodsDropdownProps): React.ReactElement
</Row>
)
}
export default MethodsDropdown

View File

@ -1,5 +1,5 @@
import { Checkbox } from '@gnosis.pm/safe-react-components'
import React from 'react'
import React, { ReactElement } from 'react'
import Col from 'src/components/layout/Col'
import Field from 'src/components/forms/Field'
@ -15,7 +15,7 @@ type Props = {
placeholder: string
}
const InputComponent = ({ type, keyValue, placeholder }: Props): React.ReactElement | null => {
export const InputComponent = ({ type, keyValue, placeholder }: Props): ReactElement | null => {
if (!type) {
return null
}
@ -67,5 +67,3 @@ const InputComponent = ({ type, keyValue, placeholder }: Props): React.ReactElem
}
}
}
export default InputComponent

View File

@ -1,13 +1,13 @@
import React from 'react'
import React, { ReactElement } from 'react'
import { useField } from 'react-final-form'
import Row from 'src/components/layout/Row'
import InputComponent from './InputComponent'
import { InputComponent } from './InputComponent'
import { generateFormFieldKey } from '../utils'
import { AbiItemExtended } from 'src/logic/contractInteraction/sources/ABIService'
const RenderInputParams = (): React.ReactElement | null => {
export const RenderInputParams = (): ReactElement | null => {
const {
meta: { valid: validABI },
} = useField('abi', { subscription: { valid: true, value: true } })
@ -31,5 +31,3 @@ const RenderInputParams = (): React.ReactElement | null => {
</>
)
}
export default RenderInputParams

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, { ReactElement } from 'react'
import { useField } from 'react-final-form'
import { makeStyles } from '@material-ui/core/styles'
import TextField from 'src/components/forms/TextField'
@ -17,7 +17,7 @@ const useStyles = makeStyles({
},
})
const RenderOutputParams = () => {
export const RenderOutputParams = (): ReactElement | null => {
const classes = useStyles()
const {
input: { value: method },
@ -27,7 +27,11 @@ const RenderOutputParams = () => {
}: any = useField('callResults', { subscription: { value: true } })
const multipleResults = !!method && method.outputs.length > 1
return results ? (
if (!results) {
return null
}
return (
<>
<Row align="left" margin="xs">
<Paragraph color="primary" size="lg" style={{ letterSpacing: '-0.5px' }}>
@ -57,7 +61,5 @@ const RenderOutputParams = () => {
)
})}
</>
) : null
)
}
export default RenderOutputParams

View File

@ -16,13 +16,20 @@ import { AbiItemExtended } from 'src/logic/contractInteraction/sources/ABIServic
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { getEthAsToken } from 'src/logic/tokens/utils/tokenHelpers'
import { styles } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style'
import Header from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/Header'
import { Header } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/Header'
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
import createTransaction from 'src/logic/safe/store/actions/createTransaction'
import { createTransaction } from 'src/logic/safe/store/actions/createTransaction'
import { TxParameters } from 'src/routes/safe/container/hooks/useTransactionParameters'
import { TxParametersDetail } from 'src/routes/safe/components/Transactions/helpers/TxParametersDetail'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import { generateFormFieldKey, getValueFromTxInputs } from '../utils'
import { useEstimateTransactionGas } from 'src/logic/hooks/useEstimateTransactionGas'
import {
generateFormFieldKey,
getValueFromTxInputs,
} from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils'
import { useEstimateTransactionGas, EstimationStatus } from 'src/logic/hooks/useEstimateTransactionGas'
import { TransactionFees } from 'src/components/TransactionsFees'
import { EditableTxParameters } from 'src/routes/safe/components/Transactions/helpers/EditableTxParameters'
const useStyles = makeStyles(styles)
@ -37,7 +44,9 @@ export type TransactionReviewType = {
type Props = {
onClose: () => void
onPrev: () => void
onEditTxParameters: () => void
tx: TransactionReviewType
txParameters: TxParameters
}
const { nativeCoin } = getNetworkInfo()
@ -46,40 +55,51 @@ const ContractInteractionReview = ({ onClose, onPrev, tx }: Props): React.ReactE
const classes = useStyles()
const dispatch = useDispatch()
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const [txParameters, setTxParameters] = useState<{
const [manualSafeTxGas, setManualSafeTxGas] = useState(0)
const [manualGasPrice, setManualGasPrice] = useState<string | undefined>()
const [txInfo, setTxInfo] = useState<{
txRecipient: string
txData: string
txAmount: string
}>({ txData: '', txAmount: '', txRecipient: '' })
const {
gasLimit,
gasEstimation,
gasPriceFormatted,
gasCostFormatted,
txEstimationExecutionStatus,
isExecution,
isOffChainSignature,
isCreation,
} = useEstimateTransactionGas({
txRecipient: txParameters?.txRecipient,
txAmount: txParameters?.txAmount,
txData: txParameters?.txData,
txRecipient: txInfo?.txRecipient,
txAmount: txInfo?.txAmount,
txData: txInfo?.txData,
safeTxGas: manualSafeTxGas,
manualGasPrice,
})
useEffect(() => {
setTxParameters({
setTxInfo({
txRecipient: tx.contractAddress as string,
txAmount: tx.value ? toTokenUnit(tx.value, nativeCoin.decimals) : '0',
txData: tx.data ? tx.data.trim() : '',
})
}, [tx.contractAddress, tx.value, tx.data, safeAddress])
const submitTx = async () => {
if (safeAddress && txParameters) {
const submitTx = async (txParameters: TxParameters) => {
if (safeAddress && txInfo) {
dispatch(
createTransaction({
safeAddress,
to: txParameters?.txRecipient,
valueInWei: txParameters?.txAmount,
txData: txParameters?.txData,
to: txInfo?.txRecipient,
valueInWei: txInfo?.txAmount,
txData: txInfo?.txData,
txNonce: txParameters.safeNonce,
safeTxGas: txParameters.safeTxGas ? Number(txParameters.safeTxGas) : undefined,
ethParameters: txParameters,
notifiedTransaction: TX_NOTIFICATION_TYPES.STANDARD_TX,
}),
)
@ -89,106 +109,138 @@ const ContractInteractionReview = ({ onClose, onPrev, tx }: Props): React.ReactE
onClose()
}
return (
<>
<Header onClose={onClose} subTitle="2 of 2" title="Contract Interaction" />
<Hairline />
<Block className={classes.formContainer}>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Contract Address
</Paragraph>
</Row>
<Row align="center" margin="md">
<AddressInfo safeAddress={tx.contractAddress as string} />
</Row>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Value
</Paragraph>
</Row>
<Row align="center" margin="md">
<Col xs={1}>
<Img alt="Ether" height={28} onError={setImageToPlaceholder} src={getEthAsToken('0').logoUri} />
</Col>
<Col layout="column" xs={11}>
<Block justify="left">
<Paragraph className={classes.value} noMargin size="md" style={{ margin: 0 }}>
{tx.value || 0}
{' ' + nativeCoin.name}
</Paragraph>
</Block>
</Col>
</Row>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Method
</Paragraph>
</Row>
<Row align="center" margin="md">
<Paragraph className={classes.value} size="md" style={{ margin: 0 }}>
{tx.selectedMethod?.name}
</Paragraph>
</Row>
{tx.selectedMethod?.inputs?.map(({ name, type }, index) => {
const key = generateFormFieldKey(type, tx.selectedMethod?.signatureHash || '', index)
const value: string = getValueFromTxInputs(key, type, tx)
const closeEditModalCallback = (txParameters: TxParameters) => {
const oldGasPrice = Number(gasPriceFormatted)
const newGasPrice = Number(txParameters.ethGasPrice)
const oldSafeTxGas = Number(gasEstimation)
const newSafeTxGas = Number(txParameters.safeTxGas)
return (
<React.Fragment key={key}>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
{name} ({type})
</Paragraph>
</Row>
<Row align="center" margin="md">
<Paragraph className={classes.value} noMargin size="md" style={{ margin: 0 }}>
{value}
</Paragraph>
</Row>
</React.Fragment>
)
})}
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Data (hex encoded)
</Paragraph>
</Row>
<Row align="center" margin="md">
<Col className={classes.outerData}>
<Row className={classes.data} size="md">
{tx.data}
if (newGasPrice && oldGasPrice !== newGasPrice) {
setManualGasPrice(txParameters.ethGasPrice)
}
if (newSafeTxGas && oldSafeTxGas !== newSafeTxGas) {
setManualSafeTxGas(newSafeTxGas)
}
}
return (
<EditableTxParameters
ethGasLimit={gasLimit}
ethGasPrice={gasPriceFormatted}
safeTxGas={gasEstimation.toString()}
closeEditModalCallback={closeEditModalCallback}
>
{(txParameters, toggleEditMode) => (
<>
<Header onClose={onClose} subTitle="2 of 2" title="Contract Interaction" />
<Hairline />
<Block className={classes.formContainer}>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Contract Address
</Paragraph>
</Row>
</Col>
</Row>
<Row>
<TransactionFees
gasCostFormatted={gasCostFormatted}
isExecution={isExecution}
isCreation={isCreation}
isOffChainSignature={isOffChainSignature}
txEstimationExecutionStatus={txEstimationExecutionStatus}
/>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button minWidth={140} onClick={onPrev}>
Back
</Button>
<Button
className={classes.submitButton}
color="primary"
data-testid="submit-tx-btn"
minWidth={140}
onClick={submitTx}
type="submit"
variant="contained"
>
Submit
</Button>
</Row>
</>
<Row align="center" margin="md">
<AddressInfo safeAddress={tx.contractAddress as string} />
</Row>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Value
</Paragraph>
</Row>
<Row align="center" margin="md">
<Col xs={1}>
<Img alt="Ether" height={28} onError={setImageToPlaceholder} src={getEthAsToken('0').logoUri} />
</Col>
<Col layout="column" xs={11}>
<Block justify="left">
<Paragraph className={classes.value} noMargin size="md" style={{ margin: 0 }}>
{tx.value || 0}
{' ' + nativeCoin.name}
</Paragraph>
</Block>
</Col>
</Row>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Method
</Paragraph>
</Row>
<Row align="center" margin="md">
<Paragraph className={classes.value} size="md" style={{ margin: 0 }}>
{tx.selectedMethod?.name}
</Paragraph>
</Row>
{tx.selectedMethod?.inputs?.map(({ name, type }, index) => {
const key = generateFormFieldKey(type, tx.selectedMethod?.signatureHash || '', index)
const value: string = getValueFromTxInputs(key, type, tx)
return (
<React.Fragment key={key}>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
{name} ({type})
</Paragraph>
</Row>
<Row align="center" margin="md">
<Paragraph className={classes.value} noMargin size="md" style={{ margin: 0 }}>
{value}
</Paragraph>
</Row>
</React.Fragment>
)
})}
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Data (hex encoded)
</Paragraph>
</Row>
<Row align="center" margin="md">
<Col className={classes.outerData}>
<Row className={classes.data} size="md">
{tx.data}
</Row>
</Col>
</Row>
{/* Tx Parameters */}
<TxParametersDetail
txParameters={txParameters}
onEdit={toggleEditMode}
isTransactionCreation={isCreation}
isTransactionExecution={isExecution}
/>
</Block>
<div className={classes.gasCostsContainer}>
<TransactionFees
gasCostFormatted={gasCostFormatted}
isExecution={isExecution}
isCreation={isCreation}
isOffChainSignature={isOffChainSignature}
txEstimationExecutionStatus={txEstimationExecutionStatus}
/>
</div>
<Row align="center" className={classes.buttonRow}>
<Button minWidth={140} onClick={onPrev}>
Back
</Button>
<Button
className={classes.submitButton}
color="primary"
data-testid="submit-tx-btn"
minWidth={140}
onClick={() => submitTx(txParameters)}
variant="contained"
disabled={txEstimationExecutionStatus === EstimationStatus.LOADING}
>
Submit
</Button>
</Row>
</>
)}
</EditableTxParameters>
)
}

Some files were not shown because too many files have changed in this diff Show More