Merge pull request #1657 from gnosis/release/v2.16.0

Release v2.16.0
This commit is contained in:
Daniel Sanchez 2020-12-01 11:10:25 +01:00 committed by GitHub
commit 08bd74229f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
161 changed files with 43202 additions and 1698 deletions

View File

@ -26,3 +26,7 @@ REACT_APP_APP_VERSION=$npm_package_version
# For Apps
REACT_APP_GNOSIS_APPS_URL=https://safe-apps.staging.gnosisdev.com
# Contracts Addresses
REACT_APP_SPENDING_LIMIT_MODULE_ADDRESS=0x9e9Bf12b5a66c0f0A7435835e0365477E121B110

View File

@ -1,7 +1,7 @@
!.eslintrc.js
build
config
contracts
/config
/contracts
flow-typed
flow-typed/npm
migrations
@ -9,5 +9,5 @@ node_modules
public
scripts
src/assets
src/config
src/types/contracts
test

View File

@ -1,6 +1,6 @@
{
"name": "safe-react",
"version": "2.15.1",
"version": "2.16.0",
"description": "Allowing crypto users manage funds in a safer way",
"website": "https://github.com/gnosis/safe-react#readme",
"bugs": {
@ -16,7 +16,7 @@
"email": "safe@gnosis.io"
},
"main": "public/electron.js",
"postinstall": "electron-builder install-app-deps",
"postinstall": "patch-package electron-builder install-app-deps",
"scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'",
"build-desktop": "cross-env REACT_APP_BUILD_FOR_DESKTOP=true REACT_APP_ENV=production yarn build-mainnet",
@ -26,11 +26,12 @@
"electron-build": "electron-builder --mac --windows --linux",
"electron-dev": "concurrently \"BROWSER=none yarn start\" \"wait-on http://localhost:3000 && electron .\"",
"format:staged": "lint-staged",
"generate-types": "yarn generate-types:contracts",
"generate-types": "yarn generate-types:contracts && yarn generate-types:spendingLimit",
"generate-types:contracts": "cross-env typechain --target=web3-v1 --outDir './src/types/contracts' './node_modules/@gnosis.pm/safe-contracts/build/contracts/*.json'",
"generate-types:spendingLimit": "cross-env typechain --target=web3-v1 --outDir './src/types/contracts' ./src/logic/contracts/artifacts/*.json",
"lint:check": "eslint './src/**/*.{js,jsx,ts,tsx}'",
"lint:fix": "yarn lint:check --fix",
"postinstall": "electron-builder install-app-deps && yarn generate-types",
"postinstall": "patch-package && electron-builder install-app-deps && yarn generate-types",
"preelectron-pack": "yarn build",
"prettier:check": "yarn prettier --check",
"prettier:fix": "yarn prettier --write",
@ -168,16 +169,16 @@
"dependencies": {
"@gnosis.pm/safe-apps-sdk": "https://github.com/gnosis/safe-apps-sdk.git#3f0689f",
"@gnosis.pm/safe-contracts": "1.1.1-dev.2",
"@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#03ff672d6f73366297986d58631f9582fe2ed4a3",
"@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#ff29c3c",
"@gnosis.pm/util-contracts": "2.0.6",
"@ledgerhq/hw-transport-node-hid-singleton": "5.29.0",
"@ledgerhq/hw-transport-node-hid-singleton": "5.30.0",
"@material-ui/core": "4.11.0",
"@material-ui/icons": "4.9.1",
"@material-ui/lab": "4.0.0-alpha.56",
"@openzeppelin/contracts": "3.1.0",
"@sentry/react": "^5.27.3",
"@sentry/tracing": "^5.27.3",
"@truffle/contract": "4.2.28",
"@sentry/react": "^5.27.0",
"@sentry/tracing": "^5.27.0",
"@truffle/contract": "4.2.30",
"async-sema": "^3.1.0",
"axios": "0.21.0",
"bignumber.js": "9.0.1",
@ -209,25 +210,25 @@
"material-ui-search-bar": "^1.0.0",
"notistack": "https://github.com/gnosis/notistack.git#v0.9.4",
"qrcode.react": "1.0.0",
"query-string": "6.13.6",
"query-string": "6.13.7",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-final-form": "^6.5.2",
"react-final-form-listeners": "^1.0.2",
"react-ga": "3.2.0",
"react-ga": "3.2.1",
"react-hot-loader": "4.13.0",
"react-qr-reader": "^2.2.1",
"react-redux": "7.2.1",
"react-redux": "7.2.2",
"react-router-dom": "5.2.0",
"react-scripts": "^3.4.3",
"react-window": "^1.8.5",
"react-window": "^1.8.6",
"recompose": "^0.30.0",
"redux": "4.0.5",
"redux-actions": "^2.6.5",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"semver": "7.3.2",
"styled-components": "^5.2.0",
"styled-components": "^5.2.1",
"web3": "1.2.11",
"web3-core": "^1.2.11",
"web3-eth-contract": "^1.2.11",
@ -238,7 +239,7 @@
"@storybook/addon-actions": "^5.3.19",
"@storybook/addon-links": "^5.3.19",
"@storybook/addons": "^5.3.19",
"@storybook/preset-create-react-app": "^3.1.4",
"@storybook/preset-create-react-app": "^3.1.5",
"@storybook/react": "^5.3.19",
"@testing-library/jest-dom": "5.11.5",
"@testing-library/react": "10.4.9",
@ -258,24 +259,25 @@
"cross-env": "^7.0.2",
"dotenv": "^8.2.0",
"dotenv-expand": "^5.1.0",
"electron": "9.3.3",
"electron": "^9.3.3",
"electron-builder": "22.9.1",
"electron-notarize": "1.0.0",
"eslint": "6.8.0",
"eslint-config-prettier": "6.14.0",
"eslint-plugin-import": "2.22.1",
"eslint-config-prettier": "^6.14.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "^7.21.5",
"eslint-plugin-sort-destructure-keys": "1.3.5",
"eslint-plugin-sort-destructure-keys": "^1.3.5",
"ethereumjs-abi": "0.6.8",
"husky": "^4.3.0",
"lint-staged": "^10.5.1",
"node-sass": "^4.14.1",
"prettier": "2.1.2",
"patch-package": "^6.2.2",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.1.2",
"react-app-rewired": "^2.1.6",
"react-docgen-typescript-loader": "^3.7.2",
"typechain": "^2.0.0",
"typechain": "^4.0.0",
"typescript": "4.0.5",
"wait-on": "5.2.0"
}

View File

@ -0,0 +1,19 @@
diff --git a/node_modules/web3-eth/src/getNetworkType.js b/node_modules/web3-eth/src/getNetworkType.js
index 3be3a20..88edbd9 100644
--- a/node_modules/web3-eth/src/getNetworkType.js
+++ b/node_modules/web3-eth/src/getNetworkType.js
@@ -63,6 +63,14 @@ var getNetworkType = function (callback) {
id === 42) {
returnValue = 'kovan';
}
+ if (genesis.hash === '0x0b6d3e680af2fc525392c720666cce58e3d8e6fe75ba4b48cb36bcc69039229b' &&
+ id === 246) {
+ returnValue = 'energyWebChain';
+ }
+ if (genesis.hash === '0xebd8b413ca7b7f84a8dd20d17519ce2b01954c74d94a0a739a3e416abe0e43e5' &&
+ id === 73799) {
+ returnValue = 'volta';
+ }
if (_.isFunction(callback)) {
callback(null, returnValue);

View File

@ -0,0 +1,15 @@
diff --git a/node_modules/web3-eth-ens/src/config.js b/node_modules/web3-eth-ens/src/config.js
index b12e5f5..e0abf2d 100644
--- a/node_modules/web3-eth-ens/src/config.js
+++ b/node_modules/web3-eth-ens/src/config.js
@@ -30,7 +30,9 @@ var config = {
main: "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e",
ropsten: "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e",
rinkeby: "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e",
- goerli: "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"
+ goerli: "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e",
+ energyWebChain: "0x0A6d64413c07E10E890220BBE1c49170080C6Ca0",
+ volta: "0xd7CeF70Ba7efc2035256d828d5287e2D285CD1ac",
},
// These ids obtained at ensdomains docs:
// https://docs.ens.domains/contract-developer-guide/writing-a-resolver

View File

@ -85,7 +85,7 @@ function getOpenedWindow(url, options) {
function createWindow(port = DEFAULT_PORT) {
mainWindow = new BrowserWindow({
show: false,
width: 1024,
width: 1366,
height: 768,
webPreferences: {
preload: path.join(__dirname, '../scripts/preload.js'),

View File

Before

Width:  |  Height:  |  Size: 690 B

After

Width:  |  Height:  |  Size: 690 B

View File

@ -130,10 +130,13 @@ const SafeHeader = ({
return (
<>
{/* Network */}
<StyledTextLabel size="sm" networkInfo={networkInfo}>
{networkInfo.label}
</StyledTextLabel>
<Container>
{/* Identicon */}
<IdenticonContainer>
<FlexSpacer />
<Identicon address={address} size="lg" />
@ -142,6 +145,7 @@ const SafeHeader = ({
</UnStyledButton>
</IdenticonContainer>
{/* SafeInfo */}
<Text size="xl">{safeName}</Text>
<StyledEthHashInfo hash={address} shortenHash={4} textSize="sm" />
<IconContainer>

View File

@ -16,7 +16,7 @@ const HelpContainer = styled.div`
const HelpCenterLink = styled.a`
height: 30px;
width: 166px;
padding: 6px 0 0 16px;
padding: 6px 0 8px 16px;
margin: 14px 0px;
text-decoration: none;
display: block;

View File

@ -19,7 +19,7 @@ const useSidebarItems = (): ListItemType[] => {
}
return useMemo((): ListItemType[] => {
if (!matchSafe || !matchSafeWithAddress) {
if (!matchSafe || !matchSafeWithAddress || !featuresEnabled) {
return []
}
@ -63,7 +63,7 @@ const useSidebarItems = (): ListItemType[] => {
},
...safeSidebar,
]
}, [matchSafe, matchSafeWithAction, matchSafeWithAddress, safeAppsEnabled])
}, [matchSafe, matchSafeWithAction, matchSafeWithAddress, safeAppsEnabled, featuresEnabled])
}
export { useSidebarItems }

View File

@ -6,53 +6,62 @@ import Header from './Header'
import Footer from './Footer'
import Sidebar from './Sidebar'
const Grid = styled.div`
height: 100%;
overflow: auto;
const Container = styled.div`
height: 100vh;
width: 100vw;
display: flex;
flex-direction: column;
background-color: ${({ theme }) => theme.colors.background};
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: 54px 1fr;
grid-template-areas:
'topbar topbar'
'sidebar body';
`
const GridTopbarWrapper = styled.nav`
const HeaderWrapper = styled.nav`
height: 54px;
width: 100%;
z-index: 1;
background-color: white;
box-shadow: 0 2px 4px 0 rgba(212, 212, 211, 0.59);
border-bottom: 2px solid ${({ theme }) => theme.colors.separator};
z-index: 999;
grid-area: topbar;
box-shadow: 0 0 4px 0 rgba(212, 212, 211, 0.59);
`
const GridSidebarWrapper = styled.aside`
width: 200px;
padding: 62px 8px 0 8px;
const BodyWrapper = styled.div`
height: calc(100% - 54px);
width: 100%;
display: flex;
flex-direction: row;
`
const SidebarWrapper = styled.aside`
height: 100%;
width: 200px;
display: flex;
flex-direction: column;
z-index: 1;
padding: 8px 8px 0 8px;
background-color: ${({ theme }) => theme.colors.white};
border-right: 2px solid ${({ theme }) => theme.colors.separator};
`
const ContentWrapper = styled.section`
width: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
position: fixed;
grid-area: sidebar;
`
overflow-x: auto;
const GridBodyWrapper = styled.section`
margin: 0 16px 0 16px;
grid-area: body;
display: flex;
flex-direction: column;
align-content: stretch;
`
padding: 0 16px;
export const BodyWrapper = styled.div`
flex: 1 100%;
`
> :nth-child(1) {
flex-grow: 1;
width: 100%;
align-items: center;
justify-content: center;
}
export const FooterWrapper = styled.footer`
margin: 0 16px;
> :nth-child(2) {
width: 100%;
height: 59px;
}
`
type Props = {
@ -77,11 +86,12 @@ const Layout: React.FC<Props> = ({
children,
sidebarItems,
}): React.ReactElement => (
<Grid>
<GridTopbarWrapper>
<Container>
<HeaderWrapper>
<Header />
</GridTopbarWrapper>
<GridSidebarWrapper>
</HeaderWrapper>
<BodyWrapper>
<SidebarWrapper>
<Sidebar
items={sidebarItems}
safeAddress={safeAddress}
@ -92,14 +102,13 @@ const Layout: React.FC<Props> = ({
onReceiveClick={onReceiveClick}
onNewTransactionClick={onNewTransactionClick}
/>
</GridSidebarWrapper>
<GridBodyWrapper>
<BodyWrapper>{children}</BodyWrapper>
<FooterWrapper>
</SidebarWrapper>
<ContentWrapper>
<div>{children}</div>
<Footer />
</FooterWrapper>
</GridBodyWrapper>
</Grid>
</ContentWrapper>
</BodyWrapper>
</Container>
)
export default Layout

View File

@ -8,7 +8,6 @@ import { fetchProvider, removeProvider } from 'src/logic/wallets/store/actions'
import transactionDataCheck from 'src/logic/wallets/transactionDataCheck'
import { getSupportedWallets } from 'src/logic/wallets/utils/walletList'
import { store } from 'src/store'
import { BLOCKNATIVE_KEY } from 'src/utils/constants'
const networkId = getNetworkId()
@ -18,7 +17,6 @@ let providerName
const wallets = getSupportedWallets()
export const onboard = Onboard({
dappId: BLOCKNATIVE_KEY,
networkId: networkId,
subscriptions: {
wallet: (wallet) => {

View File

@ -0,0 +1,9 @@
import styled from 'styled-components'
export const LoadingContainer = styled.div`
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
`

View File

@ -1,11 +1,12 @@
import Modal from '@material-ui/core/Modal'
import { withStyles } from '@material-ui/core/styles'
import { makeStyles, createStyles } from '@material-ui/core/styles'
import cn from 'classnames'
import * as React from 'react'
import React, { ReactElement, ReactNode } from 'react'
import { sm } from 'src/theme/variables'
const styles = () => ({
const useStyles = makeStyles(
createStyles({
root: {
alignItems: 'center',
justifyContent: 'center',
@ -16,7 +17,7 @@ const styles = () => ({
position: 'absolute',
top: '120px',
width: '500px',
height: '530px',
height: '540px',
borderRadius: sm,
backgroundColor: '#ffffff',
boxShadow: '0 0 5px 0 rgba(74, 85, 121, 0.5)',
@ -26,18 +27,34 @@ const styles = () => ({
display: 'flex',
flexDirection: 'column',
},
})
}),
)
interface GnoModalProps {
children: ReactNode
description: string
// type copied from Material-UI Modal's `close` prop
handleClose?: {
bivarianceHack(event: Record<string, unknown>, reason: 'backdropClick' | 'escapeKeyDown'): void
}['bivarianceHack']
modalClassName?: string
open: boolean
paperClassName?: string
title: string
}
const GnoModal = ({
children,
classes,
description,
handleClose,
modalClassName,
open,
paperClassName,
title,
}: any) => (
}: GnoModalProps): ReactElement => {
const classes = useStyles()
return (
<Modal
aria-describedby={description}
aria-labelledby={title}
@ -48,5 +65,6 @@ const GnoModal = ({
<div className={cn(classes.paper, paperClassName)}>{children}</div>
</Modal>
)
}
export default withStyles(styles as any)(GnoModal)
export default GnoModal

View File

@ -1,9 +1,8 @@
import { makeStyles } from '@material-ui/core/styles'
import { useState } from 'react'
import * as React from 'react'
import React, { ReactElement, useState } from 'react'
import QRIcon from 'src/assets/icons/qrcode.svg'
import ScanQRModal from 'src/components/ScanQRModal'
import { ScanQRModal } from 'src/components/ScanQRModal'
import Img from 'src/components/layout/Img'
const useStyles = makeStyles({
@ -12,7 +11,11 @@ const useStyles = makeStyles({
},
})
export const ScanQRWrapper = (props) => {
type Props = {
handleScan: (dataResult: string, closeQrModal: () => void) => void
}
export const ScanQRWrapper = ({ handleScan }: Props): ReactElement => {
const classes = useStyles()
const [qrModalOpen, setQrModalOpen] = useState(false)
@ -25,7 +28,7 @@ export const ScanQRWrapper = (props) => {
}
const onScanFinished = (value) => {
props.handleScan(value, closeQrModal)
handleScan(value, closeQrModal)
}
return (
@ -34,9 +37,7 @@ export const ScanQRWrapper = (props) => {
alt="Scan QR"
className={classes.qrCodeBtn}
height={20}
onClick={() => {
openQrModal()
}}
onClick={() => openQrModal()}
role="button"
src={QRIcon}
testId="qr-icon"

View File

@ -1,6 +1,6 @@
import CircularProgress from '@material-ui/core/CircularProgress'
import IconButton from '@material-ui/core/IconButton'
import { withStyles } from '@material-ui/core/styles'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import * as React from 'react'
import QrReader from 'react-qr-reader'
@ -15,11 +15,21 @@ import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { useEffect, useState } from 'react'
const { useEffect, useState } = React
const useStyles = makeStyles(styles)
const ScanQRModal = ({ classes, isOpen, onClose, onScan }) => {
const [hasWebcam, setHasWebcam] = useState<any>(null)
type Props = {
isOpen: boolean
onClose: () => void
onScan: (value: string) => void
}
export const ScanQRModal = ({ isOpen, onClose, onScan }: Props): React.ReactElement => {
const classes = useStyles()
const [useWebcam, setUseWebcam] = useState<boolean | null>(null)
const [fileUploadModalOpen, setFileUploadModalOpen] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const scannerRef: any = React.createRef()
const openImageDialog = React.useCallback(() => {
scannerRef.current.openImageDialog()
@ -28,22 +38,35 @@ const ScanQRModal = ({ classes, isOpen, onClose, onScan }) => {
useEffect(() => {
checkWebcam(
() => {
setHasWebcam(true)
setUseWebcam(true)
},
() => {
setHasWebcam(false)
setUseWebcam(false)
},
)
}, [])
useEffect(() => {
// this fires only when the hasWebcam changes to false (null > false (user doesn't have webcam)
// , true > false (user switched from webcam to file upload))
// Doesn't fire on re-render
if (hasWebcam === false) {
if (useWebcam === false && !fileUploadModalOpen && !error) {
setFileUploadModalOpen(true)
openImageDialog()
}
}, [hasWebcam, openImageDialog])
}, [useWebcam, openImageDialog, fileUploadModalOpen, setFileUploadModalOpen, error])
const onFileScannedResolve = (error: string | null, successData: string | null) => {
if (successData) {
onScan(successData)
}
if (error) {
console.error('Error uploading file', error)
setError(`The QR could not be read`)
}
if (!useWebcam) {
setError(`The QR could not be read`)
}
setFileUploadModalOpen(false)
}
return (
<Modal description="Receive Tokens Form" handleClose={onClose} open={isOpen} title="Receive Tokens">
@ -57,19 +80,16 @@ const ScanQRModal = ({ classes, isOpen, onClose, onScan }) => {
</Row>
<Hairline />
<Col className={classes.detailsContainer} layout="column" middle="xs">
{hasWebcam === null ? (
{error}
{useWebcam === null ? (
<Block className={classes.loaderContainer} justify="center">
<CircularProgress />
</Block>
) : (
<QrReader
legacyMode={!hasWebcam}
onError={(err) => {
console.error(err)
}}
onScan={(data) => {
if (data) onScan(data)
}}
legacyMode={!useWebcam}
onError={(err) => onFileScannedResolve(err, null)}
onScan={(data) => onFileScannedResolve(null, data)}
ref={scannerRef}
style={{ width: '400px', height: '400px' }}
/>
@ -85,11 +105,9 @@ const ScanQRModal = ({ classes, isOpen, onClose, onScan }) => {
color="primary"
minWidth={154}
onClick={() => {
if (hasWebcam) {
setHasWebcam(false)
} else {
openImageDialog()
}
setUseWebcam(false)
setError(null)
setFileUploadModalOpen(false)
}}
variant="contained"
>
@ -99,5 +117,3 @@ const ScanQRModal = ({ classes, isOpen, onClose, onScan }) => {
</Modal>
)
}
export default withStyles(styles as any)(ScanQRModal)

View File

@ -1,6 +1,7 @@
import { background, lg, secondaryText, sm } from 'src/theme/variables'
import { createStyles } from '@material-ui/core'
export const styles = () => ({
export const styles = createStyles({
heading: {
padding: lg,
justifyContent: 'space-between',

View File

@ -169,13 +169,19 @@ describe('Forms > Validators', () => {
it('Returns undefined for an address not contained in the passed array', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe']
expect(uniqueAddress(addresses)('0xe7e3272a84cf3fe180345b9f7234ba705eB5E2CA')).toBeUndefined()
expect(uniqueAddress(addresses)()).toBeUndefined()
})
it('Returns an error message for an address already contained in the array', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe']
it('Returns an error message for an array with duplicated values', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', '0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe']
expect(uniqueAddress(addresses)(addresses[0])).toEqual(ADDRESS_REPEATED_ERROR)
expect(uniqueAddress(addresses)()).toEqual(ADDRESS_REPEATED_ERROR)
})
it('Returns an error message for an array with duplicated checksum and not checksum values', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', '0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae']
expect(uniqueAddress(addresses)()).toEqual(ADDRESS_REPEATED_ERROR)
})
})

View File

@ -1,13 +1,11 @@
import { List } from 'immutable'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import memoize from 'lodash.memoize'
import { isFeatureEnabled } from 'src/config'
import { FEATURES } from 'src/config/networks/network.d'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { List } from 'immutable'
type ValidatorReturnType = string | undefined
type GenericValidatorType = (...args: unknown[]) => ValidatorReturnType
export type GenericValidatorType = (...args: unknown[]) => ValidatorReturnType
type AsyncValidator = (...args: unknown[]) => Promise<ValidatorReturnType>
export type Validator = GenericValidatorType | AsyncValidator
@ -89,13 +87,18 @@ export const minMaxLength = (minLen: number, maxLen: number) => (value: string):
export const ADDRESS_REPEATED_ERROR = 'Address already introduced'
export const uniqueAddress = (addresses: string[] | List<string>): GenericValidatorType =>
memoize(
(value: string): ValidatorReturnType => {
const addressAlreadyExists = addresses.some((address) => sameAddress(value, address))
return addressAlreadyExists ? ADDRESS_REPEATED_ERROR : undefined
},
)
export const uniqueAddress = (addresses: string[] | List<string>): GenericValidatorType => (): ValidatorReturnType => {
// @ts-expect-error both list and array have signatures for map but TS thinks they're not compatible
const lowercaseAddresses = addresses.map((address) => address.toLowerCase())
const uniqueAddresses = new Set(lowercaseAddresses)
const lengthPropName = 'size' in addresses ? 'size' : 'length'
if (uniqueAddresses.size !== addresses?.[lengthPropName]) {
return ADDRESS_REPEATED_ERROR
}
return undefined
}
export const composeValidators = (...validators: Validator[]) => (value: unknown): ValidatorReturnType =>
validators.reduce(

View File

@ -80,7 +80,7 @@ describe('Config Services', () => {
jest.mock('src/utils/constants', () => ({
NODE_ENV: 'production',
NETWORK: 'MAINNET',
APP_ENV: 'production'
APP_ENV: 'production',
}))
const { getTxServiceUrl, getGnosisSafeAppsUrl } = require('src/config')
const TX_SERVICE_URL = mainnet.environment.production.txServiceUrl
@ -100,7 +100,7 @@ describe('Config Services', () => {
jest.mock('src/utils/constants', () => ({
NODE_ENV: 'production',
NETWORK: 'XDAI',
APP_ENV: 'production'
APP_ENV: 'production',
}))
const { getTxServiceUrl, getGnosisSafeAppsUrl } = require('src/config')
const TX_SERVICE_URL = xdai.environment.production.txServiceUrl

View File

@ -32,9 +32,9 @@ const getCurrentEnvironment = (): string => {
}
type NetworkSpecificConfiguration = EnvironmentSettings & {
network: NetworkSettings,
disabledFeatures?: SafeFeatures,
disabledWallets?: Wallets,
network: NetworkSettings
disabledFeatures?: SafeFeatures
disabledWallets?: Wallets
}
const configuration = (): NetworkSpecificConfiguration => {
@ -60,7 +60,7 @@ const configuration = (): NetworkSpecificConfiguration => {
...networkBaseConfig,
network: configFile.network,
disabledFeatures: configFile.disabledFeatures,
disabledWallets: configFile.disabledWallets
disabledWallets: configFile.disabledWallets,
}
}

View File

@ -41,10 +41,9 @@ describe('Networks config files test', () => {
return
}
const environmentConfigKeys = Object
.keys(networkConfigElement)
.filter((environmentConfigKey) =>
environmentConfigKey.endsWith('Uri') && !!networkConfigElement[environmentConfigKey]
const environmentConfigKeys = Object.keys(networkConfigElement).filter(
(environmentConfigKey) =>
environmentConfigKey.endsWith('Uri') && !!networkConfigElement[environmentConfigKey],
)
// Then
@ -53,7 +52,10 @@ describe('Networks config files test', () => {
const isValid = isValidURL(networkConfigElementUri)
if (!isValid) {
console.log(`Invalid URI in "${networkFileName}" at ${environment}.${environmentConfigKey}:`, networkConfigElementUri)
console.log(
`Invalid URI in "${networkFileName}" at ${environment}.${environmentConfigKey}:`,
networkConfigElementUri,
)
}
expect(isValid).toBeTruthy()

View File

@ -1,5 +1,5 @@
import EwcLogo from 'src/config/assets/token_ewc.svg'
import { EnvironmentSettings, ETHEREUM_NETWORK, FEATURES, NetworkConfig, WALLETS } from 'src/config/networks/network.d'
import { EnvironmentSettings, ETHEREUM_NETWORK, NetworkConfig, WALLETS } from 'src/config/networks/network.d'
// @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
@ -61,9 +61,6 @@ const mainnet: NetworkConfig = {
WALLETS.AUTHEREUM,
WALLETS.LATTICE,
],
disabledFeatures: [
FEATURES.ENS_LOOKUP,
],
}
export default mainnet

View File

@ -11,5 +11,5 @@ export default {
rinkeby,
xdai,
energy_web_chain,
volta
volta,
}

View File

@ -42,7 +42,7 @@ const mainnet: NetworkConfig = {
decimals: 18,
logoUri: EtherLogo,
},
}
},
}
export default mainnet

View File

@ -51,12 +51,12 @@ export enum ETHEREUM_NETWORK {
export type NetworkSettings = {
// TODO: id now seems to be unnecessary
id: ETHEREUM_NETWORK,
backgroundColor: string,
textColor: string,
label: string,
isTestNet: boolean,
nativeCoin: Token,
id: ETHEREUM_NETWORK
backgroundColor: string
textColor: string
label: string
isTestNet: boolean
nativeCoin: Token
}
// something around this to display or not some critical sections in the app, depending on the network support
@ -73,10 +73,12 @@ export type GasPriceOracle = {
gasParameter: string
}
type GasPrice = {
type GasPrice =
| {
gasPrice: number
gasPriceOracle?: GasPriceOracle
} | {
}
| {
gasPrice?: number
// for infura there's a REST API Token required stored in: `REACT_APP_INFURA_TOKEN`
gasPriceOracle: GasPriceOracle

View File

@ -1,5 +1,5 @@
import EwcLogo from 'src/config/assets/token_ewc.svg'
import { EnvironmentSettings, ETHEREUM_NETWORK, FEATURES, NetworkConfig, WALLETS } from 'src/config/networks/network.d'
import { EnvironmentSettings, ETHEREUM_NETWORK, NetworkConfig, WALLETS } from 'src/config/networks/network.d'
const baseConfig: EnvironmentSettings = {
txServiceUrl: 'https://safe-transaction.volta.gnosis.io/api/v1',
@ -53,14 +53,10 @@ const mainnet: NetworkConfig = {
WALLETS.TORUS,
WALLETS.TRUST,
WALLETS.UNILOGIN,
WALLETS.WALLET_CONNECT,
WALLETS.WALLET_LINK,
WALLETS.AUTHEREUM,
WALLETS.LATTICE,
],
disabledFeatures: [
FEATURES.ENS_LOOKUP,
],
}
export default mainnet

View File

@ -14,12 +14,11 @@ const baseConfig: EnvironmentSettings = {
const xDai: NetworkConfig = {
environment: {
staging: {
...baseConfig
...baseConfig,
},
production: {
...baseConfig,
safeAppsUrl: 'https://apps-xdai.gnosis-safe.io',
},
},
network: {
@ -52,9 +51,7 @@ const xDai: NetworkConfig = {
WALLETS.AUTHEREUM,
WALLETS.LATTICE,
],
disabledFeatures: [
FEATURES.ENS_LOOKUP,
],
disabledFeatures: [FEATURES.ENS_LOOKUP],
}
export default xDai

View File

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

View File

@ -8,6 +8,10 @@ import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
export const addressBookSelector = (state: AppReduxState): AddressBookState => state[ADDRESS_BOOK_REDUCER_ID]
export const addressBookAddressesListSelector = createSelector(addressBookSelector, (addressBook): string[] => {
return addressBook.map((entry) => entry.address)
})
export const getNameFromAddressBookSelector = createSelector(
addressBookSelector,
(_, address) => address,

View File

@ -2,7 +2,6 @@ import { List } from 'immutable'
import {
checkIfEntryWasDeletedFromAddressBook,
getAddressBookFromStorage,
getAddressesListFromAddressBook,
getNameFromAddressBook,
getOwnersWithNameFromAddressBook,
isValidAddressBookName,
@ -28,24 +27,6 @@ const getMockOldAddressBookEntry = ({ address = '', name = '', isOwner = false }
}
}
describe('getAddressesListFromAdbk', () => {
const entry1 = getMockAddressBookEntry('123456', 'test1')
const entry2 = getMockAddressBookEntry('78910', 'test2')
const entry3 = getMockAddressBookEntry('4781321', 'test3')
it('It should returns the list of addresses within the addressBook given a safeAddressBook', () => {
// given
const safeAddressBook = [entry1, entry2, entry3]
const expectedResult = [entry1.address, entry2.address, entry3.address]
// when
const result = getAddressesListFromAddressBook(safeAddressBook)
// then
expect(result).toStrictEqual(expectedResult)
})
})
describe('getNameFromSafeAddressBook', () => {
const entry1 = getMockAddressBookEntry('123456', 'test1')
const entry2 = getMockAddressBookEntry('78910', 'test2')

View File

@ -56,9 +56,6 @@ export const saveAddressBook = async (addressBook: AddressBookState): Promise<vo
}
}
export const getAddressesListFromAddressBook = (addressBook: AddressBookState): string[] =>
addressBook.map((entry) => entry.address)
type GetNameFromAddressBookOptions = {
filterOnlyValidName: boolean
}

View File

@ -1,4 +1,4 @@
import { getNetworkId } from 'src/config'
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'
@ -18,6 +18,14 @@ export const CK_ADDRESS = {
[ETHEREUM_NETWORK.RINKEBY]: '0x16baf0de678e52367adc69fd067e5edd1d33e3bf',
}
// Note: xDAI ENS is missing, once we have it we need to add it here
const ENS_CONTRACT_ADDRESS = {
[ETHEREUM_NETWORK.MAINNET]: '0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85',
[ETHEREUM_NETWORK.RINKEBY]: '0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85',
[ETHEREUM_NETWORK.ENERGY_WEB_CHAIN]: '0x0A6d64413c07E10E890220BBE1c49170080C6Ca0',
[ETHEREUM_NETWORK.VOLTA]: '0xd7CeF70Ba7efc2035256d828d5287e2D285CD1ac',
}
// safeTransferFrom(address,address,uint256)
export const SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH = '42842e0e'
@ -50,12 +58,11 @@ export const getERC721Symbol = async (contractAddress: string): Promise<string>
try {
const ERC721token = await getERC721TokenContract()
const tokenInstance = await ERC721token.at(contractAddress)
tokenSymbol = tokenInstance.symbol()
tokenSymbol = await tokenInstance.symbol()
} catch (err) {
// If the contract address is an ENS token contract, we know that the ERC721 standard is not proper implemented
// The method symbol() is missing
const ENS_TOKEN_CONTRACT = '0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85'
if (sameAddress(contractAddress, ENS_TOKEN_CONTRACT)) {
if (isENSContract(contractAddress)) {
return 'ENS'
}
console.error(`Failed to retrieve token symbol for ERC721 token ${contractAddress}`)
@ -64,6 +71,11 @@ export const getERC721Symbol = async (contractAddress: string): Promise<string>
return tokenSymbol
}
export const isENSContract = (contractAddress: string): boolean => {
const { id } = getNetworkInfo()
return sameAddress(contractAddress, ENS_CONTRACT_ADDRESS[id])
}
/**
* Verifies if the provided contract is a valid ERC721
* @param {string} contractAddress

View File

@ -0,0 +1,47 @@
import axios from 'axios'
import { getTxServiceUrl } from 'src/config'
import memoize from 'lodash.memoize'
export enum MasterCopyDeployer {
GNOSIS = 'Gnosis',
CIRCLES = 'Circles',
}
type MasterCopyFetch = {
address: string
version: string
}
export type MasterCopy = {
address: string
version: string
deployer: MasterCopyDeployer
deployerRepoUrl: string
}
const extractMasterCopyInfo = (mc: MasterCopyFetch): MasterCopy => {
const isCircles = mc.version.toLowerCase().includes(MasterCopyDeployer.CIRCLES.toLowerCase())
const dashIndex = mc.version.indexOf('-')
const masterCopy = {
address: mc.address,
version: !isCircles ? mc.version : mc.version.substring(0, dashIndex),
deployer: !isCircles ? MasterCopyDeployer.GNOSIS : MasterCopyDeployer.CIRCLES,
deployerRepoUrl: !isCircles
? 'https://github.com/gnosis/safe-contracts/releases'
: 'https://github.com/CirclesUBI/safe-contracts/releases',
}
return masterCopy
}
export const fetchMasterCopies = memoize(
async (): Promise<MasterCopy[] | undefined> => {
const url = `${getTxServiceUrl()}/about/master-copies/`
try {
const res = await axios.get<{ address: string; version: string }[]>(url)
return res.data.map(extractMasterCopyInfo)
} catch (error) {
console.error('Fetching data from master-copies errored', error)
}
},
)

File diff suppressed because one or more lines are too long

View File

@ -19,20 +19,28 @@ type MethodsArgsType = Array<string | number>
address: string
batch?: BatchRequest
context?: unknown
methods: Array<string | {method: string, type?: string, args: MethodsArgsType }>
methods: Array<string | { method: string; type?: string; args: MethodsArgsType }>
}
const generateBatchRequests = <ReturnValues>({ abi, address, batch, context, methods }: Props): Promise<ReturnValues> => {
const generateBatchRequests = <ReturnValues>({
abi,
address,
batch,
context,
methods,
}: Props): Promise<ReturnValues> => {
const contractInstance = new web3.eth.Contract(abi, address)
const localBatch = new web3.BatchRequest()
const values = methods.map((methodObject) => {
let method, type, args: MethodsArgsType = []
let method,
type,
args: MethodsArgsType = []
if (typeof methodObject === 'string') {
method = methodObject
} else {
({ method, type, args } = methodObject)
;({ method, type, args } = methodObject)
}
return new Promise((resolve) => {

View File

@ -1,78 +1,173 @@
import {
DataDecoded,
SAFE_METHOD_ID_TO_NAME,
SAFE_METHODS_NAMES,
SPENDING_LIMIT_METHOD_ID_TO_NAME,
SPENDING_LIMIT_METHODS_NAMES,
TOKEN_TRANSFER_METHOD_ID_TO_NAME,
TOKEN_TRANSFER_METHODS_NAMES,
} from 'src/logic/safe/store/models/types/transactions.d'
import { web3ReadOnly as web3 } from 'src/logic/wallets/getWeb3'
import { DataDecoded, METHOD_TO_ID } from 'src/routes/safe/store/models/types/transactions.d'
import { sameString } from 'src/utils/strings'
type DecodeInfoProps = {
paramsHash: string
params: Record<string, string>
}
const decodeInfo = ({ paramsHash, params }: DecodeInfoProps): DataDecoded['parameters'] => {
const decodedParameters = web3.eth.abi.decodeParameters(Object.values(params), paramsHash)
return Object.keys(params).map((name, index) => ({
name,
type: params[name],
value: decodedParameters[index],
}))
}
export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null => {
const [methodId, params] = [data.slice(0, 10) as keyof typeof METHOD_TO_ID | string, data.slice(10)]
const [methodId, paramsHash] = [data.slice(0, 10), data.slice(10)]
const method = SAFE_METHODS_NAMES[methodId]
switch (methodId) {
// swapOwner
case '0xe318b52b': {
const decodedParameters = web3.eth.abi.decodeParameters(['uint', 'address', 'address'], params) as string[]
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'oldOwner', type: 'address', value: decodedParameters[1] },
{ name: 'newOwner', type: 'address', value: decodedParameters[2] },
],
switch (method) {
case SAFE_METHODS_NAMES.SWAP_OWNER: {
const params = {
prevOwner: 'address',
oldOwner: 'address',
newOwner: 'address',
}
// we only need to return the addresses that has been swapped, no need for the `prevOwner`
const [, oldOwner, newOwner] = decodeInfo({ paramsHash, params })
return { method, parameters: [oldOwner, newOwner] }
}
case SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD: {
const params = {
owner: 'address',
_threshold: 'uint',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
case SAFE_METHODS_NAMES.REMOVE_OWNER: {
const params = {
prevOwner: 'address',
owner: 'address',
_threshold: 'uint',
}
// we only need to return the removed owner and the new threshold, no need for the `prevOwner`
const [, oldOwner, threshold] = decodeInfo({ paramsHash, params })
return { method, parameters: [oldOwner, threshold] }
}
case SAFE_METHODS_NAMES.CHANGE_THRESHOLD: {
const params = {
_threshold: 'uint',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
case SAFE_METHODS_NAMES.ENABLE_MODULE: {
const params = {
module: 'address',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
case SAFE_METHODS_NAMES.DISABLE_MODULE: {
const params = {
prevModule: 'address',
module: 'address',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
default:
return null
}
}
// addOwnerWithThreshold
case '0x0d582f13': {
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'uint'], params)
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'owner', type: 'address', value: decodedParameters[0] },
{ name: '_threshold', type: 'uint', value: decodedParameters[1] },
],
}
export const isSetAllowanceMethod = (data: string): boolean => {
const methodId = data.slice(0, 10)
return sameString(SPENDING_LIMIT_METHOD_ID_TO_NAME[methodId], SPENDING_LIMIT_METHODS_NAMES.SET_ALLOWANCE)
}
// removeOwner
case '0xf8dc5dd9': {
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params)
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'owner', type: 'address', value: decodedParameters[1] },
{ name: '_threshold', type: 'uint', value: decodedParameters[2] },
],
}
export const isDeleteAllowanceMethod = (data: string): boolean => {
const methodId = data.slice(0, 10)
return sameString(SPENDING_LIMIT_METHOD_ID_TO_NAME[methodId], SPENDING_LIMIT_METHODS_NAMES.DELETE_ALLOWANCE)
}
// changeThreshold
case '0x694e80c3': {
const decodedParameters = web3.eth.abi.decodeParameters(['uint'], params)
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: '_threshold', type: 'uint', value: decodedParameters[0] },
],
}
export const decodeParamsFromSpendingLimit = (data: string): DataDecoded | null => {
const [methodId, paramsHash] = [data.slice(0, 10), data.slice(10)]
const method = SPENDING_LIMIT_METHOD_ID_TO_NAME[methodId]
switch (method) {
case SPENDING_LIMIT_METHODS_NAMES.ADD_DELEGATE: {
const params = {
delegate: 'address',
}
// enableModule
case '0x610b5925': {
const decodedParameters = web3.eth.abi.decodeParameters(['address'], params)
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'module', type: 'address', value: decodedParameters[0] },
],
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
// disableModule
case '0xe009cfde': {
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address'], params)
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'prevModule', type: 'address', value: decodedParameters[0] },
{ name: 'module', type: 'address', value: decodedParameters[1] },
],
case SPENDING_LIMIT_METHODS_NAMES.SET_ALLOWANCE: {
const params = {
delegate: 'address',
token: 'address',
allowanceAmount: 'uint96',
resetTimeMin: 'uint16',
resetBaseMin: 'uint32',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
case SPENDING_LIMIT_METHODS_NAMES.EXECUTE_ALLOWANCE_TRANSFER: {
const params = {
safe: 'address',
token: 'address',
to: 'address',
amount: 'uint96',
paymentToken: 'address',
payment: 'uint96',
delegate: 'address',
signature: 'bytes',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
case SPENDING_LIMIT_METHODS_NAMES.DELETE_ALLOWANCE: {
const params = {
delegate: 'address',
token: 'address',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
default:
@ -81,57 +176,53 @@ export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null =>
}
const isSafeMethod = (methodId: string): boolean => {
return !!METHOD_TO_ID[methodId]
return !!SAFE_METHOD_ID_TO_NAME[methodId]
}
export const decodeMethods = (data: string): DataDecoded | null => {
if(!data.length) {
const isSpendingLimitMethod = (methodId: string): boolean => {
return !!SPENDING_LIMIT_METHOD_ID_TO_NAME[methodId]
}
export const decodeMethods = (data: string | null): DataDecoded | null => {
if (!data?.length) {
return null
}
const [methodId, params] = [data.slice(0, 10), data.slice(10)]
const [methodId, paramsHash] = [data.slice(0, 10), data.slice(10)]
if (isSafeMethod(methodId)) {
return decodeParamsFromSafeMethod(data)
}
switch (methodId) {
// a9059cbb - transfer(address,uint256)
case '0xa9059cbb': {
const decodeParameters = web3.eth.abi.decodeParameters(['address', 'uint'], params)
return {
method: 'transfer',
parameters: [
{ name: 'to', type: '', value: decodeParameters[0] },
{ name: 'value', type: '', value: decodeParameters[1] },
],
}
if (isSpendingLimitMethod(methodId)) {
return decodeParamsFromSpendingLimit(data)
}
// 23b872dd - transferFrom(address,address,uint256)
case '0x23b872dd': {
const decodeParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params)
return {
method: 'transferFrom',
parameters: [
{ name: 'from', type: '', value: decodeParameters[0] },
{ name: 'to', type: '', value: decodeParameters[1] },
{ name: 'value', type: '', value: decodeParameters[2] },
],
}
const method = TOKEN_TRANSFER_METHOD_ID_TO_NAME[methodId]
switch (method) {
case TOKEN_TRANSFER_METHODS_NAMES.TRANSFER: {
const params = {
to: 'address',
value: 'uint',
}
// 42842e0e - safeTransferFrom(address,address,uint256)
case '0x42842e0e': {
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params)
return {
method: 'safeTransferFrom',
parameters: [
{ name: 'from', type: '', value: decodedParameters[0] },
{ name: 'to', type: '', value: decodedParameters[1] },
{ name: 'value', type: '', value: decodedParameters[2] },
],
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
case TOKEN_TRANSFER_METHODS_NAMES.TRANSFER_FROM:
case TOKEN_TRANSFER_METHODS_NAMES.SAFE_TRANSFER_FROM: {
const params = {
from: 'address',
to: 'address',
value: 'uint',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
default:

View File

@ -1,17 +1,19 @@
import { AbiItem } from 'web3-utils'
import GnosisSafeSol from '@gnosis.pm/safe-contracts/build/contracts/GnosisSafe.json'
import memoize from 'lodash.memoize'
import ProxyFactorySol from '@gnosis.pm/safe-contracts/build/contracts/GnosisSafeProxyFactory.json'
import SafeProxy from '@gnosis.pm/safe-contracts/build/contracts/GnosisSafeProxy.json'
import Web3 from 'web3'
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
import { isProxyCode } from 'src/logic/contracts/historicProxyCode'
import { ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses'
import { calculateGasOf, calculateGasPrice } from 'src/logic/wallets/ethTransactions'
import { getWeb3, getNetworkIdFrom } from 'src/logic/wallets/getWeb3'
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
import { GnosisSafeProxyFactory } from 'src/types/contracts/GnosisSafeProxyFactory.d'
import { AllowanceModule } from 'src/types/contracts/AllowanceModule.d'
import { getSafeInfo, SafeInfo } from 'src/logic/safe/utils/safeInformation'
import { SPENDING_LIMIT_MODULE_ADDRESS } from 'src/utils/constants'
import SpendingLimitModule from './artifacts/AllowanceModule.json'
export const SENTINEL_ADDRESS = '0x0000000000000000000000000000000000000001'
export const MULTI_SEND_ADDRESS = '0x8d29be29923b68abfdd21e541b9374737b49cdad'
@ -19,7 +21,6 @@ export const SAFE_MASTER_COPY_ADDRESS = '0x34CfAC646f301356fAa8B21e94227e3583Fe3
export const DEFAULT_FALLBACK_HANDLER_ADDRESS = '0xd5D82B6aDDc9027B22dCA772Aa68D5d74cdBdF44'
export const SAFE_MASTER_COPY_ADDRESS_V10 = '0xb6029EA3B2c51D09a50B53CA8012FeEB05bDa35A'
let proxyFactoryMaster: GnosisSafeProxyFactory
let safeMaster: GnosisSafe
@ -28,13 +29,13 @@ let safeMaster: GnosisSafe
* @param {Web3} web3
* @param {ETHEREUM_NETWORK} networkId
*/
const createGnosisSafeContract = (web3: Web3, networkId: ETHEREUM_NETWORK) => {
export const getGnosisSafeContract = (web3: Web3, networkId: ETHEREUM_NETWORK) => {
const networks = GnosisSafeSol.networks
// TODO: this may not be the most scalable approach,
// but up until v1.2.0 the address is the same for all the networks.
// So, if we can't find the network in the Contract artifact, we fallback to MAINNET.
const contractAddress = networks[networkId]?.address ?? networks[ETHEREUM_NETWORK.MAINNET].address
return new web3.eth.Contract(GnosisSafeSol.abi as AbiItem[], contractAddress) as unknown as GnosisSafe
return (new web3.eth.Contract(GnosisSafeSol.abi as AbiItem[], contractAddress) as unknown) as GnosisSafe
}
/**
@ -42,56 +43,92 @@ const createGnosisSafeContract = (web3: Web3, networkId: ETHEREUM_NETWORK) => {
* @param {Web3} web3
* @param {ETHEREUM_NETWORK} networkId
*/
const createProxyFactoryContract = (web3: Web3, networkId: ETHEREUM_NETWORK): GnosisSafeProxyFactory => {
const getProxyFactoryContract = (web3: Web3, networkId: ETHEREUM_NETWORK): GnosisSafeProxyFactory => {
const networks = ProxyFactorySol.networks
// TODO: this may not be the most scalable approach,
// but up until v1.2.0 the address is the same for all the networks.
// So, if we can't find the network in the Contract artifact, we fallback to MAINNET.
const contractAddress = networks[networkId]?.address ?? networks[ETHEREUM_NETWORK.MAINNET].address
return new web3.eth.Contract(ProxyFactorySol.abi as AbiItem[], contractAddress) as unknown as GnosisSafeProxyFactory
return (new web3.eth.Contract(ProxyFactorySol.abi as AbiItem[], contractAddress) as unknown) as GnosisSafeProxyFactory
}
export const getGnosisSafeContract = memoize(createGnosisSafeContract)
/**
* Creates a Contract instance of the GnosisSafeProxyFactory contract
*/
export const getSpendingLimitContract = () => {
const web3 = getWeb3()
return (new web3.eth.Contract(
SpendingLimitModule.abi as AbiItem[],
SPENDING_LIMIT_MODULE_ADDRESS,
) as unknown) as AllowanceModule
}
const getCreateProxyFactoryContract = memoize(createProxyFactoryContract)
export const getMasterCopyAddressFromProxyAddress = async (proxyAddress: string): Promise<string | undefined> => {
const res = await getSafeInfo(proxyAddress)
const masterCopyAddress = (res as SafeInfo)?.masterCopy
if (!masterCopyAddress) {
console.error(`There was not possible to get masterCopy address from proxy ${proxyAddress}.`)
return
}
return masterCopyAddress
}
const instantiateMasterCopies = async () => {
export const instantiateSafeContracts = async () => {
const web3 = getWeb3()
const networkId = await getNetworkIdFrom(web3)
// Create ProxyFactory Master Copy
proxyFactoryMaster = getCreateProxyFactoryContract(web3, networkId)
proxyFactoryMaster = getProxyFactoryContract(web3, networkId)
// Create Safe Master copy
safeMaster = getGnosisSafeContract(web3, networkId)
}
export const initContracts = instantiateMasterCopies
export const getSafeMasterContract = async () => {
await initContracts()
await instantiateSafeContracts()
return safeMaster
}
export const getSafeDeploymentTransaction = (safeAccounts, numConfirmations) => {
export const getSafeDeploymentTransaction = (
safeAccounts: string[],
numConfirmations: number,
safeCreationSalt: number,
) => {
const gnosisSafeData = safeMaster.methods
.setup(safeAccounts, numConfirmations, ZERO_ADDRESS, '0x', DEFAULT_FALLBACK_HANDLER_ADDRESS, ZERO_ADDRESS, 0, ZERO_ADDRESS)
.setup(
safeAccounts,
numConfirmations,
ZERO_ADDRESS,
'0x',
DEFAULT_FALLBACK_HANDLER_ADDRESS,
ZERO_ADDRESS,
0,
ZERO_ADDRESS,
)
.encodeABI()
return proxyFactoryMaster.methods.createProxy(safeMaster.options.address, gnosisSafeData)
return proxyFactoryMaster.methods.createProxyWithNonce(safeMaster.options.address, gnosisSafeData, safeCreationSalt)
}
export const estimateGasForDeployingSafe = async (
safeAccounts,
numConfirmations,
userAccount,
safeAccounts: string[],
numConfirmations: number,
userAccount: string,
safeCreationSalt: number,
) => {
const gnosisSafeData = await safeMaster.methods
.setup(safeAccounts, numConfirmations, ZERO_ADDRESS, '0x', DEFAULT_FALLBACK_HANDLER_ADDRESS, ZERO_ADDRESS, 0, ZERO_ADDRESS)
.setup(
safeAccounts,
numConfirmations,
ZERO_ADDRESS,
'0x',
DEFAULT_FALLBACK_HANDLER_ADDRESS,
ZERO_ADDRESS,
0,
ZERO_ADDRESS,
)
.encodeABI()
const proxyFactoryData = proxyFactoryMaster.methods
.createProxy(safeMaster.options.address, gnosisSafeData)
.createProxyWithNonce(safeMaster.options.address, gnosisSafeData, safeCreationSalt)
.encodeABI()
const gas = await calculateGasOf(proxyFactoryData, userAccount, proxyFactoryMaster.options.address)
const gasPrice = await calculateGasPrice()
@ -101,29 +138,5 @@ export const estimateGasForDeployingSafe = async (
export const getGnosisSafeInstanceAt = (safeAddress: string): GnosisSafe => {
const web3 = getWeb3()
return new web3.eth.Contract(GnosisSafeSol.abi as AbiItem[], safeAddress) as unknown as GnosisSafe
}
const cleanByteCodeMetadata = (bytecode: string): string => {
const metaData = 'a165'
return bytecode.substring(0, bytecode.lastIndexOf(metaData))
}
export const validateProxy = async (safeAddress: string): Promise<boolean> => {
// https://solidity.readthedocs.io/en/latest/metadata.html#usage-for-source-code-verification
const web3 = getWeb3()
const code = await web3.eth.getCode(safeAddress)
const codeWithoutMetadata = cleanByteCodeMetadata(code)
const supportedProxies = [SafeProxy]
for (let i = 0; i < supportedProxies.length; i += 1) {
const proxy = supportedProxies[i]
const proxyCode = proxy.deployedBytecode
const proxyCodeWithoutMetadata = cleanByteCodeMetadata(proxyCode)
if (codeWithoutMetadata === proxyCodeWithoutMetadata) {
return true
}
}
return isProxyCode(codeWithoutMetadata)
return (new web3.eth.Contract(GnosisSafeSol.abi as AbiItem[], safeAddress) as unknown) as GnosisSafe
}

View File

@ -99,6 +99,28 @@ const settingsChangeTxNotificationsQueue = {
afterExecutionError: NOTIFICATIONS.SETTINGS_CHANGE_FAILED_MSG,
}
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,
moreConfirmationsNeeded: NOTIFICATIONS.NEW_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG,
},
afterExecutionError: NOTIFICATIONS.NEW_SPENDING_LIMIT_FAILED_MSG,
}
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,
moreConfirmationsNeeded: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG,
},
afterExecutionError: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_FAILED_MSG,
}
const defaultNotificationsQueue = {
beforeExecution: NOTIFICATIONS.SIGN_TX_MSG,
pendingExecution: NOTIFICATIONS.TX_PENDING_MSG,
@ -166,6 +188,14 @@ export const getNotificationsFromTxType: any = (txType, origin) => {
notificationsQueue = settingsChangeTxNotificationsQueue
break
}
case TX_NOTIFICATION_TYPES.NEW_SPENDING_LIMIT_TX: {
notificationsQueue = newSpendingLimitTxNotificationsQueue
break
}
case TX_NOTIFICATION_TYPES.REMOVE_SPENDING_LIMIT_TX: {
notificationsQueue = removeSpendingLimitTxNotificationsQueue
break
}
case TX_NOTIFICATION_TYPES.SAFE_NAME_CHANGE_TX: {
notificationsQueue = safeNameChangeNotificationsQueue
break

View File

@ -46,6 +46,18 @@ const NOTIFICATION_IDS = {
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',
REMOVE_SPENDING_LIMIT_FAILED_MSG: 'REMOVE_SPENDING_LIMIT_FAILED_MSG',
WRONG_NETWORK_MSG: 'WRONG_NETWORK_MSG',
ADDRESS_BOOK_NEW_ENTRY_SUCCESS: 'ADDRESS_BOOK_NEW_ENTRY_SUCCESS',
ADDRESS_BOOK_EDIT_ENTRY_SUCCESS: 'ADDRESS_BOOK_EDIT_ENTRY_SUCCESS',
@ -191,6 +203,56 @@ export const NOTIFICATIONS: Record<NotificationId, Notification> = {
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
},
// Spending Limit
SIGN_NEW_SPENDING_LIMIT_MSG: {
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 },
},
NEW_SPENDING_LIMIT_EXECUTED_MSG: {
message: 'New Spending Limit successfully executed',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
},
NEW_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG: {
message: 'New Spending Limit successfully created. More confirmations needed to execute',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
},
NEW_SPENDING_LIMIT_FAILED_MSG: {
message: 'New Spending Limit failed',
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
},
SIGN_REMOVE_SPENDING_LIMIT_MSG: {
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 },
},
REMOVE_SPENDING_LIMIT_EXECUTED_MSG: {
message: 'Remove Spending Limit successfully executed',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
},
REMOVE_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG: {
message: 'Remove Spending Limit successfully created. More confirmations needed to execute',
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
},
REMOVE_SPENDING_LIMIT_FAILED_MSG: {
message: 'Remove Spending Limit failed',
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
},
// Network
TESTNET_VERSION_MSG: {
message: "Testnet Version: Don't send production assets to this Safe",

View File

@ -0,0 +1,19 @@
import { useSelector } from 'react-redux'
import { getNetworkInfo } from 'src/config'
import { Token } from 'src/logic/tokens/store/model/token'
import { sameAddress, ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses'
import { safeKnownCoins } from 'src/routes/safe/container/selector'
const { nativeCoin } = getNetworkInfo()
const useTokenInfo = (address: string): Token | undefined => {
const tokens = useSelector(safeKnownCoins)
if (tokens) {
const tokenAddress = sameAddress(address, ZERO_ADDRESS) ? nativeCoin.address : address
return tokens.find((token) => sameAddress(token.address, tokenAddress)) ?? undefined
}
}
export default useTokenInfo

View File

@ -0,0 +1,13 @@
import { createAction } from 'redux-actions'
import { ModuleTxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadModuleTransactions'
export const ADD_MODULE_TRANSACTIONS = 'ADD_MODULE_TRANSACTIONS'
export type AddModuleTransactionsAction = {
payload: {
safeAddress: string
modules: ModuleTxServiceModel[]
}
}
export const addModuleTransactions = createAction(ADD_MODULE_TRANSACTIONS)

View File

@ -41,7 +41,7 @@ import { PayableTx } from 'src/types/contracts/types.d'
import { AppReduxState } from 'src/store'
import { Dispatch, DispatchReturn } from './types'
interface CreateTransactionArgs {
export interface CreateTransactionArgs {
navigateToTransactionsTab?: boolean
notifiedTransaction: string
operation?: number

View File

@ -18,6 +18,7 @@ import { AppReduxState } from 'src/store'
import { latestMasterContractVersionSelector } from 'src/logic/safe/store/selectors'
import { getSafeInfo } from 'src/logic/safe/utils/safeInformation'
import { getModules } from 'src/logic/safe/utils/modules'
import { getSpendingLimits } from 'src/logic/safe/utils/spendingLimits'
const buildOwnersFrom = (safeOwners: string[], localSafe?: SafeRecordProps): List<SafeOwner> => {
const ownersList = safeOwners.map((ownerAddress) => {
@ -71,6 +72,7 @@ export const buildSafe = async (
const needsUpdate = safeNeedsUpdate(currentVersion, latestMasterContractVersion)
const featuresEnabled = enabledFeatures(currentVersion)
const modules = await getModules(safeInfo)
const spendingLimits = safeInfo ? await getSpendingLimits(safeInfo.modules, safeAddress) : null
return {
address: safeAddress,
@ -89,6 +91,7 @@ export const buildSafe = async (
blacklistedAssets: Set(),
blacklistedTokens: Set(),
modules,
spendingLimits,
}
}
@ -106,6 +109,9 @@ export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: Dispatch
getLocalSafe(safeAddress),
])
// request SpendingLimit info
const spendingLimits = safeInfo ? await getSpendingLimits(safeInfo.modules, safeAddress) : null
// Converts from [ { address, ownerName} ] to address array
const localOwners = localSafe ? localSafe.owners.map((localOwner) => localOwner.address) : []
@ -116,6 +122,7 @@ export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: Dispatch
address: safeAddress,
name: localSafe?.name,
modules,
spendingLimits,
nonce: Number(remoteNonce),
threshold: Number(remoteThreshold),
featuresEnabled: localSafe?.currentVersion

View File

@ -2,19 +2,27 @@ import axios from 'axios'
import { buildTxServiceUrl } from 'src/logic/safe/transactions'
import { buildIncomingTxServiceUrl } from 'src/logic/safe/transactions/incomingTxHistory'
import { buildModuleTxServiceUrl } from 'src/logic/safe/transactions/moduleTxHistory'
import { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions'
import { IncomingTxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadIncomingTransactions'
import { TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
import { ModuleTxServiceModel } from './loadModuleTransactions'
const getServiceUrl = (txType: string, safeAddress: string): string => {
return {
[TransactionTypes.INCOMING]: buildIncomingTxServiceUrl,
[TransactionTypes.OUTGOING]: buildTxServiceUrl,
[TransactionTypes.MODULE]: buildModuleTxServiceUrl,
}[txType](safeAddress)
}
// TODO: Remove this magic
/* eslint-disable */
async function fetchTransactions(
txType: TransactionTypes.MODULE,
safeAddress: string,
eTag: string | null,
): Promise<{ eTag: string | null; results: ModuleTxServiceModel[] }>
async function fetchTransactions(
txType: TransactionTypes.INCOMING,
safeAddress: string,
@ -26,10 +34,10 @@ async function fetchTransactions(
eTag: string | null,
): Promise<{ eTag: string | null; results: TxServiceModel[] }>
async function fetchTransactions(
txType: TransactionTypes.INCOMING | TransactionTypes.OUTGOING,
txType: TransactionTypes.MODULE | TransactionTypes.INCOMING | TransactionTypes.OUTGOING,
safeAddress: string,
eTag: string | null,
): Promise<{ eTag: string | null; results: TxServiceModel[] | IncomingTxServiceModel[] }> {
): Promise<{ eTag: string | null; results: ModuleTxServiceModel[] | TxServiceModel[] | IncomingTxServiceModel[] }> {
/* eslint-enable */
try {
const url = getServiceUrl(txType, safeAddress)

View File

@ -3,9 +3,11 @@ import { ThunkAction, ThunkDispatch } from 'redux-thunk'
import { AnyAction } from 'redux'
import { backOff } from 'exponential-backoff'
import { addIncomingTransactions } from '../../addIncomingTransactions'
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'
@ -44,6 +46,12 @@ export default (safeAddress: string): ThunkAction<Promise<void>, AppReduxState,
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)
}

View File

@ -10,6 +10,7 @@ import { web3ReadOnly } from 'src/logic/wallets/getWeb3'
import { makeIncomingTransaction } from 'src/logic/safe/store/models/incomingTransaction'
import fetchTransactions from 'src/logic/safe/store/actions/transactions/fetchTransactions/fetchTransactions'
import { TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
import { isENSContract } from 'src/logic/collectibles/utils'
export type IncomingTxServiceModel = {
blockNumber: number
@ -76,12 +77,18 @@ const batchIncomingTxsTokenDataRequest = (txs: IncomingTxServiceModel[]) => {
batch.execute()
return Promise.all(whenTxsValues).then((txsValues) =>
txsValues.map(([tx, symbol, decimals, ethTx, ethTxReceipt]) => [
txsValues.map(([tx, symbolFetched, decimals, ethTx, ethTxReceipt]) => {
let symbol = symbolFetched
if (!symbolFetched) {
symbol = isENSContract(tx.tokenAddress) ? 'ENS' : nativeCoin.symbol
}
return [
tx,
symbol ? symbol : nativeCoin.symbol,
symbol,
decimals ? decimals : nativeCoin.decimals,
new bn(ethTx?.gasPrice ?? 0).times(ethTxReceipt?.gasUsed ?? 0),
]),
]
}),
)
}

View File

@ -0,0 +1,35 @@
import fetchTransactions from 'src/logic/safe/store/actions/transactions/fetchTransactions/fetchTransactions'
import { TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
import { DataDecoded, Operation } from 'src/logic/safe/store/models/types/transactions.d'
export type ModuleTxServiceModel = {
created: string
executionDate: string
blockNumber: number
transactionHash: string
safe: string
module: string
to: string
value: string
data: string
operation: Operation
dataDecoded: DataDecoded
}
type ETag = string | null
let previousETag: ETag = null
export const loadModuleTransactions = async (safeAddress: string): Promise<ModuleTxServiceModel[]> => {
if (!safeAddress) {
return []
}
const { eTag, results }: { eTag: ETag; results: ModuleTxServiceModel[] } = await fetchTransactions(
TransactionTypes.MODULE,
safeAddress,
previousETag,
)
previousETag = eTag
return results
}

View File

@ -6,7 +6,22 @@ export type SafeOwner = {
address: string
}
export type ModulePair = [string, string]
export type ModulePair = [
// previous module
string,
// module
string,
]
export type SpendingLimit = {
delegate: string
token: string
amount: string
spent: string
resetTimeMin: string
lastResetMin: string
nonce: string
}
export type SafeRecordProps = {
name: string
@ -15,6 +30,7 @@ export type SafeRecordProps = {
ethBalance: string
owners: List<SafeOwner>
modules?: ModulePair[] | null
spendingLimits?: SpendingLimit[] | null
activeTokens: Set<string>
activeAssets: Set<string>
blacklistedTokens: Set<string>
@ -35,6 +51,7 @@ const makeSafe = Record<SafeRecordProps>({
ethBalance: '0',
owners: List([]),
modules: [],
spendingLimits: [],
activeTokens: Set(),
activeAssets: Set(),
blacklistedTokens: Set(),

View File

@ -1,4 +1,7 @@
import { List, Map, RecordOf } from 'immutable'
import { ModuleTxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadModuleTransactions'
import { Token } from 'src/logic/tokens/store/model/token'
import { Confirmation } from './confirmation'
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
import { DataDecoded, Transfer } from './transactions'
@ -14,6 +17,8 @@ export enum TransactionTypes {
UPGRADE = 'upgrade',
TOKEN = 'token',
COLLECTIBLE = 'collectible',
MODULE = 'module',
SPENDING_LIMIT = 'spendingLimit',
}
export type TransactionTypeValues = typeof TransactionTypes[keyof typeof TransactionTypes]
@ -47,7 +52,7 @@ export type TransactionProps = {
data: string | null
dataDecoded: DataDecoded | null
decimals?: (number | string) | null
decodedParams: DecodedParams | null
decodedParams: DecodedParams
executionDate?: string | null
executionTxHash?: string | null
executor: string
@ -101,3 +106,17 @@ export type TxArgs = {
to: string
valueInWei: string
}
type SafeModuleCompatibilityTypes = {
nonce?: string // not required for this tx: added for compatibility
fee?: number // not required for this tx: added for compatibility
executionTxHash?: string // not required for this tx: added for compatibility
safeTxHash: string // table uses this key as a unique row identifier, added for compatibility
}
export type SafeModuleTransaction = ModuleTxServiceModel &
SafeModuleCompatibilityTypes & {
status: TransactionStatus
type: TransactionTypes
tokenInfo?: Token
}

View File

@ -228,16 +228,30 @@ export const SAFE_METHODS_NAMES = {
SWAP_OWNER: 'swapOwner',
ENABLE_MODULE: 'enableModule',
DISABLE_MODULE: 'disableModule',
}
} as const
export const METHOD_TO_ID = {
export const SAFE_METHOD_ID_TO_NAME = {
'0xe318b52b': SAFE_METHODS_NAMES.SWAP_OWNER,
'0x0d582f13': SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD,
'0xf8dc5dd9': SAFE_METHODS_NAMES.REMOVE_OWNER,
'0x694e80c3': SAFE_METHODS_NAMES.CHANGE_THRESHOLD,
'0x610b5925': SAFE_METHODS_NAMES.ENABLE_MODULE,
'0xe009cfde': SAFE_METHODS_NAMES.DISABLE_MODULE,
}
} as const
export const SPENDING_LIMIT_METHODS_NAMES = {
ADD_DELEGATE: 'addDelegate',
SET_ALLOWANCE: 'setAllowance',
EXECUTE_ALLOWANCE_TRANSFER: 'executeAllowanceTransfer',
DELETE_ALLOWANCE: 'deleteAllowance',
} as const
export const SPENDING_LIMIT_METHOD_ID_TO_NAME = {
'0xe71bdf41': SPENDING_LIMIT_METHODS_NAMES.ADD_DELEGATE,
'0xbeaeb388': SPENDING_LIMIT_METHODS_NAMES.SET_ALLOWANCE,
'0x4515641a': SPENDING_LIMIT_METHODS_NAMES.EXECUTE_ALLOWANCE_TRANSFER,
'0x885133e3': SPENDING_LIMIT_METHODS_NAMES.DELETE_ALLOWANCE,
} as const
export type SafeMethods = typeof SAFE_METHODS_NAMES[keyof typeof SAFE_METHODS_NAMES]
@ -247,13 +261,19 @@ export const TOKEN_TRANSFER_METHODS_NAMES = {
SAFE_TRANSFER_FROM: 'safeTransferFrom',
} as const
export const TOKEN_TRANSFER_METHOD_ID_TO_NAME = {
'0xa9059cbb': TOKEN_TRANSFER_METHODS_NAMES.TRANSFER,
'0x23b872dd': TOKEN_TRANSFER_METHODS_NAMES.TRANSFER_FROM,
'0x42842e0e': TOKEN_TRANSFER_METHODS_NAMES.SAFE_TRANSFER_FROM,
} as const
type TokenMethods = typeof TOKEN_TRANSFER_METHODS_NAMES[keyof typeof TOKEN_TRANSFER_METHODS_NAMES]
type SafeDecodedParams = {
export type SafeDecodedParams = {
[key in SafeMethods]?: Record<string, string>
}
type TokenDecodedParams = {
export type TokenDecodedParams = {
[key in TokenMethods]?: Record<string, string>
}

View File

@ -0,0 +1,32 @@
import { handleActions } from 'redux-actions'
import {
ADD_MODULE_TRANSACTIONS,
AddModuleTransactionsAction,
} from 'src/logic/safe/store/actions/addModuleTransactions'
import { ModuleTxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadModuleTransactions'
export const MODULE_TRANSACTIONS_REDUCER_ID = 'moduleTransactions'
export interface ModuleTransactionsState {
[safeAddress: string]: ModuleTxServiceModel[]
}
export default handleActions(
{
[ADD_MODULE_TRANSACTIONS]: (state: ModuleTransactionsState, action: AddModuleTransactionsAction) => {
const { modules, safeAddress } = action.payload
const oldModuleTxs = state[safeAddress] ?? []
const oldModuleTxsHashes = oldModuleTxs.map(({ transactionHash }) => transactionHash)
// As backend is returning the whole list of txs on every request,
// to avoid duplicates, filtering happens in this level.
const newModuleTxs = modules.filter((moduleTx) => !oldModuleTxsHashes.includes(moduleTx.transactionHash))
return {
...state,
[safeAddress]: [...oldModuleTxs, ...newModuleTxs],
}
},
},
{},
)

View File

@ -208,6 +208,19 @@ export const safeModulesSelector = createSelector(safeSelector, safeFieldSelecto
export const safeFeaturesEnabledSelector = createSelector(safeSelector, safeFieldSelector('featuresEnabled'))
export const safeSpendingLimitsSelector = createSelector(safeSelector, safeFieldSelector('spendingLimits'))
export const safeOwnersAddressesListSelector = createSelector(
safeOwnersSelector,
(owners): List<string> => {
if (!owners) {
return List([])
}
return owners?.map(({ address }) => address)
},
)
export const getActiveTokensAddressesForAllSafes = createSelector(safesListSelector, (safes) => {
const addresses = Set().withMutations((set) => {
safes.forEach((safe) => {

View File

@ -2,10 +2,13 @@ import { List } from 'immutable'
import { createSelector } from 'reselect'
import { safeIncomingTransactionsSelector, safeTransactionsSelector } from 'src/logic/safe/store/selectors'
import { Transaction } from 'src/logic/safe/store/models/types/transaction'
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,
(transactions, incomingTransactions): List<Transaction> => List([...transactions, ...incomingTransactions]),
safeModuleTransactionsSelector,
(transactions, incomingTransactions, moduleTransactions): List<Transaction | SafeModuleTransaction> =>
List([...transactions, ...incomingTransactions, ...moduleTransactions]),
)

View File

@ -0,0 +1,9 @@
import { getSafeServiceBaseUrl } from 'src/config'
import { checksumAddress } from 'src/utils/checksumAddress'
export const buildModuleTxServiceUrl = (safeAddress: string): string => {
const address = checksumAddress(safeAddress)
const url = getSafeServiceBaseUrl(address)
return `${url}/module-transactions/`
}

View File

@ -1,11 +1,14 @@
export const TX_NOTIFICATION_TYPES: any = {
export const TX_NOTIFICATION_TYPES = {
STANDARD_TX: 'STANDARD_TX',
CONFIRMATION_TX: 'CONFIRMATION_TX',
CANCELLATION_TX: 'CANCELLATION_TX',
WAITING_TX: 'WAITING_TX',
SETTINGS_CHANGE_TX: 'SETTINGS_CHANGE_TX',
NEW_SPENDING_LIMIT_TX: 'NEW_SPENDING_LIMIT_TX',
REMOVE_SPENDING_LIMIT_TX: 'REMOVE_SPENDING_LIMIT_TX',
SAFE_NAME_CHANGE_TX: 'SAFE_NAME_CHANGE_TX',
OWNER_NAME_CHANGE_TX: 'OWNER_NAME_CHANGE_TX',
ADDRESSBOOK_NEW_ENTRY: 'ADDRESSBOOK_NEW_ENTRY',
ADDRESSBOOK_EDIT_ENTRY: 'ADDRESSBOOK_EDIT_ENTRY',
ADDRESSBOOK_DELETE_ENTRY: 'ADDRESSBOOK_DELETE_ENTRY',
}

View File

@ -0,0 +1,111 @@
import { SENTINEL_ADDRESS } from 'src/logic/contracts/safeContracts'
import { buildModulesLinkedList } from 'src/logic/safe/utils/modules'
describe('modules -> buildModulesLinkedList', () => {
let moduleManager
beforeEach(() => {
moduleManager = {
modules: {
[SENTINEL_ADDRESS]: SENTINEL_ADDRESS,
},
enableModule: function (module: string) {
this.modules[module] = this.modules[SENTINEL_ADDRESS]
this.modules[SENTINEL_ADDRESS] = module
},
disableModule: function (prevModule: string, module: string) {
this.modules[prevModule] = this.modules[module]
this.modules[module] = '0x0'
},
getModules: function (): string[] {
const modules: string[] = []
let module: string = this.modules[SENTINEL_ADDRESS]
while (module !== SENTINEL_ADDRESS) {
modules.push(module)
module = this.modules[module]
}
return modules
},
}
})
it(`should build a collection of addresses pair associated to a linked list`, () => {
// Given
const listOfModules = ['0xa', '0xb', '0xc', '0xd', '0xe', '0xf']
// When
const modulesPairList = buildModulesLinkedList(listOfModules)
// Then
expect(modulesPairList).toStrictEqual([
[SENTINEL_ADDRESS, '0xa'],
['0xa', '0xb'],
['0xb', '0xc'],
['0xc', '0xd'],
['0xd', '0xe'],
['0xe', '0xf'],
])
})
it(`should properly provide a list of modules pair to remove an specified module`, () => {
// Given
moduleManager.enableModule('0xc')
moduleManager.enableModule('0xb')
moduleManager.enableModule('0xa') // returned list is ordered [0xa, 0xb, 0xc]
const modulesPairList = buildModulesLinkedList(moduleManager.getModules())
// When
const moduleBPair = modulesPairList?.[1] ?? []
moduleManager.disableModule(...moduleBPair)
// Then
expect(moduleManager.modules['0xb']).toBe('0x0')
expect(moduleManager.getModules()).toStrictEqual(['0xa', '0xc'])
expect(buildModulesLinkedList(moduleManager.getModules())).toStrictEqual([
[SENTINEL_ADDRESS, '0xa'],
['0xa', '0xc'],
])
})
it(`should properly provide a list of modules pair to remove the firstly added module`, () => {
// Given
moduleManager.enableModule('0xc')
moduleManager.enableModule('0xb')
moduleManager.enableModule('0xa') // returned list is ordered [0xa, 0xb, 0xc]
const modulesPairList = buildModulesLinkedList(moduleManager.getModules())
// When
const moduleBPair = modulesPairList?.[2] ?? []
moduleManager.disableModule(...moduleBPair)
// Then
expect(moduleManager.modules['0xc']).toBe('0x0')
expect(moduleManager.getModules()).toStrictEqual(['0xa', '0xb'])
expect(buildModulesLinkedList(moduleManager.getModules())).toStrictEqual([
[SENTINEL_ADDRESS, '0xa'],
['0xa', '0xb'],
])
})
it(`should properly provide a list of modules pair to remove the lastly added module`, () => {
// Given
moduleManager.enableModule('0xc')
moduleManager.enableModule('0xb')
moduleManager.enableModule('0xa') // returned list is ordered [0xa, 0xb, 0xc]
const modulesPairList = buildModulesLinkedList(moduleManager.getModules())
// When
const moduleBPair = modulesPairList?.[0] ?? []
moduleManager.disableModule(...moduleBPair)
// Then
expect(moduleManager.modules['0xa']).toBe('0x0')
expect(moduleManager.getModules()).toStrictEqual(['0xb', '0xc'])
expect(buildModulesLinkedList(moduleManager.getModules())).toStrictEqual([
[SENTINEL_ADDRESS, '0xb'],
['0xb', '0xc'],
])
})
})

View File

@ -1,7 +1,9 @@
import semverLessThan from 'semver/functions/lt'
import { getGnosisSafeInstanceAt, SENTINEL_ADDRESS } from 'src/logic/contracts/safeContracts'
import { CreateTransactionArgs } from 'src/logic/safe/store/actions/createTransaction'
import { ModulePair } from 'src/logic/safe/store/models/safe'
import { CALL, TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { SafeInfo } from 'src/logic/safe/utils/safeInformation'
type ModulesPaginated = {
@ -9,11 +11,32 @@ type ModulesPaginated = {
next: string
}
const buildModulesLinkedList = (modules: string[], nextModule: string = SENTINEL_ADDRESS): Array<ModulePair> | null => {
/**
* Builds a collection of tuples with (prev, module) module addresses
*
* The `modules` param, is organized from the most recently added to the oldest.
*
* By assuming this, we are able to recreate the linked list that's defined at contract level
* considering `0x1` (SENTINEL_ADDRESS) address as the list's initial node.
*
* Given this scenario, we have a linked list in the form of
*
* **`0x1->modules[n]->module[n-1]->module[0]->0x1`**
*
* So,
* - if we want to disable `module[n]`, we need to pass `(module[n], 0x1)` as arguments,
* - if we want to disable `module[n-1]`, we need to pass `(module[n-1], module[n])`,
* - ... and so on
* @param {Array<string>} modules
* @returns null | Array<ModulePair>
*/
export const buildModulesLinkedList = (modules: string[]): Array<ModulePair> | null => {
if (modules?.length) {
return modules.map((moduleAddress, index, modules) => {
const prevModule = modules[index + 1]
return [moduleAddress, prevModule !== undefined ? prevModule : nextModule]
if (index === 0) {
return [SENTINEL_ADDRESS, moduleAddress]
}
return [modules[index - 1], moduleAddress]
})
}
@ -58,10 +81,12 @@ export const getModules = async (safeInfo: SafeInfo | void): Promise<Array<Modul
// as we're not sure if there are more than 10 modules enabled for the current Safe
const safeInstance = getGnosisSafeInstanceAt(safeInfo.address)
// TODO: 100 is an arbitrary large number, to avoid the need for pagination. But pagination must be properly handled
// TODO: 100 is an arbitrary large number, to avoid the need for pagination.
// But pagination must be properly handled
// if `modules.next !== SENTINEL_ADDRESS`, then we have more modules to retrieve
const modules: ModulesPaginated = await safeInstance.methods.getModulesPaginated(SENTINEL_ADDRESS, 100).call()
return buildModulesLinkedList(modules.array, modules.next)
return buildModulesLinkedList(modules.array)
} catch (e) {
console.error('Failed to retrieve Safe modules', e)
}
@ -69,8 +94,25 @@ export const getModules = async (safeInfo: SafeInfo | void): Promise<Array<Modul
}
export const getDisableModuleTxData = (modulePair: ModulePair, safeAddress: string): string => {
const [module, previousModule] = modulePair
const [previousModule, module] = modulePair
const safeInstance = getGnosisSafeInstanceAt(safeAddress)
return safeInstance.methods.disableModule(previousModule, module).encodeABI()
}
type EnableModuleParams = {
moduleAddress: string
safeAddress: string
}
export const enableModuleTx = ({ moduleAddress, safeAddress }: EnableModuleParams): CreateTransactionArgs => {
const safeInstance = getGnosisSafeInstanceAt(safeAddress)
return {
safeAddress,
to: safeAddress,
operation: CALL,
valueInWei: '0',
txData: safeInstance.methods.enableModule(moduleAddress).encodeABI(),
notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX,
}
}

View File

@ -36,17 +36,20 @@ export const safeNeedsUpdate = (currentVersion?: string, latestVersion?: string)
export const getCurrentSafeVersion = (gnosisSafeInstance: GnosisSafe): Promise<string> =>
gnosisSafeInstance.methods.VERSION().call()
const checkFeatureEnabledByVersion = (featureConfig: FeatureConfigByVersion, version: string) => {
const checkFeatureEnabledByVersion = (featureConfig: FeatureConfigByVersion, version?: string) => {
if (!version) {
return false
}
return featureConfig.validVersion ? semverSatisfies(version, featureConfig.validVersion) : true
}
export const enabledFeatures = (version?: string): FEATURES[] => {
return FEATURES_BY_VERSION.reduce((acc: FEATURES[], feature: Feature) => {
if (isFeatureEnabled(feature.name) && version && checkFeatureEnabledByVersion(feature, version)) {
return FEATURES_BY_VERSION.reduce((acc, feature: Feature) => {
if (isFeatureEnabled(feature.name) && checkFeatureEnabledByVersion(feature, version)) {
acc.push(feature.name)
}
return acc
}, [])
}, [] as FEATURES[])
}
interface SafeVersionInfo {

View File

@ -0,0 +1,294 @@
import { BigNumber } from 'bignumber.js'
import { getNetworkInfo } from 'src/config'
import { AbiItem } from 'web3-utils'
import { CreateTransactionArgs } from 'src/logic/safe/store/actions/createTransaction'
import { CALL, DELEGATE_CALL, TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { enableModuleTx } from 'src/logic/safe/utils/modules'
import SpendingLimitModule from 'src/logic/contracts/artifacts/AllowanceModule.json'
import generateBatchRequests from 'src/logic/contracts/generateBatchRequests'
import { getSpendingLimitContract, MULTI_SEND_ADDRESS } from 'src/logic/contracts/safeContracts'
import { SpendingLimit } from 'src/logic/safe/store/models/safe'
import { sameAddress, ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses'
import { getWeb3, web3ReadOnly } from 'src/logic/wallets/getWeb3'
import { SPENDING_LIMIT_MODULE_ADDRESS } from 'src/utils/constants'
import { getEncodedMultiSendCallData, MultiSendTx } from './upgradeSafe'
import { fromTokenUnit } from 'src/logic/tokens/utils/humanReadableValue'
import { getBalanceAndDecimalsFromToken, GetTokenByAddress } from 'src/logic/tokens/utils/tokenHelpers'
import { sameString } from 'src/utils/strings'
export const currentMinutes = (): number => Math.floor(Date.now() / (1000 * 60))
const requestTokensByDelegate = async (
safeAddress: string,
delegates: string[],
): Promise<[string, string[] | undefined][]> => {
const batch = new web3ReadOnly.BatchRequest()
const whenRequestValues = delegates.map((delegateAddress: string) =>
generateBatchRequests<[string, string[] | undefined]>({
abi: SpendingLimitModule.abi as AbiItem[],
address: SPENDING_LIMIT_MODULE_ADDRESS,
methods: [{ method: 'getTokens', args: [safeAddress, delegateAddress] }],
batch,
context: delegateAddress,
}),
)
batch.execute()
return Promise.all(whenRequestValues)
}
export type SpendingLimitRow = {
delegate: string
token: string
amount: string
spent: string
resetTimeMin: string
lastResetMin: string
nonce: string
}
const ZERO_VALUE = '0'
/**
* Deleted Allowance have their `amount` and `resetTime` set to `0` (zero)
* @param {SpendingLimitRow} allowance
* @returns boolean
*/
const discardZeroAllowance = ({ amount, resetTimeMin }: SpendingLimitRow): boolean =>
!(sameString(amount, ZERO_VALUE) && sameString(resetTimeMin, ZERO_VALUE))
type TokenSpendingLimit = [string, string, string, string, string]
type TokenSpendingLimitContext = {
delegate: string
token: string
}
type TokenSpendingLimitRequest = [TokenSpendingLimitContext, TokenSpendingLimit | undefined]
const requestAllowancesByDelegatesAndTokens = async (
safeAddress: string,
tokensByDelegate: [string, string[] | undefined][],
): Promise<SpendingLimitRow[]> => {
const batch = new web3ReadOnly.BatchRequest()
const whenRequestValues: Promise<TokenSpendingLimitRequest>[] = []
for (const [delegate, tokens] of tokensByDelegate) {
if (tokens) {
for (const token of tokens) {
whenRequestValues.push(
generateBatchRequests<[TokenSpendingLimitContext, TokenSpendingLimit]>({
abi: SpendingLimitModule.abi as AbiItem[],
address: SPENDING_LIMIT_MODULE_ADDRESS,
methods: [{ method: 'getTokenAllowance', args: [safeAddress, delegate, token] }],
batch,
context: { delegate, token },
}),
)
}
}
}
batch.execute()
return Promise.all(whenRequestValues).then((allowances) =>
allowances
// first, we filter out those records whose tokenSpendingLimit is undefined
.filter(([, tokenSpendingLimit]) => tokenSpendingLimit)
// then, we build the SpendingLimitRow object
.map(([{ delegate, token }, tokenSpendingLimit]) => {
const [amount, spent, resetTimeMin, lastResetMin, nonce] = tokenSpendingLimit as TokenSpendingLimit
return {
delegate,
token,
amount,
spent,
resetTimeMin,
lastResetMin,
nonce,
}
})
.filter(discardZeroAllowance),
)
}
export const getSpendingLimits = async (
modules: string[] | undefined,
safeAddress: string,
): Promise<SpendingLimit[] | null> => {
const isSpendingLimitEnabled = modules?.some((module) => sameAddress(module, SPENDING_LIMIT_MODULE_ADDRESS)) ?? false
if (isSpendingLimitEnabled) {
const delegates = await getSpendingLimitContract().methods.getDelegates(safeAddress, 0, 100).call()
const tokensByDelegate = await requestTokensByDelegate(safeAddress, delegates.results)
return requestAllowancesByDelegatesAndTokens(safeAddress, tokensByDelegate)
}
return null
}
type DeleteAllowanceParams = {
beneficiary: string
tokenAddress: string
}
export const getDeleteAllowanceTxData = ({ beneficiary, tokenAddress }: DeleteAllowanceParams): string => {
const { nativeCoin } = getNetworkInfo()
const token = sameAddress(tokenAddress, nativeCoin.address) ? ZERO_ADDRESS : tokenAddress
const web3 = getWeb3()
const spendingLimitContract = new web3.eth.Contract(
SpendingLimitModule.abi as AbiItem[],
SPENDING_LIMIT_MODULE_ADDRESS,
)
return spendingLimitContract.methods.deleteAllowance(beneficiary, token).encodeABI()
}
export const enableSpendingLimitModuleMultiSendTx = (safeAddress: string): MultiSendTx => {
const multiSendTx = enableModuleTx({ moduleAddress: SPENDING_LIMIT_MODULE_ADDRESS, safeAddress })
return {
to: multiSendTx.to,
value: Number(multiSendTx.valueInWei),
data: multiSendTx.txData as string,
operation: DELEGATE_CALL,
}
}
export const addSpendingLimitBeneficiaryMultiSendTx = (beneficiary: string): MultiSendTx => {
const spendingLimitContract = getSpendingLimitContract()
return {
to: SPENDING_LIMIT_MODULE_ADDRESS,
value: 0,
data: spendingLimitContract.methods.addDelegate(beneficiary).encodeABI(),
operation: DELEGATE_CALL,
}
}
type SpendingLimitTxParams = {
spendingLimitArgs: {
beneficiary: string
token: string
spendingLimitInWei: string
resetTimeMin: number
resetBaseMin: number
}
safeAddress
}
export const setSpendingLimitTx = ({
spendingLimitArgs: { beneficiary, token, spendingLimitInWei, resetTimeMin, resetBaseMin },
safeAddress,
}: SpendingLimitTxParams): CreateTransactionArgs => {
const spendingLimitContract = getSpendingLimitContract()
const { nativeCoin } = getNetworkInfo()
return {
safeAddress,
to: SPENDING_LIMIT_MODULE_ADDRESS,
valueInWei: ZERO_VALUE,
txData: spendingLimitContract.methods
.setAllowance(
beneficiary,
token === nativeCoin.address ? ZERO_ADDRESS : token,
spendingLimitInWei,
resetTimeMin,
resetBaseMin,
)
.encodeABI(),
operation: CALL,
notifiedTransaction: TX_NOTIFICATION_TYPES.NEW_SPENDING_LIMIT_TX,
}
}
export const setSpendingLimitMultiSendTx = (args: SpendingLimitTxParams): MultiSendTx => {
const tx = setSpendingLimitTx(args)
return {
to: tx.to,
value: Number(tx.valueInWei),
data: tx.txData as string,
operation: DELEGATE_CALL,
}
}
type SpendingLimitMultiSendTx = {
transactions: Array<MultiSendTx>
safeAddress: string
}
export const spendingLimitMultiSendTx = ({
transactions,
safeAddress,
}: SpendingLimitMultiSendTx): CreateTransactionArgs => ({
safeAddress,
to: MULTI_SEND_ADDRESS,
valueInWei: ZERO_VALUE,
txData: getEncodedMultiSendCallData(transactions, getWeb3()),
notifiedTransaction: TX_NOTIFICATION_TYPES.NEW_SPENDING_LIMIT_TX,
operation: DELEGATE_CALL,
})
type SpendingLimitAllowedBalance = GetTokenByAddress & {
tokenSpendingLimit: SpendingLimit
}
/**
* Calculates the remaining amount available for a particular SpendingLimit
* @param {string} tokenAddress
* @param {SpendingLimit} tokenSpendingLimit
* @param {List<Token>} tokens
* returns string
*/
export const spendingLimitAllowedBalance = ({
tokenAddress,
tokenSpendingLimit,
tokens,
}: SpendingLimitAllowedBalance): string | number => {
const token = getBalanceAndDecimalsFromToken({ tokenAddress, tokens })
if (!token) {
return 0
}
const { balance, decimals } = token
const diff = new BigNumber(tokenSpendingLimit.amount).minus(tokenSpendingLimit.spent).toString()
const diffInFPNotation = fromTokenUnit(diff, decimals)
return new BigNumber(balance).gt(diffInFPNotation) ? diffInFPNotation : balance
}
type GetSpendingLimitByTokenAddress = {
spendingLimits?: SpendingLimit[] | null
tokenAddress?: string
}
/**
* Returns the SpendingLimit info for the specified tokenAddress
* @param {SpendingLimit[] | undefined | null} spendingLimits
* @param {string | undefined} tokenAddress
* @returns SpendingLimit | undefined
*/
export const getSpendingLimitByTokenAddress = ({
spendingLimits,
tokenAddress,
}: GetSpendingLimitByTokenAddress): SpendingLimit | undefined => {
if (!tokenAddress || !spendingLimits) {
return
}
const { nativeCoin } = getNetworkInfo()
return spendingLimits.find(({ token: spendingLimitTokenAddress }) => {
spendingLimitTokenAddress = sameAddress(spendingLimitTokenAddress, ZERO_ADDRESS)
? nativeCoin.address
: spendingLimitTokenAddress
return sameAddress(spendingLimitTokenAddress, tokenAddress)
})
}

View File

@ -10,7 +10,7 @@ import { DELEGATE_CALL } from 'src/logic/safe/transactions'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { MultiSend } from 'src/types/contracts/MultiSend.d'
interface MultiSendTx {
export interface MultiSendTx {
operation: number
to: string
value: number

View File

@ -6,7 +6,7 @@ export type TokenProps = {
symbol: string
decimals: number | string
logoUri: string
balance?: number | string
balance: number | string
}
export const makeToken = Record<TokenProps>({
@ -15,7 +15,7 @@ export const makeToken = Record<TokenProps>({
symbol: '',
decimals: 0,
logoUri: '',
balance: undefined,
balance: 0,
})
// balance is only set in extendedSafeTokensSelector when we display user's token balances

View File

@ -1,3 +1,4 @@
import { List } from 'immutable'
import { AbiItem } from 'web3-utils'
import { getNetworkInfo } from 'src/config'
@ -9,6 +10,8 @@ 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 { CALL } from 'src/logic/safe/transactions'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
export const getEthAsToken = (balance: string | number): Token => {
const { nativeCoin } = getNetworkInfo()
@ -33,7 +36,13 @@ export const isAddressAToken = async (tokenAddress: string): Promise<boolean> =>
}
export const isTokenTransfer = (tx: TxServiceModel): boolean => {
return !isEmptyData(tx.data) && tx.data?.substring(0, 10) === '0xa9059cbb' && Number(tx.value) === 0
return (
!isEmptyData(tx.data) &&
// Check if contains 'transfer' method code
tx.data?.substring(0, 10) === '0xa9059cbb' &&
Number(tx.value) === 0 &&
tx.operation === CALL
)
}
export const getERC20DecimalsAndSymbol = async (
@ -75,3 +84,32 @@ export const isSendERC20Transaction = async (tx: TxServiceModel): Promise<boolea
return isSendTokenTx
}
export type GetTokenByAddress = {
tokenAddress: string
tokens: List<Token>
}
export type TokenFound = {
balance: string | number
decimals: string | number
}
/**
* Finds and returns a Token object by the provided address
* @param {string} tokenAddress
* @param {List<Token>} tokens
* @returns Token | undefined
*/
export const getBalanceAndDecimalsFromToken = ({ tokenAddress, tokens }: GetTokenByAddress): TokenFound | undefined => {
const token = tokens?.find(({ address }) => sameAddress(address, tokenAddress))
if (!token) {
return
}
return {
balance: token.balance ?? 0,
decimals: token.decimals ?? 0,
}
}

View File

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

View File

@ -20,10 +20,9 @@ import {
import Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col'
import Paragraph from 'src/components/layout/Paragraph'
import { SAFE_MASTER_COPY_ADDRESS_V10, getSafeMasterContract, validateProxy } from 'src/logic/contracts/safeContracts'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { FIELD_LOAD_ADDRESS, FIELD_LOAD_NAME } from 'src/routes/load/components/fields'
import { secondary } from 'src/theme/variables'
import { getSafeInfo } from 'src/logic/safe/utils/safeInformation'
const useStyles = makeStyles({
root: {
@ -42,42 +41,23 @@ const useStyles = makeStyles({
},
})
export const SAFE_INSTANCE_ERROR = 'Address given is not a Safe instance'
export const SAFE_MASTERCOPY_ERROR = 'Address is not a Safe or mastercopy is not supported'
export const SAFE_ADDRESS_NOT_VALID = 'Address given is not a valid Safe address'
// In case of an error here, it will be swallowed by final-form
// So if you're experiencing any strang behaviours like freeze or hanging
// Don't mind to check if everything is OK inside this function :)
export const safeFieldsValidation = async (values): Promise<Record<string, string>> => {
const errors = {}
const web3 = getWeb3()
const safeAddress = values[FIELD_LOAD_ADDRESS]
const address = values[FIELD_LOAD_ADDRESS]
if (!safeAddress || mustBeEthereumAddress(safeAddress) !== undefined) {
if (!address || mustBeEthereumAddress(address) !== undefined) {
return errors
}
const isValidProxy = await validateProxy(safeAddress)
if (!isValidProxy) {
errors[FIELD_LOAD_ADDRESS] = SAFE_INSTANCE_ERROR
return errors
}
// check mastercopy
const proxyAddressFromStorage = await web3.eth.getStorageAt(safeAddress, 0)
// https://www.reddit.com/r/ethereum/comments/6l3da1/how_long_are_ethereum_addresses/
// ganache returns plain address
// rinkeby returns 0x0000000000000+{40 address charachers}
// address comes last so we just get last 40 charachers (1byte = 2hex chars)
const checksummedProxyAddress = web3.utils.toChecksumAddress(
`0x${proxyAddressFromStorage.substr(proxyAddressFromStorage.length - 40)}`,
)
const safeMaster = await getSafeMasterContract()
const masterCopy = safeMaster.options.address
const sameMasterCopy =
checksummedProxyAddress === masterCopy || checksummedProxyAddress === SAFE_MASTER_COPY_ADDRESS_V10
if (!sameMasterCopy) {
errors[FIELD_LOAD_ADDRESS] = SAFE_MASTERCOPY_ERROR
// if getSafeInfo does not provide data, it's not a valid safe.
const safeInfo = await getSafeInfo(address)
if (!safeInfo) {
errors[FIELD_LOAD_ADDRESS] = SAFE_ADDRESS_NOT_VALID
}
return errors

View File

@ -6,12 +6,13 @@ import Stepper, { StepperPage } from 'src/components/Stepper'
import Block from 'src/components/layout/Block'
import Heading from 'src/components/layout/Heading'
import Row from 'src/components/layout/Row'
import { initContracts } from 'src/logic/contracts/safeContracts'
import Review from 'src/routes/open/components/ReviewInformation'
import { instantiateSafeContracts } from 'src/logic/contracts/safeContracts'
import { Review } from 'src/routes/open/components/ReviewInformation'
import SafeNameField from 'src/routes/open/components/SafeNameForm'
import SafeOwnersFields from 'src/routes/open/components/SafeOwnersConfirmationsForm'
import { SafeOwnersPage } from 'src/routes/open/components/SafeOwnersConfirmationsForm'
import {
FIELD_CONFIRMATIONS,
FIELD_CREATION_PROXY_SALT,
FIELD_SAFE_NAME,
getOwnerAddressBy,
getOwnerNameBy,
@ -40,6 +41,7 @@ type InitialValuesForm = {
owner0Name?: string
confirmations: string
safeName?: string
safeCreationSalt: number
}
const useInitialValuesFrom = (userAccount: string, safeProps?: SafeProps): InitialValuesForm => {
@ -51,6 +53,7 @@ const useInitialValuesFrom = (userAccount: string, safeProps?: SafeProps): Initi
[getOwnerNameBy(0)]: ownerName || 'My Wallet',
[getOwnerAddressBy(0)]: userAccount,
[FIELD_CONFIRMATIONS]: '1',
[FIELD_CREATION_PROXY_SALT]: Date.now(),
}
}
let obj = {}
@ -68,6 +71,7 @@ const useInitialValuesFrom = (userAccount: string, safeProps?: SafeProps): Initi
...obj,
[FIELD_CONFIRMATIONS]: threshold || '1',
[FIELD_SAFE_NAME]: name,
[FIELD_CREATION_PROXY_SALT]: Date.now(),
}
}
@ -92,7 +96,7 @@ type LayoutProps = {
safeProps?: SafeProps
}
const Layout = (props: LayoutProps): React.ReactElement => {
export const Layout = (props: LayoutProps): React.ReactElement => {
const { onCallSafeContractSubmit, safeProps } = props
const provider = useSelector(providerNameSelector)
@ -101,7 +105,7 @@ const Layout = (props: LayoutProps): React.ReactElement => {
useEffect(() => {
if (provider) {
initContracts()
instantiateSafeContracts()
}
}, [provider])
@ -129,7 +133,7 @@ const Layout = (props: LayoutProps): React.ReactElement => {
testId="create-safe-form"
>
<StepperPage component={SafeNameField} />
<StepperPage component={SafeOwnersFields} />
<StepperPage component={SafeOwnersPage} />
<StepperPage network={network} userAccount={userAccount} component={Review} />
</Stepper>
</Block>
@ -139,5 +143,3 @@ const Layout = (props: LayoutProps): React.ReactElement => {
</>
)
}
export default Layout

View File

@ -13,7 +13,7 @@ import Row from 'src/components/layout/Row'
import OpenPaper from 'src/components/Stepper/OpenPaper'
import { estimateGasForDeployingSafe } from 'src/logic/contracts/safeContracts'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { getAccountsFrom, getNamesFrom } from 'src/routes/open/utils/safeDataExtractor'
import { getAccountsFrom, getNamesFrom, getSafeCreationSaltFrom } from 'src/routes/open/utils/safeDataExtractor'
import { FIELD_CONFIRMATIONS, FIELD_NAME, getNumOwnersFrom } from '../fields'
import { useStyles } from './styles'
@ -33,20 +33,23 @@ const ReviewComponent = ({ userAccount, values }: ReviewComponentProps) => {
const names = getNamesFrom(values)
const addresses = getAccountsFrom(values)
const numOwners = getNumOwnersFrom(values)
const safeCreationSalt = getSafeCreationSaltFrom(values)
useEffect(() => {
const estimateGas = async () => {
if (!addresses.length || !numOwners || !userAccount) {
return
}
const estimatedGasCosts = (await estimateGasForDeployingSafe(addresses, numOwners, userAccount)).toString()
const estimatedGasCosts = (
await estimateGasForDeployingSafe(addresses, numOwners, userAccount, safeCreationSalt)
).toString()
const gasCosts = fromTokenUnit(estimatedGasCosts, nativeCoin.decimals)
const formattedGasCosts = formatAmount(gasCosts)
setGasCosts(formattedGasCosts)
}
estimateGas()
}, [addresses, numOwners, userAccount])
}, [addresses, numOwners, safeCreationSalt, userAccount])
return (
<>
@ -140,7 +143,7 @@ const ReviewComponent = ({ userAccount, values }: ReviewComponentProps) => {
)
}
const Review = () =>
export const Review = () =>
function ReviewPage(controls, props): React.ReactElement {
return (
<>
@ -150,5 +153,3 @@ const Review = () =>
</>
)
}
export default Review

View File

@ -8,7 +8,7 @@ import { getAddressValidator } from './validators'
import QRIcon from 'src/assets/icons/qrcode.svg'
import trash from 'src/assets/icons/trash.svg'
import ScanQRModal from 'src/components/ScanQRModal'
import { ScanQRModal } from 'src/components/ScanQRModal'
import OpenPaper from 'src/components/Stepper/OpenPaper'
import AddressInput from 'src/components/forms/AddressInput'
import Field from 'src/components/forms/Field'
@ -97,10 +97,10 @@ const SafeOwnersForm = (props): React.ReactElement => {
setNumOwners(numOwners + 1)
}
const handleScan = (value) => {
const handleScan = (value: string | null) => {
let scannedAddress = value
if (scannedAddress.startsWith('ethereum:')) {
if (scannedAddress?.startsWith('ethereum:')) {
scannedAddress = scannedAddress.replace('ethereum:', '')
}
@ -236,21 +236,13 @@ const SafeOwnersForm = (props): React.ReactElement => {
)
}
const SafeOwnersPage = ({ updateInitialProps }) =>
export const SafeOwnersPage = () =>
function OpenSafeOwnersPage(controls, { errors, form, values }) {
return (
<>
<OpenPaper controls={controls} padding={false}>
<SafeOwnersForm
errors={errors}
form={form}
otherAccounts={getAccountsFrom(values)}
updateInitialProps={updateInitialProps}
values={values}
/>
<SafeOwnersForm errors={errors} form={form} otherAccounts={getAccountsFrom(values)} values={values} />
</OpenPaper>
</>
)
}
export default SafeOwnersPage

View File

@ -1,6 +1,6 @@
import { uniqueAddress } from 'src/components/forms/validator'
import { GenericValidatorType, uniqueAddress } from 'src/components/forms/validator'
export const getAddressValidator = (addresses, position) => {
export const getAddressValidator = (addresses: string[], position: number): GenericValidatorType => {
// thanks Rich Harris
// https://twitter.com/Rich_Harris/status/1125850391155965952
const copy = addresses.slice()

View File

@ -2,6 +2,7 @@ export const FIELD_NAME = 'name'
export const FIELD_CONFIRMATIONS = 'confirmations'
export const FIELD_OWNERS = 'owners'
export const FIELD_SAFE_NAME = 'safeName'
export const FIELD_CREATION_PROXY_SALT = 'safeCreationSalt'
export const getOwnerNameBy = (index) => `owner${index}Name`
export const getOwnerAddressBy = (index) => `owner${index}Address`

View File

@ -4,7 +4,7 @@ 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 { 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'
@ -12,6 +12,7 @@ import {
getAccountsFrom,
getNamesFrom,
getOwnersFrom,
getSafeCreationSaltFrom,
getSafeNameFrom,
getThresholdFrom,
} from 'src/routes/open/utils/safeDataExtractor'
@ -58,9 +59,9 @@ export const createSafe = (values, userAccount) => {
const name = getSafeNameFrom(values)
const ownersNames = getNamesFrom(values)
const ownerAddresses = getAccountsFrom(values)
const safeCreationSalt = getSafeCreationSaltFrom(values)
const deploymentTx = getSafeDeploymentTransaction(ownerAddresses, confirmations)
const deploymentTx = getSafeDeploymentTransaction(ownerAddresses, confirmations, safeCreationSalt)
const promiEvent = deploymentTx.send({ from: userAccount })
promiEvent

View File

@ -1,7 +1,7 @@
import { List } from 'immutable'
import { makeOwner } from 'src/logic/safe/store/models/owner'
import { SafeOwner } from '../../../logic/safe/store/models/safe'
import { SafeOwner } from 'src/logic/safe/store/models/safe'
export const getAccountsFrom = (values) => {
const accounts = Object.keys(values)
@ -28,3 +28,5 @@ export const getOwnersFrom = (names, addresses): List<SafeOwner> => {
export const getThresholdFrom = (values) => Number(values.confirmations)
export const getSafeNameFrom = (values) => values.name
export const getSafeCreationSaltFrom = (values) => values.safeCreationSalt

View File

@ -9,7 +9,7 @@ import Button from 'src/components/layout/Button'
import Heading from 'src/components/layout/Heading'
import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph'
import { initContracts } from 'src/logic/contracts/safeContracts'
import { instantiateSafeContracts } from 'src/logic/contracts/safeContracts'
import { EMPTY_DATA } from 'src/logic/wallets/ethTransactions'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { background, connected } from 'src/theme/variables'
@ -152,7 +152,7 @@ const SafeDeployment = ({ creationTxHash, onCancel, onRetry, onSuccess, submitte
useEffect(() => {
const loadContracts = async () => {
await initContracts()
await instantiateSafeContracts()
setLoading(false)
}

View File

@ -19,8 +19,7 @@ import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
import { getAddressesListFromAddressBook } from 'src/logic/addressBook/utils'
import { addressBookAddressesListSelector } from 'src/logic/addressBook/store/selectors'
export const CREATE_ENTRY_INPUT_NAME_ID = 'create-entry-input-name'
export const CREATE_ENTRY_INPUT_ADDRESS_ID = 'create-entry-input-address'
@ -42,8 +41,7 @@ const CreateEditEntryModalComponent = ({
}
}
const addressBook = useSelector(addressBookSelector)
const addressBookAddressesList = getAddressesListFromAddressBook(addressBook)
const addressBookAddressesList = useSelector(addressBookAddressesListSelector)
const entryDoesntExist = uniqueAddress(addressBookAddressesList)
const formMutators = {

View File

@ -63,12 +63,14 @@ export const EllipsisTransactionDetails = ({
<div className={classes.container} role="menu" tabIndex={0}>
<MoreHorizIcon onClick={handleClick} onKeyDown={handleClick} />
<Menu anchorEl={anchorEl} id="simple-menu" keepMounted onClose={closeMenuHandler} open={Boolean(anchorEl)}>
{sendModalOpenHandler ? (
<>
<MenuItem onClick={sendModalOpenHandler}>Send Again</MenuItem>
<Divider />
</>
) : null}
{sendModalOpenHandler
? [
<MenuItem key="send-again-button" onClick={sendModalOpenHandler}>
Send Again
</MenuItem>,
<Divider key="divider" />,
]
: null}
{knownAddress ? (
<MenuItem onClick={addOrEditEntryHandler}>Edit Address book Entry</MenuItem>
) : (

View File

@ -1,27 +0,0 @@
import React from 'react'
import { useFormState } from 'react-final-form'
import { SafeApp } from 'src/routes/safe/components/Apps/types.d'
import { isAppManifestValid } from 'src/routes/safe/components/Apps/utils'
interface SubmitButtonStatusProps {
appInfo: SafeApp
onSubmitButtonStatusChange: (disabled: boolean) => void
}
const SubmitButtonStatus = ({ appInfo, onSubmitButtonStatusChange }: SubmitButtonStatusProps): null => {
const { valid, validating, visited } = useFormState({
subscription: { valid: true, validating: true, visited: true },
})
React.useEffect(() => {
// if non visited, fields were not evaluated yet. Then, the default value is considered invalid
const fieldsVisited = visited?.agreementAccepted && visited.appUrl
onSubmitButtonStatusChange(validating || !valid || !fieldsVisited || !isAppManifestValid(appInfo))
}, [validating, valid, visited, onSubmitButtonStatusChange, appInfo])
return null
}
export default SubmitButtonStatus

View File

@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="102" height="92" viewBox="0 0 102 92">
<defs>
<path id="611coc912a" d="M0.033 0L92.033 0 92.033 92 0.033 92z"/>
<path id="vvw5qne11c" d="M0 0.355L21.594 0.355 21.594 21.949 0 21.949z"/>
</defs>
<g fill="none" fill-rule="evenodd">
<g>
<g>
<g transform="translate(-286 -140) translate(286 140) translate(6)">
<mask id="vl08viacbb" fill="#fff">
<use xlink:href="#611coc912a"/>
</mask>
<path fill="#F7F5F5" d="M46.033 0c25.404 0 46 20.595 46 46 0 25.404-20.596 46-46 46-25.405 0-46-20.596-46-46 0-25.405 20.595-46 46-46" mask="url(#vl08viacbb)"/>
</g>
<path fill="#B2B5B2" d="M14.613 32.974h-7.59c-3.867 0-7 3.134-7 7v7.591c0 3.865 3.133 7 7 7h7.59c3.866 0 7-3.135 7-7v-7.59c0-3.867-3.134-7-7-7m0 4c1.654 0 3 1.345 3 3v7.59c0 1.653-1.346 3-3 3h-7.59c-1.655 0-3-1.347-3-3v-7.59c0-1.655 1.345-3 3-3h7.59M37.201 32.974h-.04c-5.95 0-10.775 4.824-10.775 10.774v.041c0 5.952 4.824 10.776 10.774 10.776h.041c5.951 0 10.775-4.824 10.775-10.776v-.04c0-5.95-4.824-10.775-10.775-10.775m0 4c3.735 0 6.775 3.04 6.775 6.815 0 3.736-3.04 6.776-6.816 6.776-3.735 0-6.774-3.04-6.774-6.817 0-3.735 3.04-6.774 6.774-6.774h.041M41.002 59.363H33.41c-3.866 0-7 3.134-7 7v7.59c0 3.867 3.134 7 7 7H41c3.867 0 7-3.133 7-7v-7.59c0-3.866-3.133-7-7-7m0 4c1.655 0 3 1.346 3 3v7.59c0 1.654-1.345 3-3 3h-7.59c-1.654 0-3-1.346-3-3v-7.59c0-1.654 1.346-3 3-3H41M20.924 80.273l-.006.006c-.894.894-2.357.894-3.252 0L.67 63.284c-.894-.895-.894-2.358 0-3.252l.006-.006c.894-.895 2.357-.895 3.252 0L20.924 77.02c.895.895.895 2.357 0 3.252" transform="translate(-286 -140) translate(286 140)"/>
<g transform="translate(-286 -140) translate(286 140) translate(0 59)">
<mask id="i3d0m5zbyd" fill="#fff">
<use xlink:href="#vvw5qne11c"/>
</mask>
<path fill="#B2B5B2" d="M.67 21.273l.007.006c.894.894 2.357.894 3.252 0L20.924 4.284c.894-.895.894-2.358 0-3.252l-.006-.006c-.894-.895-2.357-.895-3.252 0L.67 18.02c-.895.895-.895 2.357 0 3.252" mask="url(#i3d0m5zbyd)"/>
</g>
<path stroke="#008C73" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M12.031 15.984L36.031 15.984M24.031 27.984L24.031 3.984" transform="translate(-286 -140) translate(286 140)"/>
<path fill="#F7F5F5" d="M48.719 67.994c-3.136 0-5.687-2.552-5.687-5.687V25.68c0-3.135 2.55-5.687 5.687-5.687h44.625c3.136 0 5.688 2.552 5.688 5.687v36.626c0 3.135-2.552 5.687-5.688 5.687H48.719z" transform="translate(-286 -140) translate(286 140)"/>
<path fill="#B2B5B2" d="M93.344 17.994H48.719c-4.246 0-7.688 3.44-7.688 7.687v36.626c0 4.246 3.442 7.687 7.688 7.687h44.625c4.245 0 7.687-3.441 7.687-7.687V25.68c0-4.246-3.442-7.687-7.687-7.687m0 4c2.033 0 3.688 1.654 3.688 3.687v36.626c0 2.033-1.655 3.687-3.688 3.687H48.719c-2.034 0-3.688-1.654-3.688-3.687V25.68c0-2.033 1.654-3.687 3.688-3.687h44.625" transform="translate(-286 -140) translate(286 140)"/>
<path stroke="#B2B5B2" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M44.566 33.978L97.566 33.978" transform="translate(-286 -140) translate(286 140)"/>
<path fill="#B2B5B2" d="M52.032 26.977c0 1.104-.896 2-2 2s-2-.896-2-2 .896-2 2-2 2 .896 2 2M59.007 26.977c0 1.104-.896 2-2 2s-2-.896-2-2 .896-2 2-2 2 .896 2 2M66.024 26.977c0 1.104-.896 2-2 2s-2-.896-2-2 .896-2 2-2 2 .896 2 2" transform="translate(-286 -140) translate(286 140)"/>
<path stroke="#B2B5B2" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M66.033 59.977L57.033 50.977 66.033 41.977M76.016 59.977L85.016 50.977 76.016 41.977" transform="translate(-286 -140) translate(286 140)"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,51 @@
import { Button, Divider } from '@gnosis.pm/safe-react-components'
import React, { ReactElement, useMemo } from 'react'
import { useFormState } from 'react-final-form'
import styled from 'styled-components'
import GnoButton from 'src/components/layout/Button'
import { SafeApp } from 'src/routes/safe/components/Apps/types.d'
import { isAppManifestValid } from 'src/routes/safe/components/Apps/utils'
const StyledDivider = styled(Divider)`
margin: 16px -24px;
`
const ButtonsContainer = styled.div`
display: flex;
justify-content: space-between;
`
interface Props {
appInfo: SafeApp
onCancel: () => void
}
const FormButtons = ({ appInfo, onCancel }: Props): ReactElement => {
const { valid, validating, visited } = useFormState({
subscription: { valid: true, validating: true, visited: true },
})
const isSubmitDisabled = useMemo(() => {
// if non visited, fields were not evaluated yet. Then, the default value is considered invalid
const fieldsVisited = visited?.agreementAccepted && visited?.appUrl
return validating || !valid || !fieldsVisited || !isAppManifestValid(appInfo)
}, [validating, valid, visited, appInfo])
return (
<>
<StyledDivider />
<ButtonsContainer>
<Button size="md" onClick={onCancel} color="secondary">
Cancel
</Button>
<GnoButton color="primary" variant="contained" type="submit" disabled={isSubmitDisabled}>
Add
</GnoButton>
</ButtonsContainer>
</>
)
}
export default FormButtons

View File

@ -1,19 +1,20 @@
import { Text, TextField } from '@gnosis.pm/safe-react-components'
import React from 'react'
import { TextField } from '@gnosis.pm/safe-react-components'
import React, { useState, ReactElement } from 'react'
import styled from 'styled-components'
import AppAgreement from './AppAgreement'
import AppUrl, { AppInfoUpdater, appUrlResolver } from './AppUrl'
import SubmitButtonStatus from './SubmitButtonStatus'
import { SafeApp } from 'src/routes/safe/components/Apps/types.d'
import GnoForm from 'src/components/forms/GnoForm'
import Img from 'src/components/layout/Img'
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
const StyledText = styled(Text)`
margin-bottom: 19px;
`
import AppAgreement from './AppAgreement'
import AppUrl, { AppInfoUpdater, appUrlResolver } from './AppUrl'
import FormButtons from './FormButtons'
import { APPS_STORAGE_KEY, getEmptySafeApp } from 'src/routes/safe/components/Apps/utils'
import { saveToStorage } from 'src/utils/storage'
import { SAFELIST_ADDRESS } from 'src/routes/routes'
import { useHistory, useRouteMatch } from 'react-router-dom'
const FORM_ID = 'add-apps-form'
const StyledTextFileAppName = styled(TextField)`
&& {
@ -39,38 +40,34 @@ const INITIAL_VALUES: AddAppFormValues = {
agreementAccepted: false,
}
const APP_INFO: SafeApp = {
id: '',
url: '',
name: '',
iconUrl: appsIconSvg,
error: false,
description: '',
}
const APP_INFO = getEmptySafeApp()
interface AddAppProps {
appList: SafeApp[]
closeModal: () => void
formId: string
onAppAdded: (app: SafeApp) => void
setIsSubmitDisabled: (disabled: boolean) => void
}
const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }: AddAppProps): React.ReactElement => {
const [appInfo, setAppInfo] = React.useState<SafeApp>(APP_INFO)
const AddApp = ({ appList, closeModal }: AddAppProps): ReactElement => {
const [appInfo, setAppInfo] = useState<SafeApp>(APP_INFO)
const history = useHistory()
const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
const handleSubmit = () => {
closeModal()
onAppAdded(appInfo)
const newAppList = [
{ url: appInfo.url, disabled: false },
...appList.map(({ url, disabled }) => ({ url, disabled })),
]
saveToStorage(APPS_STORAGE_KEY, newAppList)
const goToApp = `${matchSafeWithAddress?.url}/apps?appUrl=${encodeURI(appInfo.url)}`
history.push(goToApp)
}
return (
<GnoForm decorators={[appUrlResolver]} initialValues={INITIAL_VALUES} onSubmit={handleSubmit} testId={formId}>
<GnoForm decorators={[appUrlResolver]} initialValues={INITIAL_VALUES} onSubmit={handleSubmit} testId={FORM_ID}>
{() => (
<>
<StyledText size="xl">Add custom app</StyledText>
<AppUrl appList={appList} />
{/* Fetch app from url and return a SafeApp */}
<AppInfoUpdater onAppInfo={setAppInfo} />
@ -81,7 +78,7 @@ const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }
<AppAgreement />
<SubmitButtonStatus onSubmitButtonStatusChange={setIsSubmitDisabled} appInfo={appInfo} />
<FormButtons appInfo={appInfo} onCancel={closeModal} />
</>
)}
</GnoForm>

View File

@ -0,0 +1,25 @@
import React from 'react'
import AppCard from './index'
import AddAppIcon from 'src/routes/safe/components/Apps/assets/addApp.svg'
export default {
title: 'Apps/AppCard',
component: AppCard,
}
export const Loading = (): React.ReactElement => <AppCard isLoading />
export const AddCustomApp = (): React.ReactElement => (
<AppCard iconUrl={AddAppIcon} onClick={console.log} buttonText="Add custom app" />
)
export const LoadedApp = (): React.ReactElement => (
<AppCard
iconUrl="https://cryptologos.cc/logos/versions/gnosis-gno-gno-logo-circle.svg?v=007"
name="Gnosis"
description="Gnosis safe app"
onClick={console.log}
/>
)

View File

@ -0,0 +1,107 @@
import React, { SyntheticEvent } from 'react'
import styled from 'styled-components'
import { fade } from '@material-ui/core/styles/colorManipulator'
import { Title, Text, Button, Card } from '@gnosis.pm/safe-react-components'
import appsIconSvg from 'src/assets/icons/apps.svg'
import { AppIconSK, DescriptionSK, TitleSK } from './skeleton'
const StyledAppCard = styled(Card)`
display: flex;
align-items: center;
flex-direction: column;
justify-content: space-evenly;
box-shadow: 1px 2px 10px 0 ${({ theme }) => fade(theme.colors.shadow.color, 0.18)};
height: 232px !important;
box-sizing: border-box;
cursor: pointer;
:hover {
box-shadow: 1px 2px 16px 0 ${({ theme }) => fade(theme.colors.shadow.color, 0.35)};
transition: box-shadow 0.3s ease-in-out;
background-color: ${({ theme }) => theme.colors.background};
cursor: pointer;
h5 {
color: ${({ theme }) => theme.colors.primary};
}
}
`
const IconImg = styled.img<{ size: 'md' | 'lg'; src: string | undefined }>`
width: ${({ size }) => (size === 'md' ? '60px' : '102px')};
height: ${({ size }) => (size === 'md' ? '60px' : '92px')};
margin-top: ${({ size }) => (size === 'md' ? '0' : '-16px')};
object-fit: contain;
`
const AppName = styled(Title)`
text-align: center;
margin: 16px 0 9px 0;
`
const AppDescription = styled(Text)`
height: 71px;
text-align: center;
`
export const setAppImageFallback = (error: SyntheticEvent<HTMLImageElement, Event>): void => {
error.currentTarget.onerror = null
error.currentTarget.src = appsIconSvg
}
export enum TriggerType {
Button,
Content,
}
type Props = {
onClick?: () => void
isLoading?: boolean
className?: string
name?: string
description?: string
iconUrl?: string
iconSize?: 'md' | 'lg'
buttonText?: string
}
const AppCard = ({
isLoading = false,
className,
name,
description,
iconUrl,
iconSize = 'md',
buttonText,
onClick = () => undefined,
}: Props): React.ReactElement => {
if (isLoading) {
return (
<StyledAppCard className={className}>
<AppIconSK />
<TitleSK />
<DescriptionSK />
<DescriptionSK />
</StyledAppCard>
)
}
return (
<StyledAppCard className={className} onClick={onClick}>
<IconImg alt={`${name || 'App'} Logo`} src={iconUrl} onError={setAppImageFallback} size={iconSize} />
{name && <AppName size="xs">{name}</AppName>}
{description && <AppDescription size="lg">{description} </AppDescription>}
{buttonText && (
<Button size="md" color="primary" variant="contained" onClick={onClick}>
{buttonText}
</Button>
)}
</StyledAppCard>
)
}
export default AppCard

View File

@ -0,0 +1,41 @@
import styled, { keyframes } from 'styled-components'
const gradientSK = keyframes`
0% {
background-position: 0% 54%;
}
50% {
background-position: 100% 47%;
}
100% {
background-position: 0% 54%;
}
`
export const AppIconSK = styled.div`
height: 60px;
width: 60px;
border-radius: 30px;
margin: 0 auto;
background-color: lightgrey;
background: linear-gradient(84deg, lightgrey, transparent);
background-size: 400% 400%;
animation: ${gradientSK} 1.5s ease infinite;
`
export const TitleSK = styled.div`
height: 24px;
width: 160px;
margin: 24px auto;
background-color: lightgrey;
background: linear-gradient(84deg, lightgrey, transparent);
background-size: 400% 400%;
animation: ${gradientSK} 1.5s ease infinite;
`
export const DescriptionSK = styled.div`
height: 16px;
width: 200px;
background-color: lightgrey;
background: linear-gradient(84deg, lightgrey, transparent);
background-size: 400% 400%;
animation: ${gradientSK} 1.5s ease infinite;
`

View File

@ -1,92 +1,289 @@
import React, { forwardRef } from 'react'
import React, { useState, useRef, useCallback, useEffect } from 'react'
import styled from 'styled-components'
import { FixedIcon, Loader, Title } from '@gnosis.pm/safe-react-components'
import { useHistory } from 'react-router-dom'
import {
FixedIcon,
Loader,
Title,
Text,
Card,
GenericModal,
ModalFooterConfirmation,
Menu,
ButtonLink,
} from '@gnosis.pm/safe-react-components'
import { useHistory, useRouteMatch } from 'react-router-dom'
import { useSelector } from 'react-redux'
import {
INTERFACE_MESSAGES,
Transaction,
RequestId,
LowercaseNetworks,
SendTransactionParams,
} from '@gnosis.pm/safe-apps-sdk'
import {
safeEthBalanceSelector,
safeParamAddressFromStateSelector,
safeNameSelector,
} from 'src/logic/safe/store/selectors'
import { grantedSelector } from 'src/routes/safe/container/selector'
import { getNetworkName } from 'src/config'
import { SAFELIST_ADDRESS } from 'src/routes/routes'
import { isSameURL } from 'src/utils/url'
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
import { staticAppsList } from 'src/routes/safe/components/Apps/utils'
import ConfirmTransactionModal from '../components/ConfirmTransactionModal'
import { useIframeMessageHandler } from '../hooks/useIframeMessageHandler'
import { useLegalConsent } from '../hooks/useLegalConsent'
import { SafeApp } from '../types'
import LegalDisclaimer from './LegalDisclaimer'
import { APPS_STORAGE_KEY, getAppInfoFromUrl } from '../utils'
import { SafeApp, StoredSafeApp } from '../types.d'
import { LoadingContainer } from 'src/components/LoaderContainer'
const StyledIframe = styled.iframe`
padding: 15px;
box-sizing: border-box;
width: 100%;
height: 100%;
`
const LoadingContainer = styled.div`
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
`
const IframeWrapper = styled.div`
position: relative;
height: 100%;
width: 100%;
overflow: hidden;
`
const Centered = styled.div`
const OwnerDisclaimer = styled.div`
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
height: 476px;
`
type AppFrameProps = {
selectedApp: SafeApp | undefined
safeAddress: string
network: string
granted: boolean
appIsLoading: boolean
onIframeLoad: () => void
const AppWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`
const StyledCard = styled(Card)`
flex-grow: 1;
`
const StyledIframe = styled.iframe`
height: 100%;
width: 100%;
overflow: auto;
box-sizing: border-box;
`
const Breadcrumb = styled.div`
height: 51px;
`
type ConfirmTransactionModalState = {
isOpen: boolean
txs: Transaction[]
requestId?: RequestId
params?: SendTransactionParams
}
const AppFrame = forwardRef<HTMLIFrameElement, AppFrameProps>(function AppFrameComponent(
{ selectedApp, safeAddress, network, appIsLoading, granted, onIframeLoad },
iframeRef,
): React.ReactElement {
type Props = {
appUrl: string
}
const NETWORK_NAME = getNetworkName()
const INITIAL_CONFIRM_TX_MODAL_STATE: ConfirmTransactionModalState = {
isOpen: false,
txs: [],
requestId: undefined,
params: undefined,
}
const AppFrame = ({ appUrl }: Props): React.ReactElement => {
const granted = useSelector(grantedSelector)
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const ethBalance = useSelector(safeEthBalanceSelector)
const safeName = useSelector(safeNameSelector)
const { trackEvent } = useAnalytics()
const history = useHistory()
const { consentReceived, onConsentReceipt } = useLegalConsent()
const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
const iframeRef = useRef<HTMLIFrameElement>(null)
const [confirmTransactionModal, setConfirmTransactionModal] = useState<ConfirmTransactionModalState>(
INITIAL_CONFIRM_TX_MODAL_STATE,
)
const [appIsLoading, setAppIsLoading] = useState<boolean>(true)
const [safeApp, setSafeApp] = useState<SafeApp | undefined>()
const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false)
const [isAppDeletable, setIsAppDeletable] = useState<boolean | undefined>()
const redirectToBalance = () => history.push(`${SAFELIST_ADDRESS}/${safeAddress}/balances`)
if (!selectedApp) {
return <div />
const openConfirmationModal = useCallback(
(txs: Transaction[], params: SendTransactionParams | undefined, requestId: RequestId) =>
setConfirmTransactionModal({
isOpen: true,
txs,
requestId,
params,
}),
[setConfirmTransactionModal],
)
const closeConfirmationModal = useCallback(() => setConfirmTransactionModal(INITIAL_CONFIRM_TX_MODAL_STATE), [
setConfirmTransactionModal,
])
const { sendMessageToIframe } = useIframeMessageHandler(
safeApp,
openConfirmationModal,
closeConfirmationModal,
iframeRef,
)
const onIframeLoad = useCallback(() => {
const iframe = iframeRef.current
if (!iframe || !isSameURL(iframe.src, appUrl as string)) {
return
}
if (!consentReceived) {
setAppIsLoading(false)
sendMessageToIframe({
messageId: INTERFACE_MESSAGES.ON_SAFE_INFO,
data: {
safeAddress: safeAddress as string,
network: NETWORK_NAME.toLowerCase() as LowercaseNetworks,
ethBalance: ethBalance as string,
},
})
}, [ethBalance, safeAddress, appUrl, sendMessageToIframe])
const onUserTxConfirm = (safeTxHash: string) => {
sendMessageToIframe(
{ messageId: INTERFACE_MESSAGES.TRANSACTION_CONFIRMED, data: { safeTxHash } },
confirmTransactionModal.requestId,
)
}
const onTxReject = () => {
sendMessageToIframe(
{ messageId: INTERFACE_MESSAGES.TRANSACTION_REJECTED, data: {} },
confirmTransactionModal.requestId,
)
}
const openRemoveModal = () => setIsRemoveModalOpen(true)
const closeRemoveModal = () => setIsRemoveModalOpen(false)
const removeApp = async () => {
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || []
const filteredList = persistedAppList.filter((a) => a.url !== safeApp?.url)
saveToStorage(APPS_STORAGE_KEY, filteredList)
const goToApp = `${matchSafeWithAddress?.url}/apps`
history.push(goToApp)
}
useEffect(() => {
const loadApp = async () => {
const app = await getAppInfoFromUrl(appUrl)
const existsStaticApp = staticAppsList.some((staticApp) => staticApp.url === app.url)
setIsAppDeletable(!existsStaticApp)
setSafeApp(app)
}
loadApp()
}, [appUrl])
//track GA
useEffect(() => {
if (safeApp) {
trackEvent({ category: SAFE_NAVIGATION_EVENT, action: 'Apps', label: safeApp.name })
}
}, [safeApp, trackEvent])
if (!appUrl) {
throw Error('App url No provided or it is invalid.')
}
if (!safeApp) {
return (
<LoadingContainer>
<Loader size="md" />
</LoadingContainer>
)
}
if (consentReceived === false) {
return <LegalDisclaimer onCancel={redirectToBalance} onConfirm={onConsentReceipt} />
}
if (network === 'UNKNOWN' || !granted) {
if (NETWORK_NAME === 'UNKNOWN' || !granted) {
return (
<Centered style={{ height: '476px' }}>
<OwnerDisclaimer>
<FixedIcon type="notOwner" />
<Title size="xs">To use apps, you must be an owner of this Safe</Title>
</Centered>
</OwnerDisclaimer>
)
}
return (
<IframeWrapper>
<AppWrapper>
<Menu>
<Breadcrumb />
{isAppDeletable && (
<ButtonLink color="error" iconType="delete" onClick={openRemoveModal}>
Remove app
</ButtonLink>
)}
</Menu>
<StyledCard>
{appIsLoading && (
<LoadingContainer>
<Loader size="md" />
</LoadingContainer>
)}
<StyledIframe
frameBorder="0"
id={`iframe-${selectedApp.name}`}
id={`iframe-${appUrl}`}
ref={iframeRef}
src={selectedApp.url}
title={selectedApp.name}
src={appUrl}
title={safeApp.name}
onLoad={onIframeLoad}
/>
</IframeWrapper>
</StyledCard>
{isRemoveModalOpen && (
<GenericModal
title={
<Title size="sm" withoutMargin>
Remove app
</Title>
}
body={<Text size="md">This action will remove {safeApp.name} from the interface</Text>}
footer={
<ModalFooterConfirmation
cancelText="Cancel"
handleCancel={closeRemoveModal}
handleOk={removeApp}
okText="Remove"
/>
}
onClose={closeRemoveModal}
/>
)}
<ConfirmTransactionModal
isOpen={confirmTransactionModal.isOpen}
app={safeApp as SafeApp}
safeAddress={safeAddress}
ethBalance={ethBalance as string}
safeName={safeName as string}
txs={confirmTransactionModal.txs}
onClose={closeConfirmationModal}
onUserConfirm={onUserTxConfirm}
params={confirmTransactionModal.params}
onTxReject={onTxReject}
/>
</AppWrapper>
)
})
}
export default AppFrame

View File

@ -0,0 +1,126 @@
import React, { useState } from 'react'
import styled, { css } from 'styled-components'
import { useSelector } from 'react-redux'
import { GenericModal, IconText, Loader, Menu } from '@gnosis.pm/safe-react-components'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import AppCard from 'src/routes/safe/components/Apps/components/AppCard'
import AddAppIcon from 'src/routes/safe/components/Apps/assets/addApp.svg'
import { useRouteMatch, useHistory } from 'react-router-dom'
import { SAFELIST_ADDRESS } from 'src/routes/routes'
import { useAppList } from '../hooks/useAppList'
import { SAFE_APP_FETCH_STATUS, SafeApp } from '../types.d'
import AddAppForm from './AddAppForm'
const Wrapper = styled.div`
height: 100%;
display: flex;
flex-direction: column;
`
const centerCSS = css`
display: flex;
align-items: center;
justify-content: center;
`
const LoadingContainer = styled.div`
width: 100%;
height: 100%;
${centerCSS};
`
const CardsWrapper = styled.div`
width: 100%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(243px, 1fr));
column-gap: 20px;
row-gap: 20px;
justify-content: space-evenly;
margin: 0 0 16px 0;
`
const ContentWrapper = styled.div`
display: flex;
flex-direction: column;
justify-content: space-between;
flex-grow: 1;
align-items: center;
`
const Breadcrumb = styled.div`
height: 51px;
`
const AppsList = (): React.ReactElement => {
const history = useHistory()
const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const { appList } = useAppList()
const [isAddAppModalOpen, setIsAddAppModalOpen] = useState<boolean>(false)
const onAddAppHandler = (url: string) => () => {
const goToApp = `${matchSafeWithAddress?.url}/apps?appUrl=${encodeURI(url)}`
history.push(goToApp)
}
const openAddAppModal = () => setIsAddAppModalOpen(true)
const closeAddAppModal = () => setIsAddAppModalOpen(false)
const isAppLoading = (app: SafeApp) => SAFE_APP_FETCH_STATUS.LOADING === app.fetchStatus
if (!appList.length || !safeAddress) {
return (
<LoadingContainer>
<Loader size="md" />
</LoadingContainer>
)
}
return (
<Wrapper>
<Menu>
{/* TODO: Add navigation breadcrumb. Empty for now to give some top margin */}
<Breadcrumb />
</Menu>
<ContentWrapper>
<CardsWrapper>
<AppCard iconUrl={AddAppIcon} onClick={openAddAppModal} buttonText="Add custom app" iconSize="lg" />
{appList
.filter((a) => a.fetchStatus !== SAFE_APP_FETCH_STATUS.ERROR)
.map((a) => (
<AppCard
isLoading={isAppLoading(a)}
key={a.url}
iconUrl={a.iconUrl}
name={a.name}
description={a.description}
onClick={onAddAppHandler(a.url)}
/>
))}
</CardsWrapper>
<IconText
color="secondary"
iconSize="sm"
iconType="info"
text="These are third-party apps, which means they are not owned, controlled, maintained or audited by Gnosis. Interacting with the apps is at your own risk. Any communication within the Apps is for informational purposes only and must not be construed as investment advice to engage in any transaction."
textSize="sm"
/>
</ContentWrapper>
{isAddAppModalOpen && (
<GenericModal
title="Add custom app"
body={<AddAppForm closeModal={closeAddAppModal} appList={appList} />}
onClose={closeAddAppModal}
/>
)}
</Wrapper>
)
}
export default AppsList

View File

@ -1,75 +0,0 @@
import { ButtonLink, ManageListModal } from '@gnosis.pm/safe-react-components'
import React, { useState } from 'react'
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
import AddAppForm from '../AddAppForm'
import { SafeApp } from '../types'
const FORM_ID = 'add-apps-form'
type Props = {
appList: Array<SafeApp>
onAppAdded: (app: SafeApp) => void
onAppToggle: (appId: string, enabled: boolean) => void
onAppRemoved: (appId: string) => void
}
type AppListItem = SafeApp & { checked: boolean }
const ManageApps = ({ appList, onAppAdded, onAppToggle, onAppRemoved }: Props): React.ReactElement => {
const [isOpen, setIsOpen] = useState(false)
const [isSubmitDisabled, setIsSubmitDisabled] = useState(true)
const onSubmitForm = () => {
// This sucks, but it's the way the docs suggest
// https://github.com/final-form/react-final-form/blob/master/docs/faq.md#via-documentgetelementbyid
document.querySelectorAll(`[data-testId=${FORM_ID}]`)[0].dispatchEvent(new Event('submit', { cancelable: true }))
}
const toggleOpen = () => setIsOpen(!isOpen)
const closeModal = () => setIsOpen(false)
const getItemList = (): AppListItem[] =>
appList.map((a) => {
return { ...a, checked: !a.disabled }
})
const onItemToggle = (itemId: string, checked: boolean): void => {
onAppToggle(itemId, checked)
}
const Form = (
<AddAppForm
formId={FORM_ID}
appList={appList}
closeModal={closeModal}
onAppAdded={onAppAdded}
setIsSubmitDisabled={setIsSubmitDisabled}
/>
)
return (
<>
<ButtonLink color="primary" onClick={toggleOpen}>
Manage Apps
</ButtonLink>
{isOpen && (
<ManageListModal
addButtonLabel="Add custom app"
showDeleteButton
defaultIconUrl={appsIconSvg}
formBody={Form}
isSubmitFormDisabled={isSubmitDisabled}
itemList={getItemList()}
onClose={closeModal}
onItemToggle={onItemToggle}
onItemDeleted={onAppRemoved}
onSubmitForm={onSubmitForm}
/>
)}
</>
)
}
export default ManageApps

View File

@ -1,141 +1,62 @@
import { useState, useEffect, useCallback } from 'react'
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
import { getAppInfoFromUrl, staticAppsList } from '../utils'
import { SafeApp, StoredSafeApp } from '../types'
import { useState, useEffect } from 'react'
import { loadFromStorage } from 'src/utils/storage'
import { APPS_STORAGE_KEY, getAppInfoFromUrl, getEmptySafeApp, staticAppsList } from '../utils'
import { SafeApp, StoredSafeApp, SAFE_APP_FETCH_STATUS } from '../types.d'
import { getNetworkId } from 'src/config'
const APPS_STORAGE_KEY = 'APPS_STORAGE_KEY'
type onAppToggleHandler = (appId: string, enabled: boolean) => Promise<void>
type onAppAddedHandler = (app: SafeApp) => void
type onAppRemovedHandler = (appId: string) => void
type UseAppListReturnType = {
appList: SafeApp[]
loadingAppList: boolean
onAppToggle: onAppToggleHandler
onAppAdded: onAppAddedHandler
onAppRemoved: onAppRemovedHandler
}
const useAppList = (): UseAppListReturnType => {
const [appList, setAppList] = useState<SafeApp[]>([])
const [loadingAppList, setLoadingAppList] = useState<boolean>(true)
// Load apps list
// for each URL we return a mocked safe-app with a loading status
// it was developed to speed up initial page load, otherwise the
// app renders a loading until all the safe-apps are fetched.
useEffect(() => {
const fetchAppCallback = (res: SafeApp) => {
setAppList((prevStatus) => {
const cpPrevStatus = [...prevStatus]
const appIndex = cpPrevStatus.findIndex((a) => a.url === res.url)
const newStatus = res.error ? SAFE_APP_FETCH_STATUS.ERROR : SAFE_APP_FETCH_STATUS.SUCCESS
cpPrevStatus[appIndex] = { ...res, fetchStatus: newStatus }
return cpPrevStatus.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
})
}
const loadApps = async () => {
// recover apps from storage:
// * third-party apps added by the user
// * disabled status for both static and third-party apps
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || []
let list: (StoredSafeApp & { isDeletable: boolean; networks?: number[] })[] = persistedAppList.map((a) => ({
...a,
isDeletable: true,
// recover apps from storage (third-party apps added by the user)
const persistedAppList =
(await loadFromStorage<(StoredSafeApp & { networks?: number[] })[]>(APPS_STORAGE_KEY)) || []
// backward compatibility. In a previous implementation a safe app could be disabled, that state was
// persisted in the storage.
const customApps = persistedAppList.filter(
(persistedApp) => !staticAppsList.some((staticApp) => staticApp.url === persistedApp.url),
)
const apps: SafeApp[] = [...staticAppsList, ...customApps]
// if the app does not expose supported networks, include them. (backward compatible)
.filter((app) => (!app.networks ? true : app.networks.includes(getNetworkId())))
.map((app) => ({
...getEmptySafeApp(),
url: app.url.trim(),
}))
// merge stored apps with static apps (apps added manually can be deleted by the user)
staticAppsList.forEach((staticApp) => {
const app = list.find((persistedApp) => persistedApp.url === staticApp.url)
if (app) {
app.isDeletable = false
app.networks = staticApp.networks
} else {
list.push({ ...staticApp, isDeletable: false })
}
})
// filter app by network
list = list.filter((app) => {
// if the app does not expose supported networks, include them. (backward compatible)
if (!app.networks) {
return true
}
return app.networks.includes(getNetworkId())
})
let apps: SafeApp[] = []
// using the appURL to recover app info
for (let index = 0; index < list.length; index++) {
try {
const currentApp = list[index]
const appInfo: SafeApp = await getAppInfoFromUrl(currentApp.url)
if (appInfo.error) {
throw Error(`There was a problem trying to load app ${currentApp.url}`)
}
appInfo.disabled = Boolean(currentApp.disabled)
appInfo.isDeletable = Boolean(currentApp.isDeletable) === undefined ? true : currentApp.isDeletable
apps.push(appInfo)
} catch (error) {
console.error(error)
}
}
apps = apps.sort((a, b) => a.name.localeCompare(b.name))
setAppList(apps)
setLoadingAppList(false)
apps.forEach((app) => getAppInfoFromUrl(app.url).then(fetchAppCallback))
}
if (!appList.length) {
loadApps()
}, [])
const onAppToggle: onAppToggleHandler = useCallback(
async (appId, enabled) => {
// update in-memory list
const appListCopy = [...appList]
const app = appListCopy.find((a) => a.id === appId)
if (!app) {
return
}
app.disabled = !enabled
setAppList(appListCopy)
// update storage list
const listToPersist: StoredSafeApp[] = appListCopy.map(({ url, disabled }) => ({ url, disabled }))
saveToStorage(APPS_STORAGE_KEY, listToPersist)
},
[appList],
)
const onAppAdded: onAppAddedHandler = useCallback(
(app) => {
const newAppList = [
{ url: app.url, disabled: false },
...appList.map((a) => ({
url: a.url,
disabled: a.disabled,
})),
]
saveToStorage(APPS_STORAGE_KEY, newAppList)
setAppList([...appList, { ...app, isDeletable: true }])
},
[appList],
)
const onAppRemoved: onAppRemovedHandler = useCallback(
(appId) => {
const appListCopy = appList.filter((a) => a.id !== appId)
setAppList(appListCopy)
const listToPersist: StoredSafeApp[] = appListCopy.map(({ url, disabled }) => ({ url, disabled }))
saveToStorage(APPS_STORAGE_KEY, listToPersist)
},
[appList],
)
}, [appList])
return {
appList,
loadingAppList,
onAppToggle,
onAppAdded,
onAppRemoved,
}
}

View File

@ -3,8 +3,8 @@ import { loadFromStorage, saveToStorage } from 'src/utils/storage'
const APPS_LEGAL_CONSENT_RECEIVED = 'APPS_LEGAL_CONSENT_RECEIVED'
const useLegalConsent = (): { consentReceived: boolean; onConsentReceipt: () => void } => {
const [consentReceived, setConsentReceived] = useState<boolean>(false)
const useLegalConsent = (): { consentReceived: boolean | undefined; onConsentReceipt: () => void } => {
const [consentReceived, setConsentReceived] = useState<boolean | undefined>()
useEffect(() => {
const checkLegalDisclaimer = async () => {
@ -12,6 +12,8 @@ const useLegalConsent = (): { consentReceived: boolean; onConsentReceipt: () =>
if (storedConsentReceived) {
setConsentReceived(true)
} else {
setConsentReceived(false)
}
}

View File

@ -1,234 +1,23 @@
import React, { useCallback, useEffect, useRef, useState, useMemo } from 'react'
import {
INTERFACE_MESSAGES,
Transaction,
RequestId,
LowercaseNetworks,
SendTransactionParams,
} from '@gnosis.pm/safe-apps-sdk'
import { Card, IconText, Loader, Menu, Title } from '@gnosis.pm/safe-react-components'
import { useSelector } from 'react-redux'
import styled, { css } from 'styled-components'
import React from 'react'
import ManageApps from './components/ManageApps'
import AppFrame from './components/AppFrame'
import { useAppList } from './hooks/useAppList'
import { SafeApp } from './types.d'
import AppsList from './components/AppsList'
import LCL from 'src/components/ListContentLayout'
import { grantedSelector } from 'src/routes/safe/container/selector'
import {
safeEthBalanceSelector,
safeParamAddressFromStateSelector,
safeNameSelector,
} from 'src/logic/safe/store/selectors'
import { isSameURL } from 'src/utils/url'
import { useIframeMessageHandler } from './hooks/useIframeMessageHandler'
import ConfirmTransactionModal from './components/ConfirmTransactionModal'
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
import { getNetworkName } from 'src/config'
import { useLocation } from 'react-router-dom'
const centerCSS = css`
display: flex;
align-items: center;
justify-content: center;
`
const LoadingContainer = styled.div`
width: 100%;
height: 100%;
${centerCSS};
`
const StyledCard = styled(Card)`
margin-bottom: 24px;
${centerCSS};
`
const CenteredMT = styled.div`
${centerCSS};
margin-top: 16px;
`
type ConfirmTransactionModalState = {
isOpen: boolean
txs: Transaction[]
requestId?: RequestId
params?: SendTransactionParams
const useQuery = () => {
return new URLSearchParams(useLocation().search)
}
const INITIAL_CONFIRM_TX_MODAL_STATE: ConfirmTransactionModalState = {
isOpen: false,
txs: [],
requestId: undefined,
params: undefined,
}
const NETWORK_NAME = getNetworkName()
const Apps = (): React.ReactElement => {
const { appList, loadingAppList, onAppToggle, onAppAdded, onAppRemoved } = useAppList()
const query = useQuery()
const appUrl = query.get('appUrl')
const [appIsLoading, setAppIsLoading] = useState<boolean>(true)
const [selectedAppId, setSelectedAppId] = useState<string>()
const [confirmTransactionModal, setConfirmTransactionModal] = useState<ConfirmTransactionModalState>(
INITIAL_CONFIRM_TX_MODAL_STATE,
)
const iframeRef = useRef<HTMLIFrameElement>(null)
const { trackEvent } = useAnalytics()
const granted = useSelector(grantedSelector)
const safeAddress = useSelector(safeParamAddressFromStateSelector)
const safeName = useSelector(safeNameSelector)
const ethBalance = useSelector(safeEthBalanceSelector)
const openConfirmationModal = useCallback(
(txs: Transaction[], params: SendTransactionParams | undefined, requestId: RequestId) =>
setConfirmTransactionModal({
isOpen: true,
txs,
requestId,
params,
}),
[setConfirmTransactionModal],
)
const closeConfirmationModal = useCallback(() => setConfirmTransactionModal(INITIAL_CONFIRM_TX_MODAL_STATE), [
setConfirmTransactionModal,
])
const selectedApp = useMemo(() => appList.find((app) => app.id === selectedAppId), [appList, selectedAppId])
const enabledApps = useMemo(() => appList.filter((a) => !a.disabled), [appList])
const { sendMessageToIframe } = useIframeMessageHandler(
selectedApp,
openConfirmationModal,
closeConfirmationModal,
iframeRef,
)
const onUserTxConfirm = (safeTxHash: string) => {
sendMessageToIframe(
{ messageId: INTERFACE_MESSAGES.TRANSACTION_CONFIRMED, data: { safeTxHash } },
confirmTransactionModal.requestId,
)
if (appUrl) {
return <AppFrame appUrl={appUrl} />
} else {
return <AppsList />
}
const onTxReject = () => {
sendMessageToIframe(
{ messageId: INTERFACE_MESSAGES.TRANSACTION_REJECTED, data: {} },
confirmTransactionModal.requestId,
)
}
const onSelectApp = useCallback(
(appId) => {
if (selectedAppId === appId) {
return
}
setAppIsLoading(true)
setSelectedAppId(appId)
},
[selectedAppId],
)
// Auto Select app first App
useEffect(() => {
const selectFirstEnabledApp = () => {
const firstEnabledApp = appList.find((a) => !a.disabled)
if (firstEnabledApp) {
setSelectedAppId(firstEnabledApp.id)
}
}
const initialSelect = appList.length && !selectedAppId
const currentAppWasDisabled = selectedApp?.disabled
if (initialSelect || currentAppWasDisabled) {
selectFirstEnabledApp()
}
}, [appList, selectedApp, selectedAppId, trackEvent])
// track GA
useEffect(() => {
if (selectedApp) {
trackEvent({ category: SAFE_NAVIGATION_EVENT, action: 'Apps', label: selectedApp.name })
}
}, [selectedApp, trackEvent])
const handleIframeLoad = useCallback(() => {
const iframe = iframeRef.current
if (!iframe || !selectedApp || !isSameURL(iframe.src, selectedApp.url)) {
return
}
setAppIsLoading(false)
sendMessageToIframe({
messageId: INTERFACE_MESSAGES.ON_SAFE_INFO,
data: {
safeAddress: safeAddress as string,
network: NETWORK_NAME.toLowerCase() as LowercaseNetworks,
ethBalance: ethBalance as string,
},
})
}, [ethBalance, safeAddress, selectedApp, sendMessageToIframe])
if (loadingAppList || !appList.length || !safeAddress) {
return (
<LoadingContainer>
<Loader size="md" />
</LoadingContainer>
)
}
return (
<>
<Menu>
<ManageApps appList={appList} onAppAdded={onAppAdded} onAppToggle={onAppToggle} onAppRemoved={onAppRemoved} />
</Menu>
{enabledApps.length ? (
<LCL.Wrapper>
<LCL.Menu>
<LCL.List activeItem={selectedAppId} items={enabledApps} onItemClick={onSelectApp} />
</LCL.Menu>
<LCL.Content>
<AppFrame
ref={iframeRef}
granted={granted}
selectedApp={selectedApp}
safeAddress={safeAddress}
network={NETWORK_NAME}
appIsLoading={appIsLoading}
onIframeLoad={handleIframeLoad}
/>
</LCL.Content>
</LCL.Wrapper>
) : (
<StyledCard>
<Title size="xs">No Apps Enabled</Title>
</StyledCard>
)}
<CenteredMT>
<IconText
color="secondary"
iconSize="sm"
iconType="info"
text="These are third-party apps, which means they are not owned, controlled, maintained or audited by Gnosis. Interacting with the apps is at your own risk."
textSize="sm"
/>
</CenteredMT>
<ConfirmTransactionModal
isOpen={confirmTransactionModal.isOpen}
app={selectedApp as SafeApp}
safeAddress={safeAddress}
ethBalance={ethBalance as string}
safeName={safeName as string}
txs={confirmTransactionModal.txs}
onClose={closeConfirmationModal}
onUserConfirm={onUserTxConfirm}
params={confirmTransactionModal.params}
onTxReject={onTxReject}
/>
</>
)
}
export default Apps

View File

@ -1,15 +1,20 @@
export enum SAFE_APP_FETCH_STATUS {
LOADING = 'LOADING',
SUCCESS = 'SUCCESS',
ERROR = 'ERROR',
}
export type SafeApp = {
id: string
url: string
name: string
iconUrl: string
disabled?: boolean
isDeletable?: boolean
error: boolean
description: string
error: boolean
fetchStatus: SAFE_APP_FETCH_STATUS
}
export type StoredSafeApp = {
url: string
disabled?: boolean
}

View File

@ -1,13 +1,15 @@
import axios from 'axios'
import memoize from 'lodash.memoize'
import { SafeApp } from './types.d'
import { SafeApp, SAFE_APP_FETCH_STATUS } from './types.d'
import { getGnosisSafeAppsUrl } from 'src/config'
import { getContentFromENS } from 'src/logic/wallets/getWeb3'
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
import appsIconSvg from 'src/assets/icons/apps.svg'
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
export const APPS_STORAGE_KEY = 'APPS_STORAGE_KEY'
const removeLastTrailingSlash = (url) => {
if (url.substr(-1) === '/') {
return url.substr(0, url.length - 1)
@ -16,7 +18,12 @@ const removeLastTrailingSlash = (url) => {
}
const gnosisAppsUrl = removeLastTrailingSlash(getGnosisSafeAppsUrl())
export const staticAppsList: Array<{ url: string; disabled: boolean; networks: number[] }> = [
export type StaticAppInfo = {
url: string
disabled: boolean
networks: number[]
}
export const staticAppsList: Array<StaticAppInfo> = [
// 1inch
{
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmUDTSghr154kCCGguyA3cbG5HRVd2tQgNR7yD69bcsjm5`,
@ -57,7 +64,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean; networks: n
},
// Sablier
{
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmfLqzEHz5TEupRLPuFp7prtcVAm6hKii5YZsVZWeM17Lr`,
url: `${process.env.REACT_APP_IPFS_GATEWAY}/Qmb1Xpfu9mnX4A3trpoVeBZ9sTiNtEuRoFKEiaVXWntDxB`,
disabled: false,
networks: [ETHEREUM_NETWORK.MAINNET, ETHEREUM_NETWORK.RINKEBY],
},
@ -81,7 +88,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean; networks: n
},
// TX-Builder
{
url: `${gnosisAppsUrl}/tx-builder`,
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmXdrr9hRbXSaqMb71iKnEp66PwwsAbJDR9XdwByUYSTxB`,
disabled: false,
networks: [
ETHEREUM_NETWORK.MAINNET,
@ -93,7 +100,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean; networks: n
},
// Wallet-Connect
{
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmVWjxqMYuqZ4WvxKdrErcTt1Sx5JHxZosjYz9zHiHRAiq`,
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmWwSuByB3B3hLU5ita3RQgiSEDYtBr5LjjDCRGb8YqLKF`,
disabled: false,
networks: [
ETHEREUM_NETWORK.MAINNET,
@ -111,7 +118,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean; networks: n
},
]
export const getAppInfoFromOrigin = (origin: string): Record<string, string> | null => {
export const getAppInfoFromOrigin = (origin: string): { url: string; name: string } | null => {
try {
return JSON.parse(origin)
} catch (error) {
@ -132,9 +139,25 @@ export const isAppManifestValid = (appInfo: SafeApp): boolean =>
// no `error` (or `error` undefined)
!appInfo.error
export const getEmptySafeApp = (): SafeApp => {
return {
id: Math.random().toString(),
url: '',
name: 'unknown',
iconUrl: appsIconSvg,
error: false,
description: '',
fetchStatus: SAFE_APP_FETCH_STATUS.LOADING,
}
}
export const getAppInfoFromUrl = memoize(
async (appUrl: string): Promise<SafeApp> => {
let res = { id: '', url: appUrl, name: 'unknown', iconUrl: appsIconSvg, error: true, description: '' }
let res = {
...getEmptySafeApp(),
error: true,
loadingStatus: SAFE_APP_FETCH_STATUS.ERROR,
}
if (!appUrl?.length) {
return res
@ -161,6 +184,7 @@ export const getAppInfoFromUrl = memoize(
...appInfo.data,
id: JSON.stringify({ url: res.url, name: appInfo.data.name.substring(0, remainingSpace) }),
error: false,
loadingStatus: SAFE_APP_FETCH_STATUS.SUCCESS,
}
if (appInfo.data.iconPath) {
@ -196,10 +220,10 @@ export const getIpfsLinkFromEns = memoize(
)
export const uniqueApp = (appList: SafeApp[]) => (url: string): string | undefined => {
const newUrl = new URL(url)
const exists = appList.some((a) => {
try {
const currentUrl = new URL(a.url)
const newUrl = new URL(url)
return currentUrl.href === newUrl.href
} catch (error) {
console.error('There was a problem trying to validate the URL existence.', error.message)

View File

@ -9,7 +9,7 @@ import { CustomTx } from './screens/ContractInteraction/ReviewCustomTx'
import { ContractInteractionTx } from './screens/ContractInteraction'
import { CustomTxProps } from './screens/ContractInteraction/SendCustomTx'
import { ReviewTxProp } from './screens/ReviewTx'
import { NFTToken } from 'src/logic/collectibles/sources/collectibles'
import { NFTToken } from 'src/logic/collectibles/sources/collectibles.d'
import { SendCollectibleTxInfo } from './screens/SendCollectible'
const ChooseTxType = React.lazy(() => import('./screens/ChooseTxType'))

View File

@ -20,6 +20,7 @@ import { trimSpaces } from 'src/utils/strings'
export interface AddressBookProps {
fieldMutator: (address: string) => void
label?: string
pristine?: boolean
recipientAddress?: string
setIsValidAddress: (valid: boolean) => void
@ -36,6 +37,7 @@ export interface BaseAddressBookInputProps extends AddressBookProps {
const BaseAddressBookInput = ({
addressBookEntries,
fieldMutator,
label = 'Recipient',
setIsValidAddress,
setSelectedEntry,
setValidationText,
@ -137,7 +139,7 @@ const BaseAddressBookInput = ({
fullWidth
id="filled-error-helper-text"
variant="filled"
label={validationText ? validationText : 'Recipient'}
label={validationText ? validationText : label}
InputLabelProps={{ shrink: true, required: true, classes: labelStyles }}
InputProps={{ ...params.InputProps, classes: inputStyles }}
/>

View File

@ -27,7 +27,7 @@ export interface EthAddressInputProps {
text: string
}
const EthAddressInput = ({
export const EthAddressInput = ({
isContract = true,
isRequired = true,
name,
@ -57,6 +57,7 @@ const EthAddressInput = ({
scannedAddress = scannedAddress.replace('ethereum:', '')
}
setSelectedEntry({ address: scannedAddress })
onScannedValue(scannedAddress)
closeQrModal()
}
@ -97,5 +98,3 @@ const EthAddressInput = ({
</>
)
}
export default EthAddressInput

View File

@ -22,11 +22,12 @@ import Hairline from 'src/components/layout/Hairline'
import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import ScanQRModal from 'src/components/ScanQRModal'
import { ScanQRModal } from 'src/components/ScanQRModal'
import { safeSelector } from 'src/logic/safe/store/selectors'
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
import { ContractsAddressBookInput } from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
import { sm } from 'src/theme/variables'
import { sameString } from 'src/utils/strings'
import ArrowDown from '../../assets/arrow-down.svg'
@ -147,7 +148,7 @@ const SendCustomTx: React.FC<Props> = ({ initialValues, onClose, onNext, contrac
{selectedEntry && selectedEntry.address ? (
<div
onKeyDown={(e) => {
if (e.key === 'Tab') {
if (sameString(e.key, 'Tab')) {
return
}
setSelectedEntry(null)

View File

@ -11,7 +11,7 @@ import { safeSelector } from 'src/logic/safe/store/selectors'
import Paragraph from 'src/components/layout/Paragraph'
import Buttons from './Buttons'
import ContractABI from './ContractABI'
import EthAddressInput from './EthAddressInput'
import { EthAddressInput } from './EthAddressInput'
import FormDivisor from './FormDivisor'
import FormErrorMessage from './FormErrorMessage'
import Header from './Header'

View File

@ -1,7 +1,6 @@
import IconButton from '@material-ui/core/IconButton'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import { BigNumber } from 'bignumber.js'
import React, { useEffect, useMemo, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { toTokenUnit, fromTokenUnit } from 'src/logic/tokens/utils/humanReadableValue'
@ -16,17 +15,21 @@ import Hairline from 'src/components/layout/Hairline'
import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { getSpendingLimitContract } from 'src/logic/contracts/safeContracts'
import createTransaction from 'src/logic/safe/store/actions/createTransaction'
import { safeSelector } from 'src/logic/safe/store/selectors'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { estimateTxGasCosts } from 'src/logic/safe/transactions/gas'
import { getHumanFriendlyToken } from 'src/logic/tokens/store/actions/fetchTokens'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { sameAddress, ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses'
import { EMPTY_DATA } from 'src/logic/wallets/ethTransactions'
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
import { extendedSafeTokensSelector } from 'src/routes/safe/container/selector'
import { SpendingLimit } from 'src/logic/safe/store/models/safe'
import { sm } from 'src/theme/variables'
import { sameString } from 'src/utils/strings'
import ArrowDown from '../assets/arrow-down.svg'
@ -42,6 +45,8 @@ export type ReviewTxProp = {
amount: string
txRecipient: string
token: string
txType?: string
tokenSpendingLimit?: SpendingLimit
}
type ReviewTxProps = {
@ -58,8 +63,8 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
const [gasCosts, setGasCosts] = useState('< 0.001')
const [data, setData] = useState('')
const txToken = useMemo(() => tokens.find((token) => token.address === tx.token), [tokens, tx.token])
const isSendingETH = txToken?.address === nativeCoin.address
const txToken = useMemo(() => tokens.find((token) => sameAddress(token.address, tx.token)), [tokens, tx.token])
const isSendingETH = sameAddress(txToken?.address, nativeCoin.address)
const txRecipient = isSendingETH ? tx.recipientAddress : txToken?.address
useEffect(() => {
@ -75,8 +80,7 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
if (!isSendingETH) {
const StandardToken = await getHumanFriendlyToken()
const tokenInstance = await StandardToken.at(txToken.address as string)
const decimals = await tokenInstance.decimals()
const txAmount = new BigNumber(tx.amount).times(10 ** decimals.toNumber()).toString()
const txAmount = toTokenUnit(tx.amount, txToken.decimals)
txData = tokenInstance.contract.methods.transfer(tx.recipientAddress, txAmount).encodeABI()
}
@ -99,12 +103,34 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
}, [isSendingETH, safeAddress, tx.amount, tx.recipientAddress, txRecipient, txToken])
const submitTx = async () => {
const isSpendingLimit = sameString(tx.txType, 'spendingLimit')
// txAmount should be 0 if we send tokens
// the real value is encoded in txData and will be used by the contract
// if txAmount > 0 it would send ETH from the Safe
const txAmount = isSendingETH ? toTokenUnit(tx.amount, nativeCoin.decimals) : '0'
if (safeAddress) {
if (!safeAddress) {
console.error('There was an error trying to submit the transaction, the safeAddress was not found')
return
}
if (isSpendingLimit && txToken && tx.tokenSpendingLimit) {
const spendingLimit = getSpendingLimitContract()
spendingLimit.methods
.executeAllowanceTransfer(
safeAddress,
sameAddress(txToken.address, nativeCoin.address) ? ZERO_ADDRESS : txToken.address,
tx.recipientAddress,
toTokenUnit(tx.amount, txToken.decimals),
ZERO_ADDRESS,
0,
tx.tokenSpendingLimit.delegate,
EMPTY_DATA,
)
.send({ from: tx.tokenSpendingLimit.delegate })
.on('transactionHash', () => onClose())
.catch(console.error)
} else {
dispatch(
createTransaction({
safeAddress: safeAddress,
@ -114,11 +140,9 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
notifiedTransaction: TX_NOTIFICATION_TYPES.STANDARD_TX,
}),
)
} else {
console.error('There was an error trying to submit the transaction, the safeAddress was not found')
}
onClose()
}
}
return (
<>

View File

@ -13,7 +13,7 @@ import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph'
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
import { textShortener } from 'src/utils/strings'
import { NFTToken } from 'src/logic/collectibles/sources/collectibles'
import { NFTToken } from 'src/logic/collectibles/sources/collectibles.d'
const useSelectedCollectibleStyles = makeStyles(selectedTokenStyles)

View File

@ -14,7 +14,7 @@ import Paragraph from 'src/components/layout/Paragraph'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
import { textShortener } from 'src/utils/strings'
import { NFTAssets } from 'src/logic/collectibles/sources/collectibles'
import { NFTAssets } from 'src/logic/collectibles/sources/collectibles.d'
const useSelectedTokenStyles = makeStyles(selectedTokenStyles)

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