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 # For Apps
REACT_APP_GNOSIS_APPS_URL=https://safe-apps.staging.gnosisdev.com 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 !.eslintrc.js
build build
config /config
contracts /contracts
flow-typed flow-typed
flow-typed/npm flow-typed/npm
migrations migrations
@ -9,5 +9,5 @@ node_modules
public public
scripts scripts
src/assets src/assets
src/config src/types/contracts
test test

View File

@ -1,6 +1,6 @@
{ {
"name": "safe-react", "name": "safe-react",
"version": "2.15.1", "version": "2.16.0",
"description": "Allowing crypto users manage funds in a safer way", "description": "Allowing crypto users manage funds in a safer way",
"website": "https://github.com/gnosis/safe-react#readme", "website": "https://github.com/gnosis/safe-react#readme",
"bugs": { "bugs": {
@ -16,7 +16,7 @@
"email": "safe@gnosis.io" "email": "safe@gnosis.io"
}, },
"main": "public/electron.js", "main": "public/electron.js",
"postinstall": "electron-builder install-app-deps", "postinstall": "patch-package electron-builder install-app-deps",
"scripts": { "scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'", "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", "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-build": "electron-builder --mac --windows --linux",
"electron-dev": "concurrently \"BROWSER=none yarn start\" \"wait-on http://localhost:3000 && electron .\"", "electron-dev": "concurrently \"BROWSER=none yarn start\" \"wait-on http://localhost:3000 && electron .\"",
"format:staged": "lint-staged", "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: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:check": "eslint './src/**/*.{js,jsx,ts,tsx}'",
"lint:fix": "yarn lint:check --fix", "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", "preelectron-pack": "yarn build",
"prettier:check": "yarn prettier --check", "prettier:check": "yarn prettier --check",
"prettier:fix": "yarn prettier --write", "prettier:fix": "yarn prettier --write",
@ -168,16 +169,16 @@
"dependencies": { "dependencies": {
"@gnosis.pm/safe-apps-sdk": "https://github.com/gnosis/safe-apps-sdk.git#3f0689f", "@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-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", "@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/core": "4.11.0",
"@material-ui/icons": "4.9.1", "@material-ui/icons": "4.9.1",
"@material-ui/lab": "4.0.0-alpha.56", "@material-ui/lab": "4.0.0-alpha.56",
"@openzeppelin/contracts": "3.1.0", "@openzeppelin/contracts": "3.1.0",
"@sentry/react": "^5.27.3", "@sentry/react": "^5.27.0",
"@sentry/tracing": "^5.27.3", "@sentry/tracing": "^5.27.0",
"@truffle/contract": "4.2.28", "@truffle/contract": "4.2.30",
"async-sema": "^3.1.0", "async-sema": "^3.1.0",
"axios": "0.21.0", "axios": "0.21.0",
"bignumber.js": "9.0.1", "bignumber.js": "9.0.1",
@ -209,25 +210,25 @@
"material-ui-search-bar": "^1.0.0", "material-ui-search-bar": "^1.0.0",
"notistack": "https://github.com/gnosis/notistack.git#v0.9.4", "notistack": "https://github.com/gnosis/notistack.git#v0.9.4",
"qrcode.react": "1.0.0", "qrcode.react": "1.0.0",
"query-string": "6.13.6", "query-string": "6.13.7",
"react": "16.13.1", "react": "16.13.1",
"react-dom": "16.13.1", "react-dom": "16.13.1",
"react-final-form": "^6.5.2", "react-final-form": "^6.5.2",
"react-final-form-listeners": "^1.0.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-hot-loader": "4.13.0",
"react-qr-reader": "^2.2.1", "react-qr-reader": "^2.2.1",
"react-redux": "7.2.1", "react-redux": "7.2.2",
"react-router-dom": "5.2.0", "react-router-dom": "5.2.0",
"react-scripts": "^3.4.3", "react-scripts": "^3.4.3",
"react-window": "^1.8.5", "react-window": "^1.8.6",
"recompose": "^0.30.0", "recompose": "^0.30.0",
"redux": "4.0.5", "redux": "4.0.5",
"redux-actions": "^2.6.5", "redux-actions": "^2.6.5",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"reselect": "^4.0.0", "reselect": "^4.0.0",
"semver": "7.3.2", "semver": "7.3.2",
"styled-components": "^5.2.0", "styled-components": "^5.2.1",
"web3": "1.2.11", "web3": "1.2.11",
"web3-core": "^1.2.11", "web3-core": "^1.2.11",
"web3-eth-contract": "^1.2.11", "web3-eth-contract": "^1.2.11",
@ -238,7 +239,7 @@
"@storybook/addon-actions": "^5.3.19", "@storybook/addon-actions": "^5.3.19",
"@storybook/addon-links": "^5.3.19", "@storybook/addon-links": "^5.3.19",
"@storybook/addons": "^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", "@storybook/react": "^5.3.19",
"@testing-library/jest-dom": "5.11.5", "@testing-library/jest-dom": "5.11.5",
"@testing-library/react": "10.4.9", "@testing-library/react": "10.4.9",
@ -258,24 +259,25 @@
"cross-env": "^7.0.2", "cross-env": "^7.0.2",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"dotenv-expand": "^5.1.0", "dotenv-expand": "^5.1.0",
"electron": "9.3.3", "electron": "^9.3.3",
"electron-builder": "22.9.1", "electron-builder": "22.9.1",
"electron-notarize": "1.0.0", "electron-notarize": "1.0.0",
"eslint": "6.8.0", "eslint": "6.8.0",
"eslint-config-prettier": "6.14.0", "eslint-config-prettier": "^6.14.0",
"eslint-plugin-import": "2.22.1", "eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.3.1", "eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-prettier": "^3.1.4", "eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "^7.21.5", "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", "ethereumjs-abi": "0.6.8",
"husky": "^4.3.0", "husky": "^4.3.0",
"lint-staged": "^10.5.1", "lint-staged": "^10.5.1",
"node-sass": "^4.14.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-app-rewired": "^2.1.6",
"react-docgen-typescript-loader": "^3.7.2", "typechain": "^4.0.0",
"typechain": "^2.0.0",
"typescript": "4.0.5", "typescript": "4.0.5",
"wait-on": "5.2.0" "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) { function createWindow(port = DEFAULT_PORT) {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
show: false, show: false,
width: 1024, width: 1366,
height: 768, height: 768,
webPreferences: { webPreferences: {
preload: path.join(__dirname, '../scripts/preload.js'), 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 ( return (
<> <>
{/* Network */}
<StyledTextLabel size="sm" networkInfo={networkInfo}> <StyledTextLabel size="sm" networkInfo={networkInfo}>
{networkInfo.label} {networkInfo.label}
</StyledTextLabel> </StyledTextLabel>
<Container> <Container>
{/* Identicon */}
<IdenticonContainer> <IdenticonContainer>
<FlexSpacer /> <FlexSpacer />
<Identicon address={address} size="lg" /> <Identicon address={address} size="lg" />
@ -142,6 +145,7 @@ const SafeHeader = ({
</UnStyledButton> </UnStyledButton>
</IdenticonContainer> </IdenticonContainer>
{/* SafeInfo */}
<Text size="xl">{safeName}</Text> <Text size="xl">{safeName}</Text>
<StyledEthHashInfo hash={address} shortenHash={4} textSize="sm" /> <StyledEthHashInfo hash={address} shortenHash={4} textSize="sm" />
<IconContainer> <IconContainer>

View File

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

View File

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

View File

@ -6,53 +6,62 @@ import Header from './Header'
import Footer from './Footer' import Footer from './Footer'
import Sidebar from './Sidebar' import Sidebar from './Sidebar'
const Grid = styled.div` const Container = styled.div`
height: 100%; height: 100vh;
overflow: auto; width: 100vw;
display: flex;
flex-direction: column;
background-color: ${({ theme }) => theme.colors.background}; 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; background-color: white;
box-shadow: 0 2px 4px 0 rgba(212, 212, 211, 0.59); box-shadow: 0 0 4px 0 rgba(212, 212, 211, 0.59);
border-bottom: 2px solid ${({ theme }) => theme.colors.separator};
z-index: 999;
grid-area: topbar;
` `
const GridSidebarWrapper = styled.aside` const BodyWrapper = styled.div`
width: 200px; height: calc(100% - 54px);
padding: 62px 8px 0 8px; width: 100%;
display: flex;
flex-direction: row;
`
const SidebarWrapper = styled.aside`
height: 100%; height: 100%;
width: 200px;
display: flex;
flex-direction: column;
z-index: 1;
padding: 8px 8px 0 8px;
background-color: ${({ theme }) => theme.colors.white}; background-color: ${({ theme }) => theme.colors.white};
border-right: 2px solid ${({ theme }) => theme.colors.separator}; border-right: 2px solid ${({ theme }) => theme.colors.separator};
`
const ContentWrapper = styled.section`
width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box; overflow-x: auto;
position: fixed;
grid-area: sidebar;
`
const GridBodyWrapper = styled.section` padding: 0 16px;
margin: 0 16px 0 16px;
grid-area: body;
display: flex;
flex-direction: column;
align-content: stretch;
`
export const BodyWrapper = styled.div` > :nth-child(1) {
flex: 1 100%; flex-grow: 1;
` width: 100%;
align-items: center;
justify-content: center;
}
export const FooterWrapper = styled.footer` > :nth-child(2) {
margin: 0 16px; width: 100%;
height: 59px;
}
` `
type Props = { type Props = {
@ -77,29 +86,29 @@ const Layout: React.FC<Props> = ({
children, children,
sidebarItems, sidebarItems,
}): React.ReactElement => ( }): React.ReactElement => (
<Grid> <Container>
<GridTopbarWrapper> <HeaderWrapper>
<Header /> <Header />
</GridTopbarWrapper> </HeaderWrapper>
<GridSidebarWrapper> <BodyWrapper>
<Sidebar <SidebarWrapper>
items={sidebarItems} <Sidebar
safeAddress={safeAddress} items={sidebarItems}
safeName={safeName} safeAddress={safeAddress}
balance={balance} safeName={safeName}
granted={granted} balance={balance}
onToggleSafeList={onToggleSafeList} granted={granted}
onReceiveClick={onReceiveClick} onToggleSafeList={onToggleSafeList}
onNewTransactionClick={onNewTransactionClick} onReceiveClick={onReceiveClick}
/> onNewTransactionClick={onNewTransactionClick}
</GridSidebarWrapper> />
<GridBodyWrapper> </SidebarWrapper>
<BodyWrapper>{children}</BodyWrapper> <ContentWrapper>
<FooterWrapper> <div>{children}</div>
<Footer /> <Footer />
</FooterWrapper> </ContentWrapper>
</GridBodyWrapper> </BodyWrapper>
</Grid> </Container>
) )
export default Layout 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 transactionDataCheck from 'src/logic/wallets/transactionDataCheck'
import { getSupportedWallets } from 'src/logic/wallets/utils/walletList' import { getSupportedWallets } from 'src/logic/wallets/utils/walletList'
import { store } from 'src/store' import { store } from 'src/store'
import { BLOCKNATIVE_KEY } from 'src/utils/constants'
const networkId = getNetworkId() const networkId = getNetworkId()
@ -18,7 +17,6 @@ let providerName
const wallets = getSupportedWallets() const wallets = getSupportedWallets()
export const onboard = Onboard({ export const onboard = Onboard({
dappId: BLOCKNATIVE_KEY,
networkId: networkId, networkId: networkId,
subscriptions: { subscriptions: {
wallet: (wallet) => { 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,52 +1,70 @@
import Modal from '@material-ui/core/Modal' 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 cn from 'classnames'
import * as React from 'react' import React, { ReactElement, ReactNode } from 'react'
import { sm } from 'src/theme/variables' import { sm } from 'src/theme/variables'
const styles = () => ({ const useStyles = makeStyles(
root: { createStyles({
alignItems: 'center', root: {
justifyContent: 'center', alignItems: 'center',
display: 'flex', justifyContent: 'center',
overflowY: 'scroll', display: 'flex',
}, overflowY: 'scroll',
paper: {
position: 'absolute',
top: '120px',
width: '500px',
height: '530px',
borderRadius: sm,
backgroundColor: '#ffffff',
boxShadow: '0 0 5px 0 rgba(74, 85, 121, 0.5)',
'&:focus': {
outline: 'none',
}, },
display: 'flex', paper: {
flexDirection: 'column', position: 'absolute',
}, top: '120px',
}) width: '500px',
height: '540px',
borderRadius: sm,
backgroundColor: '#ffffff',
boxShadow: '0 0 5px 0 rgba(74, 85, 121, 0.5)',
'&:focus': {
outline: 'none',
},
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 = ({ const GnoModal = ({
children, children,
classes,
description, description,
handleClose, handleClose,
modalClassName, modalClassName,
open, open,
paperClassName, paperClassName,
title, title,
}: any) => ( }: GnoModalProps): ReactElement => {
<Modal const classes = useStyles()
aria-describedby={description}
aria-labelledby={title}
className={cn(classes.root, modalClassName)}
onClose={handleClose}
open={open}
>
<div className={cn(classes.paper, paperClassName)}>{children}</div>
</Modal>
)
export default withStyles(styles as any)(GnoModal) return (
<Modal
aria-describedby={description}
aria-labelledby={title}
className={cn(classes.root, modalClassName)}
onClose={handleClose}
open={open}
>
<div className={cn(classes.paper, paperClassName)}>{children}</div>
</Modal>
)
}
export default GnoModal

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import { background, lg, secondaryText, sm } from 'src/theme/variables' import { background, lg, secondaryText, sm } from 'src/theme/variables'
import { createStyles } from '@material-ui/core'
export const styles = () => ({ export const styles = createStyles({
heading: { heading: {
padding: lg, padding: lg,
justifyContent: 'space-between', 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 () => { it('Returns undefined for an address not contained in the passed array', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe'] 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 () => { it('Returns an error message for an array with duplicated values', async () => {
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe'] 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 memoize from 'lodash.memoize'
import { isFeatureEnabled } from 'src/config' import { isFeatureEnabled } from 'src/config'
import { FEATURES } from 'src/config/networks/network.d' import { FEATURES } from 'src/config/networks/network.d'
import { List } from 'immutable'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
type ValidatorReturnType = string | undefined type ValidatorReturnType = string | undefined
type GenericValidatorType = (...args: unknown[]) => ValidatorReturnType export type GenericValidatorType = (...args: unknown[]) => ValidatorReturnType
type AsyncValidator = (...args: unknown[]) => Promise<ValidatorReturnType> type AsyncValidator = (...args: unknown[]) => Promise<ValidatorReturnType>
export type Validator = GenericValidatorType | AsyncValidator 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 ADDRESS_REPEATED_ERROR = 'Address already introduced'
export const uniqueAddress = (addresses: string[] | List<string>): GenericValidatorType => export const uniqueAddress = (addresses: string[] | List<string>): GenericValidatorType => (): ValidatorReturnType => {
memoize( // @ts-expect-error both list and array have signatures for map but TS thinks they're not compatible
(value: string): ValidatorReturnType => { const lowercaseAddresses = addresses.map((address) => address.toLowerCase())
const addressAlreadyExists = addresses.some((address) => sameAddress(value, address)) const uniqueAddresses = new Set(lowercaseAddresses)
return addressAlreadyExists ? ADDRESS_REPEATED_ERROR : undefined 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 => export const composeValidators = (...validators: Validator[]) => (value: unknown): ValidatorReturnType =>
validators.reduce( validators.reduce(

View File

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

View File

@ -32,9 +32,9 @@ const getCurrentEnvironment = (): string => {
} }
type NetworkSpecificConfiguration = EnvironmentSettings & { type NetworkSpecificConfiguration = EnvironmentSettings & {
network: NetworkSettings, network: NetworkSettings
disabledFeatures?: SafeFeatures, disabledFeatures?: SafeFeatures
disabledWallets?: Wallets, disabledWallets?: Wallets
} }
const configuration = (): NetworkSpecificConfiguration => { const configuration = (): NetworkSpecificConfiguration => {
@ -60,7 +60,7 @@ const configuration = (): NetworkSpecificConfiguration => {
...networkBaseConfig, ...networkBaseConfig,
network: configFile.network, network: configFile.network,
disabledFeatures: configFile.disabledFeatures, disabledFeatures: configFile.disabledFeatures,
disabledWallets: configFile.disabledWallets disabledWallets: configFile.disabledWallets,
} }
} }
@ -137,10 +137,10 @@ const fetchContractABI = memoize(
(url, contractAddress) => `${url}_${contractAddress}`, (url, contractAddress) => `${url}_${contractAddress}`,
) )
const getNetworkExplorerApiKey = (networkExplorerName: string): string | undefined=> { const getNetworkExplorerApiKey = (networkExplorerName: string): string | undefined => {
switch (networkExplorerName.toLowerCase()) { switch (networkExplorerName.toLowerCase()) {
case 'etherscan': { case 'etherscan': {
return ETHERSCAN_API_KEY return ETHERSCAN_API_KEY
} }
default: { default: {
return undefined return undefined
@ -148,7 +148,7 @@ const getNetworkExplorerApiKey = (networkExplorerName: string): string | undefin
} }
} }
export const getContractABI = async (contractAddress: string) =>{ export const getContractABI = async (contractAddress: string) => {
const { apiUrl, name } = getNetworkExplorerInfo() const { apiUrl, name } = getNetworkExplorerInfo()
const apiKey = getNetworkExplorerApiKey(name) const apiKey = getNetworkExplorerApiKey(name)
@ -181,7 +181,7 @@ export const getExplorerInfo = (hash: string): BlockScanInfo => {
const type = hash.length > 42 ? 'tx' : 'address' const type = hash.length > 42 ? 'tx' : 'address'
return () => ({ return () => ({
url: `${url}/${type}/${hash}`, url: `${url}/${type}/${hash}`,
alt: name || '', alt: name || '',
}) })
} }
} }

View File

@ -41,11 +41,10 @@ describe('Networks config files test', () => {
return return
} }
const environmentConfigKeys = Object const environmentConfigKeys = Object.keys(networkConfigElement).filter(
.keys(networkConfigElement) (environmentConfigKey) =>
.filter((environmentConfigKey) => environmentConfigKey.endsWith('Uri') && !!networkConfigElement[environmentConfigKey],
environmentConfigKey.endsWith('Uri') && !!networkConfigElement[environmentConfigKey] )
)
// Then // Then
environmentConfigKeys.forEach((environmentConfigKey) => { environmentConfigKeys.forEach((environmentConfigKey) => {
@ -53,7 +52,10 @@ describe('Networks config files test', () => {
const isValid = isValidURL(networkConfigElementUri) const isValid = isValidURL(networkConfigElementUri)
if (!isValid) { 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() expect(isValid).toBeTruthy()

View File

@ -1,5 +1,5 @@
import EwcLogo from 'src/config/assets/token_ewc.svg' 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 // @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 // once the oracle is fixed we need to remove the fixed value
@ -61,9 +61,6 @@ const mainnet: NetworkConfig = {
WALLETS.AUTHEREUM, WALLETS.AUTHEREUM,
WALLETS.LATTICE, WALLETS.LATTICE,
], ],
disabledFeatures: [
FEATURES.ENS_LOOKUP,
],
} }
export default mainnet export default mainnet

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,7 +23,7 @@ Sentry.init({
dsn: SENTRY_DSN, dsn: SENTRY_DSN,
release: `safe-react@${process.env.REACT_APP_APP_VERSION}`, release: `safe-react@${process.env.REACT_APP_APP_VERSION}`,
integrations: [new Integrations.BrowserTracing()], integrations: [new Integrations.BrowserTracing()],
sampleRate: 1, sampleRate: 0.2,
}) })
const root = document.getElementById('root') 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 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( export const getNameFromAddressBookSelector = createSelector(
addressBookSelector, addressBookSelector,
(_, address) => address, (_, address) => address,

View File

@ -2,7 +2,6 @@ import { List } from 'immutable'
import { import {
checkIfEntryWasDeletedFromAddressBook, checkIfEntryWasDeletedFromAddressBook,
getAddressBookFromStorage, getAddressBookFromStorage,
getAddressesListFromAddressBook,
getNameFromAddressBook, getNameFromAddressBook,
getOwnersWithNameFromAddressBook, getOwnersWithNameFromAddressBook,
isValidAddressBookName, 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', () => { describe('getNameFromSafeAddressBook', () => {
const entry1 = getMockAddressBookEntry('123456', 'test1') const entry1 = getMockAddressBookEntry('123456', 'test1')
const entry2 = getMockAddressBookEntry('78910', 'test2') 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 = { type GetNameFromAddressBookOptions = {
filterOnlyValidName: boolean 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 { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
import { nftAssetsListAddressesSelector } from 'src/logic/collectibles/store/selectors' import { nftAssetsListAddressesSelector } from 'src/logic/collectibles/store/selectors'
import { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions' import { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions'
@ -18,6 +18,14 @@ export const CK_ADDRESS = {
[ETHEREUM_NETWORK.RINKEBY]: '0x16baf0de678e52367adc69fd067e5edd1d33e3bf', [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) // safeTransferFrom(address,address,uint256)
export const SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH = '42842e0e' export const SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH = '42842e0e'
@ -50,12 +58,11 @@ export const getERC721Symbol = async (contractAddress: string): Promise<string>
try { try {
const ERC721token = await getERC721TokenContract() const ERC721token = await getERC721TokenContract()
const tokenInstance = await ERC721token.at(contractAddress) const tokenInstance = await ERC721token.at(contractAddress)
tokenSymbol = tokenInstance.symbol() tokenSymbol = await tokenInstance.symbol()
} catch (err) { } catch (err) {
// If the contract address is an ENS token contract, we know that the ERC721 standard is not proper implemented // If the contract address is an ENS token contract, we know that the ERC721 standard is not proper implemented
// The method symbol() is missing // The method symbol() is missing
const ENS_TOKEN_CONTRACT = '0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85' if (isENSContract(contractAddress)) {
if (sameAddress(contractAddress, ENS_TOKEN_CONTRACT)) {
return 'ENS' return 'ENS'
} }
console.error(`Failed to retrieve token symbol for ERC721 token ${contractAddress}`) 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 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 * Verifies if the provided contract is a valid ERC721
* @param {string} contractAddress * @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

@ -14,25 +14,33 @@ import { AbiItem } from 'web3-utils'
*/ */
type MethodsArgsType = Array<string | number> type MethodsArgsType = Array<string | number>
interface Props { interface Props {
abi: AbiItem[] abi: AbiItem[]
address: string address: string
batch?: BatchRequest batch?: BatchRequest
context?: unknown 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 contractInstance = new web3.eth.Contract(abi, address)
const localBatch = new web3.BatchRequest() const localBatch = new web3.BatchRequest()
const values = methods.map((methodObject) => { const values = methods.map((methodObject) => {
let method, type, args: MethodsArgsType = [] let method,
type,
args: MethodsArgsType = []
if (typeof methodObject === 'string') { if (typeof methodObject === 'string') {
method = methodObject method = methodObject
} else { } else {
({ method, type, args } = methodObject) ;({ method, type, args } = methodObject)
} }
return new Promise((resolve) => { 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 { 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 => { 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) { switch (method) {
// swapOwner case SAFE_METHODS_NAMES.SWAP_OWNER: {
case '0xe318b52b': { const params = {
const decodedParameters = web3.eth.abi.decodeParameters(['uint', 'address', 'address'], params) as string[] prevOwner: 'address',
return { oldOwner: 'address',
method: METHOD_TO_ID[methodId], newOwner: 'address',
parameters: [
{ name: 'oldOwner', type: 'address', value: decodedParameters[1] },
{ name: 'newOwner', type: 'address', value: decodedParameters[2] },
],
} }
// 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] }
} }
// addOwnerWithThreshold case SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD: {
case '0x0d582f13': { const params = {
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'uint'], params) owner: 'address',
return { _threshold: 'uint',
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'owner', type: 'address', value: decodedParameters[0] },
{ name: '_threshold', type: 'uint', value: decodedParameters[1] },
],
} }
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
} }
// removeOwner case SAFE_METHODS_NAMES.REMOVE_OWNER: {
case '0xf8dc5dd9': { const params = {
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params) prevOwner: 'address',
return { owner: 'address',
method: METHOD_TO_ID[methodId], _threshold: 'uint',
parameters: [
{ name: 'owner', type: 'address', value: decodedParameters[1] },
{ name: '_threshold', type: 'uint', value: decodedParameters[2] },
],
} }
// 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] }
} }
// changeThreshold case SAFE_METHODS_NAMES.CHANGE_THRESHOLD: {
case '0x694e80c3': { const params = {
const decodedParameters = web3.eth.abi.decodeParameters(['uint'], params) _threshold: 'uint',
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: '_threshold', type: 'uint', value: decodedParameters[0] },
],
} }
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
} }
// enableModule case SAFE_METHODS_NAMES.ENABLE_MODULE: {
case '0x610b5925': { const params = {
const decodedParameters = web3.eth.abi.decodeParameters(['address'], params) module: 'address',
return {
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'module', type: 'address', value: decodedParameters[0] },
],
} }
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
} }
// disableModule case SAFE_METHODS_NAMES.DISABLE_MODULE: {
case '0xe009cfde': { const params = {
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address'], params) prevModule: 'address',
return { module: 'address',
method: METHOD_TO_ID[methodId],
parameters: [
{ name: 'prevModule', type: 'address', value: decodedParameters[0] },
{ name: 'module', type: 'address', value: decodedParameters[1] },
],
} }
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
default:
return null
}
}
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)
}
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)
}
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',
}
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
}
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: default:
@ -81,57 +176,53 @@ export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null =>
} }
const isSafeMethod = (methodId: string): boolean => { const isSafeMethod = (methodId: string): boolean => {
return !!METHOD_TO_ID[methodId] return !!SAFE_METHOD_ID_TO_NAME[methodId]
} }
export const decodeMethods = (data: string): DataDecoded | null => { const isSpendingLimitMethod = (methodId: string): boolean => {
if(!data.length) { return !!SPENDING_LIMIT_METHOD_ID_TO_NAME[methodId]
}
export const decodeMethods = (data: string | null): DataDecoded | null => {
if (!data?.length) {
return null 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)) { if (isSafeMethod(methodId)) {
return decodeParamsFromSafeMethod(data) return decodeParamsFromSafeMethod(data)
} }
switch (methodId) { if (isSpendingLimitMethod(methodId)) {
// a9059cbb - transfer(address,uint256) return decodeParamsFromSpendingLimit(data)
case '0xa9059cbb': { }
const decodeParameters = web3.eth.abi.decodeParameters(['address', 'uint'], params)
return { const method = TOKEN_TRANSFER_METHOD_ID_TO_NAME[methodId]
method: 'transfer',
parameters: [ switch (method) {
{ name: 'to', type: '', value: decodeParameters[0] }, case TOKEN_TRANSFER_METHODS_NAMES.TRANSFER: {
{ name: 'value', type: '', value: decodeParameters[1] }, const params = {
], to: 'address',
value: 'uint',
} }
const parameters = decodeInfo({ paramsHash, params })
return { method, parameters }
} }
// 23b872dd - transferFrom(address,address,uint256) case TOKEN_TRANSFER_METHODS_NAMES.TRANSFER_FROM:
case '0x23b872dd': { case TOKEN_TRANSFER_METHODS_NAMES.SAFE_TRANSFER_FROM: {
const decodeParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params) const params = {
return { from: 'address',
method: 'transferFrom', to: 'address',
parameters: [ value: 'uint',
{ name: 'from', type: '', value: decodeParameters[0] },
{ name: 'to', type: '', value: decodeParameters[1] },
{ name: 'value', type: '', value: decodeParameters[2] },
],
} }
}
// 42842e0e - safeTransferFrom(address,address,uint256) const parameters = decodeInfo({ paramsHash, params })
case '0x42842e0e': {
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params) return { method, parameters }
return {
method: 'safeTransferFrom',
parameters: [
{ name: 'from', type: '', value: decodedParameters[0] },
{ name: 'to', type: '', value: decodedParameters[1] },
{ name: 'value', type: '', value: decodedParameters[2] },
],
}
} }
default: default:

View File

@ -1,17 +1,19 @@
import { AbiItem } from 'web3-utils' import { AbiItem } from 'web3-utils'
import GnosisSafeSol from '@gnosis.pm/safe-contracts/build/contracts/GnosisSafe.json' 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 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 Web3 from 'web3'
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d' 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 { ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses'
import { calculateGasOf, calculateGasPrice } from 'src/logic/wallets/ethTransactions' import { calculateGasOf, calculateGasPrice } from 'src/logic/wallets/ethTransactions'
import { getWeb3, getNetworkIdFrom } from 'src/logic/wallets/getWeb3' import { getWeb3, getNetworkIdFrom } from 'src/logic/wallets/getWeb3'
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d' import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
import { GnosisSafeProxyFactory } from 'src/types/contracts/GnosisSafeProxyFactory.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 SENTINEL_ADDRESS = '0x0000000000000000000000000000000000000001'
export const MULTI_SEND_ADDRESS = '0x8d29be29923b68abfdd21e541b9374737b49cdad' 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 DEFAULT_FALLBACK_HANDLER_ADDRESS = '0xd5D82B6aDDc9027B22dCA772Aa68D5d74cdBdF44'
export const SAFE_MASTER_COPY_ADDRESS_V10 = '0xb6029EA3B2c51D09a50B53CA8012FeEB05bDa35A' export const SAFE_MASTER_COPY_ADDRESS_V10 = '0xb6029EA3B2c51D09a50B53CA8012FeEB05bDa35A'
let proxyFactoryMaster: GnosisSafeProxyFactory let proxyFactoryMaster: GnosisSafeProxyFactory
let safeMaster: GnosisSafe let safeMaster: GnosisSafe
@ -28,13 +29,13 @@ let safeMaster: GnosisSafe
* @param {Web3} web3 * @param {Web3} web3
* @param {ETHEREUM_NETWORK} networkId * @param {ETHEREUM_NETWORK} networkId
*/ */
const createGnosisSafeContract = (web3: Web3, networkId: ETHEREUM_NETWORK) => { export const getGnosisSafeContract = (web3: Web3, networkId: ETHEREUM_NETWORK) => {
const networks = GnosisSafeSol.networks const networks = GnosisSafeSol.networks
// TODO: this may not be the most scalable approach, // TODO: this may not be the most scalable approach,
// but up until v1.2.0 the address is the same for all the networks. // 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. // 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 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 {Web3} web3
* @param {ETHEREUM_NETWORK} networkId * @param {ETHEREUM_NETWORK} networkId
*/ */
const createProxyFactoryContract = (web3: Web3, networkId: ETHEREUM_NETWORK): GnosisSafeProxyFactory => { const getProxyFactoryContract = (web3: Web3, networkId: ETHEREUM_NETWORK): GnosisSafeProxyFactory => {
const networks = ProxyFactorySol.networks const networks = ProxyFactorySol.networks
// TODO: this may not be the most scalable approach, // TODO: this may not be the most scalable approach,
// but up until v1.2.0 the address is the same for all the networks. // 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. // 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 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 web3 = getWeb3()
const networkId = await getNetworkIdFrom(web3) const networkId = await getNetworkIdFrom(web3)
// Create ProxyFactory Master Copy // Create ProxyFactory Master Copy
proxyFactoryMaster = getCreateProxyFactoryContract(web3, networkId) proxyFactoryMaster = getProxyFactoryContract(web3, networkId)
// Create Safe Master copy // Create Safe Master copy
safeMaster = getGnosisSafeContract(web3, networkId) safeMaster = getGnosisSafeContract(web3, networkId)
} }
export const initContracts = instantiateMasterCopies
export const getSafeMasterContract = async () => { export const getSafeMasterContract = async () => {
await initContracts() await instantiateSafeContracts()
return safeMaster return safeMaster
} }
export const getSafeDeploymentTransaction = (safeAccounts, numConfirmations) => { export const getSafeDeploymentTransaction = (
safeAccounts: string[],
numConfirmations: number,
safeCreationSalt: number,
) => {
const gnosisSafeData = safeMaster.methods 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() .encodeABI()
return proxyFactoryMaster.methods.createProxyWithNonce(safeMaster.options.address, gnosisSafeData, safeCreationSalt)
return proxyFactoryMaster.methods.createProxy(safeMaster.options.address, gnosisSafeData)
} }
export const estimateGasForDeployingSafe = async ( export const estimateGasForDeployingSafe = async (
safeAccounts, safeAccounts: string[],
numConfirmations, numConfirmations: number,
userAccount, userAccount: string,
safeCreationSalt: number,
) => { ) => {
const gnosisSafeData = await safeMaster.methods 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() .encodeABI()
const proxyFactoryData = proxyFactoryMaster.methods const proxyFactoryData = proxyFactoryMaster.methods
.createProxy(safeMaster.options.address, gnosisSafeData) .createProxyWithNonce(safeMaster.options.address, gnosisSafeData, safeCreationSalt)
.encodeABI() .encodeABI()
const gas = await calculateGasOf(proxyFactoryData, userAccount, proxyFactoryMaster.options.address) const gas = await calculateGasOf(proxyFactoryData, userAccount, proxyFactoryMaster.options.address)
const gasPrice = await calculateGasPrice() const gasPrice = await calculateGasPrice()
@ -101,29 +138,5 @@ export const estimateGasForDeployingSafe = async (
export const getGnosisSafeInstanceAt = (safeAddress: string): GnosisSafe => { export const getGnosisSafeInstanceAt = (safeAddress: string): GnosisSafe => {
const web3 = getWeb3() const web3 = getWeb3()
return new web3.eth.Contract(GnosisSafeSol.abi as AbiItem[], safeAddress) as unknown as GnosisSafe 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)
} }

View File

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

View File

@ -46,6 +46,18 @@ const NOTIFICATION_IDS = {
SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG: 'SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG', SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG: 'SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG',
SETTINGS_CHANGE_FAILED_MSG: 'SETTINGS_CHANGE_FAILED_MSG', SETTINGS_CHANGE_FAILED_MSG: 'SETTINGS_CHANGE_FAILED_MSG',
TESTNET_VERSION_MSG: 'TESTNET_VERSION_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', WRONG_NETWORK_MSG: 'WRONG_NETWORK_MSG',
ADDRESS_BOOK_NEW_ENTRY_SUCCESS: 'ADDRESS_BOOK_NEW_ENTRY_SUCCESS', ADDRESS_BOOK_NEW_ENTRY_SUCCESS: 'ADDRESS_BOOK_NEW_ENTRY_SUCCESS',
ADDRESS_BOOK_EDIT_ENTRY_SUCCESS: 'ADDRESS_BOOK_EDIT_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 }, 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 // Network
TESTNET_VERSION_MSG: { TESTNET_VERSION_MSG: {
message: "Testnet Version: Don't send production assets to this Safe", 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 { AppReduxState } from 'src/store'
import { Dispatch, DispatchReturn } from './types' import { Dispatch, DispatchReturn } from './types'
interface CreateTransactionArgs { export interface CreateTransactionArgs {
navigateToTransactionsTab?: boolean navigateToTransactionsTab?: boolean
notifiedTransaction: string notifiedTransaction: string
operation?: number operation?: number

View File

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

View File

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

View File

@ -3,9 +3,11 @@ import { ThunkAction, ThunkDispatch } from 'redux-thunk'
import { AnyAction } from 'redux' import { AnyAction } from 'redux'
import { backOff } from 'exponential-backoff' 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 { loadIncomingTransactions } from './loadIncomingTransactions'
import { loadModuleTransactions } from './loadModuleTransactions'
import { loadOutgoingTransactions } from './loadOutgoingTransactions' import { loadOutgoingTransactions } from './loadOutgoingTransactions'
import { addOrUpdateCancellationTransactions } from 'src/logic/safe/store/actions/transactions/addOrUpdateCancellationTransactions' 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) { if (safeIncomingTxs?.size) {
dispatch(addIncomingTransactions(incomingTransactions)) dispatch(addIncomingTransactions(incomingTransactions))
} }
const moduleTransactions = await loadModuleTransactions(safeAddress)
if (moduleTransactions.length) {
dispatch(addModuleTransactions({ modules: moduleTransactions, safeAddress }))
}
} catch (error) { } catch (error) {
console.log('Error fetching transactions:', 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 { makeIncomingTransaction } from 'src/logic/safe/store/models/incomingTransaction'
import fetchTransactions from 'src/logic/safe/store/actions/transactions/fetchTransactions/fetchTransactions' import fetchTransactions from 'src/logic/safe/store/actions/transactions/fetchTransactions/fetchTransactions'
import { TransactionTypes } from 'src/logic/safe/store/models/types/transaction' import { TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
import { isENSContract } from 'src/logic/collectibles/utils'
export type IncomingTxServiceModel = { export type IncomingTxServiceModel = {
blockNumber: number blockNumber: number
@ -76,12 +77,18 @@ const batchIncomingTxsTokenDataRequest = (txs: IncomingTxServiceModel[]) => {
batch.execute() batch.execute()
return Promise.all(whenTxsValues).then((txsValues) => return Promise.all(whenTxsValues).then((txsValues) =>
txsValues.map(([tx, symbol, decimals, ethTx, ethTxReceipt]) => [ txsValues.map(([tx, symbolFetched, decimals, ethTx, ethTxReceipt]) => {
tx, let symbol = symbolFetched
symbol ? symbol : nativeCoin.symbol, if (!symbolFetched) {
decimals ? decimals : nativeCoin.decimals, symbol = isENSContract(tx.tokenAddress) ? 'ENS' : nativeCoin.symbol
new bn(ethTx?.gasPrice ?? 0).times(ethTxReceipt?.gasUsed ?? 0), }
]), return [
tx,
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 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 = { export type SafeRecordProps = {
name: string name: string
@ -15,6 +30,7 @@ export type SafeRecordProps = {
ethBalance: string ethBalance: string
owners: List<SafeOwner> owners: List<SafeOwner>
modules?: ModulePair[] | null modules?: ModulePair[] | null
spendingLimits?: SpendingLimit[] | null
activeTokens: Set<string> activeTokens: Set<string>
activeAssets: Set<string> activeAssets: Set<string>
blacklistedTokens: Set<string> blacklistedTokens: Set<string>
@ -35,6 +51,7 @@ const makeSafe = Record<SafeRecordProps>({
ethBalance: '0', ethBalance: '0',
owners: List([]), owners: List([]),
modules: [], modules: [],
spendingLimits: [],
activeTokens: Set(), activeTokens: Set(),
activeAssets: Set(), activeAssets: Set(),
blacklistedTokens: Set(), blacklistedTokens: Set(),

View File

@ -1,4 +1,7 @@
import { List, Map, RecordOf } from 'immutable' 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 { Confirmation } from './confirmation'
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d' import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
import { DataDecoded, Transfer } from './transactions' import { DataDecoded, Transfer } from './transactions'
@ -14,6 +17,8 @@ export enum TransactionTypes {
UPGRADE = 'upgrade', UPGRADE = 'upgrade',
TOKEN = 'token', TOKEN = 'token',
COLLECTIBLE = 'collectible', COLLECTIBLE = 'collectible',
MODULE = 'module',
SPENDING_LIMIT = 'spendingLimit',
} }
export type TransactionTypeValues = typeof TransactionTypes[keyof typeof TransactionTypes] export type TransactionTypeValues = typeof TransactionTypes[keyof typeof TransactionTypes]
@ -47,7 +52,7 @@ export type TransactionProps = {
data: string | null data: string | null
dataDecoded: DataDecoded | null dataDecoded: DataDecoded | null
decimals?: (number | string) | null decimals?: (number | string) | null
decodedParams: DecodedParams | null decodedParams: DecodedParams
executionDate?: string | null executionDate?: string | null
executionTxHash?: string | null executionTxHash?: string | null
executor: string executor: string
@ -101,3 +106,17 @@ export type TxArgs = {
to: string to: string
valueInWei: 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', SWAP_OWNER: 'swapOwner',
ENABLE_MODULE: 'enableModule', ENABLE_MODULE: 'enableModule',
DISABLE_MODULE: 'disableModule', DISABLE_MODULE: 'disableModule',
} } as const
export const METHOD_TO_ID = { export const SAFE_METHOD_ID_TO_NAME = {
'0xe318b52b': SAFE_METHODS_NAMES.SWAP_OWNER, '0xe318b52b': SAFE_METHODS_NAMES.SWAP_OWNER,
'0x0d582f13': SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD, '0x0d582f13': SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD,
'0xf8dc5dd9': SAFE_METHODS_NAMES.REMOVE_OWNER, '0xf8dc5dd9': SAFE_METHODS_NAMES.REMOVE_OWNER,
'0x694e80c3': SAFE_METHODS_NAMES.CHANGE_THRESHOLD, '0x694e80c3': SAFE_METHODS_NAMES.CHANGE_THRESHOLD,
'0x610b5925': SAFE_METHODS_NAMES.ENABLE_MODULE, '0x610b5925': SAFE_METHODS_NAMES.ENABLE_MODULE,
'0xe009cfde': SAFE_METHODS_NAMES.DISABLE_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] 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', SAFE_TRANSFER_FROM: 'safeTransferFrom',
} as const } 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 TokenMethods = typeof TOKEN_TRANSFER_METHODS_NAMES[keyof typeof TOKEN_TRANSFER_METHODS_NAMES]
type SafeDecodedParams = { export type SafeDecodedParams = {
[key in SafeMethods]?: Record<string, string> [key in SafeMethods]?: Record<string, string>
} }
type TokenDecodedParams = { export type TokenDecodedParams = {
[key in TokenMethods]?: Record<string, string> [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 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) => { export const getActiveTokensAddressesForAllSafes = createSelector(safesListSelector, (safes) => {
const addresses = Set().withMutations((set) => { const addresses = Set().withMutations((set) => {
safes.forEach((safe) => { safes.forEach((safe) => {

View File

@ -2,10 +2,13 @@ import { List } from 'immutable'
import { createSelector } from 'reselect' import { createSelector } from 'reselect'
import { safeIncomingTransactionsSelector, safeTransactionsSelector } from 'src/logic/safe/store/selectors' 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( export const extendedTransactionsSelector = createSelector(
safeTransactionsSelector, safeTransactionsSelector,
safeIncomingTransactionsSelector, 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', STANDARD_TX: 'STANDARD_TX',
CONFIRMATION_TX: 'CONFIRMATION_TX', CONFIRMATION_TX: 'CONFIRMATION_TX',
CANCELLATION_TX: 'CANCELLATION_TX', CANCELLATION_TX: 'CANCELLATION_TX',
WAITING_TX: 'WAITING_TX', WAITING_TX: 'WAITING_TX',
SETTINGS_CHANGE_TX: 'SETTINGS_CHANGE_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', SAFE_NAME_CHANGE_TX: 'SAFE_NAME_CHANGE_TX',
OWNER_NAME_CHANGE_TX: 'OWNER_NAME_CHANGE_TX', OWNER_NAME_CHANGE_TX: 'OWNER_NAME_CHANGE_TX',
ADDRESSBOOK_NEW_ENTRY: 'ADDRESSBOOK_NEW_ENTRY', ADDRESSBOOK_NEW_ENTRY: 'ADDRESSBOOK_NEW_ENTRY',
ADDRESSBOOK_EDIT_ENTRY: 'ADDRESSBOOK_EDIT_ENTRY',
ADDRESSBOOK_DELETE_ENTRY: 'ADDRESSBOOK_DELETE_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 semverLessThan from 'semver/functions/lt'
import { getGnosisSafeInstanceAt, SENTINEL_ADDRESS } from 'src/logic/contracts/safeContracts' 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 { 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' import { SafeInfo } from 'src/logic/safe/utils/safeInformation'
type ModulesPaginated = { type ModulesPaginated = {
@ -9,11 +11,32 @@ type ModulesPaginated = {
next: string 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) { if (modules?.length) {
return modules.map((moduleAddress, index, modules) => { return modules.map((moduleAddress, index, modules) => {
const prevModule = modules[index + 1] if (index === 0) {
return [moduleAddress, prevModule !== undefined ? prevModule : nextModule] 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 // as we're not sure if there are more than 10 modules enabled for the current Safe
const safeInstance = getGnosisSafeInstanceAt(safeInfo.address) 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() const modules: ModulesPaginated = await safeInstance.methods.getModulesPaginated(SENTINEL_ADDRESS, 100).call()
return buildModulesLinkedList(modules.array, modules.next) return buildModulesLinkedList(modules.array)
} catch (e) { } catch (e) {
console.error('Failed to retrieve Safe modules', 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 => { export const getDisableModuleTxData = (modulePair: ModulePair, safeAddress: string): string => {
const [module, previousModule] = modulePair const [previousModule, module] = modulePair
const safeInstance = getGnosisSafeInstanceAt(safeAddress) const safeInstance = getGnosisSafeInstanceAt(safeAddress)
return safeInstance.methods.disableModule(previousModule, module).encodeABI() 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> => export const getCurrentSafeVersion = (gnosisSafeInstance: GnosisSafe): Promise<string> =>
gnosisSafeInstance.methods.VERSION().call() 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 return featureConfig.validVersion ? semverSatisfies(version, featureConfig.validVersion) : true
} }
export const enabledFeatures = (version?: string): FEATURES[] => { export const enabledFeatures = (version?: string): FEATURES[] => {
return FEATURES_BY_VERSION.reduce((acc: FEATURES[], feature: Feature) => { return FEATURES_BY_VERSION.reduce((acc, feature: Feature) => {
if (isFeatureEnabled(feature.name) && version && checkFeatureEnabledByVersion(feature, version)) { if (isFeatureEnabled(feature.name) && checkFeatureEnabledByVersion(feature, version)) {
acc.push(feature.name) acc.push(feature.name)
} }
return acc return acc
}, []) }, [] as FEATURES[])
} }
interface SafeVersionInfo { 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 { getWeb3 } from 'src/logic/wallets/getWeb3'
import { MultiSend } from 'src/types/contracts/MultiSend.d' import { MultiSend } from 'src/types/contracts/MultiSend.d'
interface MultiSendTx { export interface MultiSendTx {
operation: number operation: number
to: string to: string
value: number value: number

View File

@ -6,7 +6,7 @@ export type TokenProps = {
symbol: string symbol: string
decimals: number | string decimals: number | string
logoUri: string logoUri: string
balance?: number | string balance: number | string
} }
export const makeToken = Record<TokenProps>({ export const makeToken = Record<TokenProps>({
@ -15,7 +15,7 @@ export const makeToken = Record<TokenProps>({
symbol: '', symbol: '',
decimals: 0, decimals: 0,
logoUri: '', logoUri: '',
balance: undefined, balance: 0,
}) })
// balance is only set in extendedSafeTokensSelector when we display user's token balances // 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 { AbiItem } from 'web3-utils'
import { getNetworkInfo } from 'src/config' 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 { web3ReadOnly as web3 } from 'src/logic/wallets/getWeb3'
import { isEmptyData } from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers' import { isEmptyData } from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers'
import { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions' 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 => { export const getEthAsToken = (balance: string | number): Token => {
const { nativeCoin } = getNetworkInfo() const { nativeCoin } = getNetworkInfo()
@ -33,7 +36,13 @@ export const isAddressAToken = async (tokenAddress: string): Promise<boolean> =>
} }
export const isTokenTransfer = (tx: TxServiceModel): 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 ( export const getERC20DecimalsAndSymbol = async (
@ -75,3 +84,32 @@ export const isSendERC20Transaction = async (tx: TxServiceModel): Promise<boolea
return isSendTokenTx 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 => export const isUserAnOwnerOfAnySafe = (safes: List<SafeRecord> | SafeRecord[], userAccount: string): boolean =>
safes.some((safe: SafeRecord) => isUserAnOwner(safe, userAccount)) 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 Block from 'src/components/layout/Block'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
import Paragraph from 'src/components/layout/Paragraph' 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 { FIELD_LOAD_ADDRESS, FIELD_LOAD_NAME } from 'src/routes/load/components/fields'
import { secondary } from 'src/theme/variables' import { secondary } from 'src/theme/variables'
import { getSafeInfo } from 'src/logic/safe/utils/safeInformation'
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
@ -42,42 +41,23 @@ const useStyles = makeStyles({
}, },
}) })
export const SAFE_INSTANCE_ERROR = 'Address given is not a Safe instance' export const SAFE_ADDRESS_NOT_VALID = 'Address given is not a valid Safe address'
export const SAFE_MASTERCOPY_ERROR = 'Address is not a Safe or mastercopy is not supported'
// In case of an error here, it will be swallowed by final-form // 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 // So if you're experiencing any strang behaviours like freeze or hanging
// Don't mind to check if everything is OK inside this function :) // Don't mind to check if everything is OK inside this function :)
export const safeFieldsValidation = async (values): Promise<Record<string, string>> => { export const safeFieldsValidation = async (values): Promise<Record<string, string>> => {
const errors = {} const errors = {}
const web3 = getWeb3() const address = values[FIELD_LOAD_ADDRESS]
const safeAddress = values[FIELD_LOAD_ADDRESS]
if (!safeAddress || mustBeEthereumAddress(safeAddress) !== undefined) { if (!address || mustBeEthereumAddress(address) !== undefined) {
return errors return errors
} }
const isValidProxy = await validateProxy(safeAddress) // if getSafeInfo does not provide data, it's not a valid safe.
if (!isValidProxy) { const safeInfo = await getSafeInfo(address)
errors[FIELD_LOAD_ADDRESS] = SAFE_INSTANCE_ERROR if (!safeInfo) {
return errors errors[FIELD_LOAD_ADDRESS] = SAFE_ADDRESS_NOT_VALID
}
// 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
} }
return errors return errors

View File

@ -6,12 +6,13 @@ import Stepper, { StepperPage } from 'src/components/Stepper'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Heading from 'src/components/layout/Heading' import Heading from 'src/components/layout/Heading'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { initContracts } from 'src/logic/contracts/safeContracts' import { instantiateSafeContracts } from 'src/logic/contracts/safeContracts'
import Review from 'src/routes/open/components/ReviewInformation' import { Review } from 'src/routes/open/components/ReviewInformation'
import SafeNameField from 'src/routes/open/components/SafeNameForm' 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 { import {
FIELD_CONFIRMATIONS, FIELD_CONFIRMATIONS,
FIELD_CREATION_PROXY_SALT,
FIELD_SAFE_NAME, FIELD_SAFE_NAME,
getOwnerAddressBy, getOwnerAddressBy,
getOwnerNameBy, getOwnerNameBy,
@ -40,6 +41,7 @@ type InitialValuesForm = {
owner0Name?: string owner0Name?: string
confirmations: string confirmations: string
safeName?: string safeName?: string
safeCreationSalt: number
} }
const useInitialValuesFrom = (userAccount: string, safeProps?: SafeProps): InitialValuesForm => { const useInitialValuesFrom = (userAccount: string, safeProps?: SafeProps): InitialValuesForm => {
@ -51,6 +53,7 @@ const useInitialValuesFrom = (userAccount: string, safeProps?: SafeProps): Initi
[getOwnerNameBy(0)]: ownerName || 'My Wallet', [getOwnerNameBy(0)]: ownerName || 'My Wallet',
[getOwnerAddressBy(0)]: userAccount, [getOwnerAddressBy(0)]: userAccount,
[FIELD_CONFIRMATIONS]: '1', [FIELD_CONFIRMATIONS]: '1',
[FIELD_CREATION_PROXY_SALT]: Date.now(),
} }
} }
let obj = {} let obj = {}
@ -68,6 +71,7 @@ const useInitialValuesFrom = (userAccount: string, safeProps?: SafeProps): Initi
...obj, ...obj,
[FIELD_CONFIRMATIONS]: threshold || '1', [FIELD_CONFIRMATIONS]: threshold || '1',
[FIELD_SAFE_NAME]: name, [FIELD_SAFE_NAME]: name,
[FIELD_CREATION_PROXY_SALT]: Date.now(),
} }
} }
@ -92,7 +96,7 @@ type LayoutProps = {
safeProps?: SafeProps safeProps?: SafeProps
} }
const Layout = (props: LayoutProps): React.ReactElement => { export const Layout = (props: LayoutProps): React.ReactElement => {
const { onCallSafeContractSubmit, safeProps } = props const { onCallSafeContractSubmit, safeProps } = props
const provider = useSelector(providerNameSelector) const provider = useSelector(providerNameSelector)
@ -101,7 +105,7 @@ const Layout = (props: LayoutProps): React.ReactElement => {
useEffect(() => { useEffect(() => {
if (provider) { if (provider) {
initContracts() instantiateSafeContracts()
} }
}, [provider]) }, [provider])
@ -129,7 +133,7 @@ const Layout = (props: LayoutProps): React.ReactElement => {
testId="create-safe-form" testId="create-safe-form"
> >
<StepperPage component={SafeNameField} /> <StepperPage component={SafeNameField} />
<StepperPage component={SafeOwnersFields} /> <StepperPage component={SafeOwnersPage} />
<StepperPage network={network} userAccount={userAccount} component={Review} /> <StepperPage network={network} userAccount={userAccount} component={Review} />
</Stepper> </Stepper>
</Block> </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 OpenPaper from 'src/components/Stepper/OpenPaper'
import { estimateGasForDeployingSafe } from 'src/logic/contracts/safeContracts' import { estimateGasForDeployingSafe } from 'src/logic/contracts/safeContracts'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount' 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 { FIELD_CONFIRMATIONS, FIELD_NAME, getNumOwnersFrom } from '../fields'
import { useStyles } from './styles' import { useStyles } from './styles'
@ -33,20 +33,23 @@ const ReviewComponent = ({ userAccount, values }: ReviewComponentProps) => {
const names = getNamesFrom(values) const names = getNamesFrom(values)
const addresses = getAccountsFrom(values) const addresses = getAccountsFrom(values)
const numOwners = getNumOwnersFrom(values) const numOwners = getNumOwnersFrom(values)
const safeCreationSalt = getSafeCreationSaltFrom(values)
useEffect(() => { useEffect(() => {
const estimateGas = async () => { const estimateGas = async () => {
if (!addresses.length || !numOwners || !userAccount) { if (!addresses.length || !numOwners || !userAccount) {
return 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 gasCosts = fromTokenUnit(estimatedGasCosts, nativeCoin.decimals)
const formattedGasCosts = formatAmount(gasCosts) const formattedGasCosts = formatAmount(gasCosts)
setGasCosts(formattedGasCosts) setGasCosts(formattedGasCosts)
} }
estimateGas() estimateGas()
}, [addresses, numOwners, userAccount]) }, [addresses, numOwners, safeCreationSalt, userAccount])
return ( return (
<> <>
@ -140,7 +143,7 @@ const ReviewComponent = ({ userAccount, values }: ReviewComponentProps) => {
) )
} }
const Review = () => export const Review = () =>
function ReviewPage(controls, props): React.ReactElement { function ReviewPage(controls, props): React.ReactElement {
return ( 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 QRIcon from 'src/assets/icons/qrcode.svg'
import trash from 'src/assets/icons/trash.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 OpenPaper from 'src/components/Stepper/OpenPaper'
import AddressInput from 'src/components/forms/AddressInput' import AddressInput from 'src/components/forms/AddressInput'
import Field from 'src/components/forms/Field' import Field from 'src/components/forms/Field'
@ -97,10 +97,10 @@ const SafeOwnersForm = (props): React.ReactElement => {
setNumOwners(numOwners + 1) setNumOwners(numOwners + 1)
} }
const handleScan = (value) => { const handleScan = (value: string | null) => {
let scannedAddress = value let scannedAddress = value
if (scannedAddress.startsWith('ethereum:')) { if (scannedAddress?.startsWith('ethereum:')) {
scannedAddress = scannedAddress.replace('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 }) { function OpenSafeOwnersPage(controls, { errors, form, values }) {
return ( return (
<> <>
<OpenPaper controls={controls} padding={false}> <OpenPaper controls={controls} padding={false}>
<SafeOwnersForm <SafeOwnersForm errors={errors} form={form} otherAccounts={getAccountsFrom(values)} values={values} />
errors={errors}
form={form}
otherAccounts={getAccountsFrom(values)}
updateInitialProps={updateInitialProps}
values={values}
/>
</OpenPaper> </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 // thanks Rich Harris
// https://twitter.com/Rich_Harris/status/1125850391155965952 // https://twitter.com/Rich_Harris/status/1125850391155965952
const copy = addresses.slice() const copy = addresses.slice()

View File

@ -2,6 +2,7 @@ export const FIELD_NAME = 'name'
export const FIELD_CONFIRMATIONS = 'confirmations' export const FIELD_CONFIRMATIONS = 'confirmations'
export const FIELD_OWNERS = 'owners' export const FIELD_OWNERS = 'owners'
export const FIELD_SAFE_NAME = 'safeName' export const FIELD_SAFE_NAME = 'safeName'
export const FIELD_CREATION_PROXY_SALT = 'safeCreationSalt'
export const getOwnerNameBy = (index) => `owner${index}Name` export const getOwnerNameBy = (index) => `owner${index}Name`
export const getOwnerAddressBy = (index) => `owner${index}Address` 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 ReactGA from 'react-ga'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import Opening from 'src/routes/opening' 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 Page from 'src/components/layout/Page'
import { getSafeDeploymentTransaction } from 'src/logic/contracts/safeContracts' import { getSafeDeploymentTransaction } from 'src/logic/contracts/safeContracts'
import { checkReceiptStatus } from 'src/logic/wallets/ethTransactions' import { checkReceiptStatus } from 'src/logic/wallets/ethTransactions'
@ -12,6 +12,7 @@ import {
getAccountsFrom, getAccountsFrom,
getNamesFrom, getNamesFrom,
getOwnersFrom, getOwnersFrom,
getSafeCreationSaltFrom,
getSafeNameFrom, getSafeNameFrom,
getThresholdFrom, getThresholdFrom,
} from 'src/routes/open/utils/safeDataExtractor' } from 'src/routes/open/utils/safeDataExtractor'
@ -58,9 +59,9 @@ export const createSafe = (values, userAccount) => {
const name = getSafeNameFrom(values) const name = getSafeNameFrom(values)
const ownersNames = getNamesFrom(values) const ownersNames = getNamesFrom(values)
const ownerAddresses = getAccountsFrom(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 }) const promiEvent = deploymentTx.send({ from: userAccount })
promiEvent promiEvent

View File

@ -1,7 +1,7 @@
import { List } from 'immutable' import { List } from 'immutable'
import { makeOwner } from 'src/logic/safe/store/models/owner' 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) => { export const getAccountsFrom = (values) => {
const accounts = Object.keys(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 getThresholdFrom = (values) => Number(values.confirmations)
export const getSafeNameFrom = (values) => values.name 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 Heading from 'src/components/layout/Heading'
import Img from 'src/components/layout/Img' import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph' 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 { EMPTY_DATA } from 'src/logic/wallets/ethTransactions'
import { getWeb3 } from 'src/logic/wallets/getWeb3' import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { background, connected } from 'src/theme/variables' import { background, connected } from 'src/theme/variables'
@ -152,7 +152,7 @@ const SafeDeployment = ({ creationTxHash, onCancel, onRetry, onSuccess, submitte
useEffect(() => { useEffect(() => {
const loadContracts = async () => { const loadContracts = async () => {
await initContracts() await instantiateSafeContracts()
setLoading(false) setLoading(false)
} }

View File

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

View File

@ -63,12 +63,14 @@ export const EllipsisTransactionDetails = ({
<div className={classes.container} role="menu" tabIndex={0}> <div className={classes.container} role="menu" tabIndex={0}>
<MoreHorizIcon onClick={handleClick} onKeyDown={handleClick} /> <MoreHorizIcon onClick={handleClick} onKeyDown={handleClick} />
<Menu anchorEl={anchorEl} id="simple-menu" keepMounted onClose={closeMenuHandler} open={Boolean(anchorEl)}> <Menu anchorEl={anchorEl} id="simple-menu" keepMounted onClose={closeMenuHandler} open={Boolean(anchorEl)}>
{sendModalOpenHandler ? ( {sendModalOpenHandler
<> ? [
<MenuItem onClick={sendModalOpenHandler}>Send Again</MenuItem> <MenuItem key="send-again-button" onClick={sendModalOpenHandler}>
<Divider /> Send Again
</> </MenuItem>,
) : null} <Divider key="divider" />,
]
: null}
{knownAddress ? ( {knownAddress ? (
<MenuItem onClick={addOrEditEntryHandler}>Edit Address book Entry</MenuItem> <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 { TextField } from '@gnosis.pm/safe-react-components'
import React from 'react' import React, { useState, ReactElement } from 'react'
import styled from 'styled-components' 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 { SafeApp } from 'src/routes/safe/components/Apps/types.d'
import GnoForm from 'src/components/forms/GnoForm' import GnoForm from 'src/components/forms/GnoForm'
import Img from 'src/components/layout/Img' import Img from 'src/components/layout/Img'
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
const StyledText = styled(Text)` import AppAgreement from './AppAgreement'
margin-bottom: 19px; 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)` const StyledTextFileAppName = styled(TextField)`
&& { && {
@ -39,38 +40,34 @@ const INITIAL_VALUES: AddAppFormValues = {
agreementAccepted: false, agreementAccepted: false,
} }
const APP_INFO: SafeApp = { const APP_INFO = getEmptySafeApp()
id: '',
url: '',
name: '',
iconUrl: appsIconSvg,
error: false,
description: '',
}
interface AddAppProps { interface AddAppProps {
appList: SafeApp[] appList: SafeApp[]
closeModal: () => void closeModal: () => void
formId: string
onAppAdded: (app: SafeApp) => void
setIsSubmitDisabled: (disabled: boolean) => void
} }
const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }: AddAppProps): React.ReactElement => { const AddApp = ({ appList, closeModal }: AddAppProps): ReactElement => {
const [appInfo, setAppInfo] = React.useState<SafeApp>(APP_INFO) const [appInfo, setAppInfo] = useState<SafeApp>(APP_INFO)
const history = useHistory()
const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
const handleSubmit = () => { const handleSubmit = () => {
closeModal() const newAppList = [
onAppAdded(appInfo) { 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 ( 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} /> <AppUrl appList={appList} />
{/* Fetch app from url and return a SafeApp */} {/* Fetch app from url and return a SafeApp */}
<AppInfoUpdater onAppInfo={setAppInfo} /> <AppInfoUpdater onAppInfo={setAppInfo} />
@ -81,7 +78,7 @@ const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }
<AppAgreement /> <AppAgreement />
<SubmitButtonStatus onSubmitButtonStatusChange={setIsSubmitDisabled} appInfo={appInfo} /> <FormButtons appInfo={appInfo} onCancel={closeModal} />
</> </>
)} )}
</GnoForm> </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 styled from 'styled-components'
import { FixedIcon, Loader, Title } from '@gnosis.pm/safe-react-components' import {
import { useHistory } from 'react-router-dom' 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 { 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 { useLegalConsent } from '../hooks/useLegalConsent'
import { SafeApp } from '../types'
import LegalDisclaimer from './LegalDisclaimer' 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` const OwnerDisclaimer = styled.div`
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`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-direction: column; flex-direction: column;
height: 476px;
` `
type AppFrameProps = { const AppWrapper = styled.div`
selectedApp: SafeApp | undefined display: flex;
safeAddress: string flex-direction: column;
network: string height: 100%;
granted: boolean `
appIsLoading: boolean
onIframeLoad: () => void 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( type Props = {
{ selectedApp, safeAddress, network, appIsLoading, granted, onIframeLoad }, appUrl: string
iframeRef, }
): React.ReactElement {
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 history = useHistory()
const { consentReceived, onConsentReceipt } = useLegalConsent() 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`) const redirectToBalance = () => history.push(`${SAFELIST_ADDRESS}/${safeAddress}/balances`)
if (!selectedApp) { const openConfirmationModal = useCallback(
return <div /> (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
}
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,
)
} }
if (!consentReceived) { 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} /> return <LegalDisclaimer onCancel={redirectToBalance} onConfirm={onConsentReceipt} />
} }
if (network === 'UNKNOWN' || !granted) { if (NETWORK_NAME === 'UNKNOWN' || !granted) {
return ( return (
<Centered style={{ height: '476px' }}> <OwnerDisclaimer>
<FixedIcon type="notOwner" /> <FixedIcon type="notOwner" />
<Title size="xs">To use apps, you must be an owner of this Safe</Title> <Title size="xs">To use apps, you must be an owner of this Safe</Title>
</Centered> </OwnerDisclaimer>
) )
} }
return ( return (
<IframeWrapper> <AppWrapper>
{appIsLoading && ( <Menu>
<LoadingContainer> <Breadcrumb />
<Loader size="md" /> {isAppDeletable && (
</LoadingContainer> <ButtonLink color="error" iconType="delete" onClick={openRemoveModal}>
Remove app
</ButtonLink>
)}
</Menu>
<StyledCard>
{appIsLoading && (
<LoadingContainer>
<Loader size="md" />
</LoadingContainer>
)}
<StyledIframe
frameBorder="0"
id={`iframe-${appUrl}`}
ref={iframeRef}
src={appUrl}
title={safeApp.name}
onLoad={onIframeLoad}
/>
</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}
/>
)} )}
<StyledIframe
frameBorder="0" <ConfirmTransactionModal
id={`iframe-${selectedApp.name}`} isOpen={confirmTransactionModal.isOpen}
ref={iframeRef} app={safeApp as SafeApp}
src={selectedApp.url} safeAddress={safeAddress}
title={selectedApp.name} ethBalance={ethBalance as string}
onLoad={onIframeLoad} safeName={safeName as string}
txs={confirmTransactionModal.txs}
onClose={closeConfirmationModal}
onUserConfirm={onUserTxConfirm}
params={confirmTransactionModal.params}
onTxReject={onTxReject}
/> />
</IframeWrapper> </AppWrapper>
) )
}) }
export default AppFrame 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 { useState, useEffect } from 'react'
import { loadFromStorage, saveToStorage } from 'src/utils/storage' import { loadFromStorage } from 'src/utils/storage'
import { getAppInfoFromUrl, staticAppsList } from '../utils' import { APPS_STORAGE_KEY, getAppInfoFromUrl, getEmptySafeApp, staticAppsList } from '../utils'
import { SafeApp, StoredSafeApp } from '../types' import { SafeApp, StoredSafeApp, SAFE_APP_FETCH_STATUS } from '../types.d'
import { getNetworkId } from 'src/config' 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 = { type UseAppListReturnType = {
appList: SafeApp[] appList: SafeApp[]
loadingAppList: boolean
onAppToggle: onAppToggleHandler
onAppAdded: onAppAddedHandler
onAppRemoved: onAppRemovedHandler
} }
const useAppList = (): UseAppListReturnType => { const useAppList = (): UseAppListReturnType => {
const [appList, setAppList] = useState<SafeApp[]>([]) const [appList, setAppList] = useState<SafeApp[]>([])
const [loadingAppList, setLoadingAppList] = useState<boolean>(true)
// Load apps list // 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(() => { useEffect(() => {
const loadApps = async () => { const fetchAppCallback = (res: SafeApp) => {
// recover apps from storage: setAppList((prevStatus) => {
// * third-party apps added by the user const cpPrevStatus = [...prevStatus]
// * disabled status for both static and third-party apps const appIndex = cpPrevStatus.findIndex((a) => a.url === res.url)
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || [] const newStatus = res.error ? SAFE_APP_FETCH_STATUS.ERROR : SAFE_APP_FETCH_STATUS.SUCCESS
let list: (StoredSafeApp & { isDeletable: boolean; networks?: number[] })[] = persistedAppList.map((a) => ({ cpPrevStatus[appIndex] = { ...res, fetchStatus: newStatus }
...a, return cpPrevStatus.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
isDeletable: true,
}))
// 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)
} }
loadApps() const loadApps = async () => {
}, []) // recover apps from storage (third-party apps added by the user)
const persistedAppList =
(await loadFromStorage<(StoredSafeApp & { networks?: number[] })[]>(APPS_STORAGE_KEY)) || []
const onAppToggle: onAppToggleHandler = useCallback( // backward compatibility. In a previous implementation a safe app could be disabled, that state was
async (appId, enabled) => { // persisted in the storage.
// update in-memory list const customApps = persistedAppList.filter(
const appListCopy = [...appList] (persistedApp) => !staticAppsList.some((staticApp) => staticApp.url === persistedApp.url),
)
const app = appListCopy.find((a) => a.id === appId) const apps: SafeApp[] = [...staticAppsList, ...customApps]
if (!app) { // if the app does not expose supported networks, include them. (backward compatible)
return .filter((app) => (!app.networks ? true : app.networks.includes(getNetworkId())))
} .map((app) => ({
app.disabled = !enabled ...getEmptySafeApp(),
url: app.url.trim(),
}))
setAppList(appListCopy) setAppList(apps)
// update storage list apps.forEach((app) => getAppInfoFromUrl(app.url).then(fetchAppCallback))
const listToPersist: StoredSafeApp[] = appListCopy.map(({ url, disabled }) => ({ url, disabled })) }
saveToStorage(APPS_STORAGE_KEY, listToPersist)
},
[appList],
)
const onAppAdded: onAppAddedHandler = useCallback( if (!appList.length) {
(app) => { loadApps()
const newAppList = [ }
{ url: app.url, disabled: false }, }, [appList])
...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],
)
return { return {
appList, 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 APPS_LEGAL_CONSENT_RECEIVED = 'APPS_LEGAL_CONSENT_RECEIVED'
const useLegalConsent = (): { consentReceived: boolean; onConsentReceipt: () => void } => { const useLegalConsent = (): { consentReceived: boolean | undefined; onConsentReceipt: () => void } => {
const [consentReceived, setConsentReceived] = useState<boolean>(false) const [consentReceived, setConsentReceived] = useState<boolean | undefined>()
useEffect(() => { useEffect(() => {
const checkLegalDisclaimer = async () => { const checkLegalDisclaimer = async () => {
@ -12,6 +12,8 @@ const useLegalConsent = (): { consentReceived: boolean; onConsentReceipt: () =>
if (storedConsentReceived) { if (storedConsentReceived) {
setConsentReceived(true) setConsentReceived(true)
} else {
setConsentReceived(false)
} }
} }

View File

@ -1,234 +1,23 @@
import React, { useCallback, useEffect, useRef, useState, useMemo } from 'react' import React 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 ManageApps from './components/ManageApps'
import AppFrame from './components/AppFrame' import AppFrame from './components/AppFrame'
import { useAppList } from './hooks/useAppList' import AppsList from './components/AppsList'
import { SafeApp } from './types.d'
import LCL from 'src/components/ListContentLayout' import { useLocation } from 'react-router-dom'
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'
const centerCSS = css` const useQuery = () => {
display: flex; return new URLSearchParams(useLocation().search)
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 INITIAL_CONFIRM_TX_MODAL_STATE: ConfirmTransactionModalState = {
isOpen: false,
txs: [],
requestId: undefined,
params: undefined,
}
const NETWORK_NAME = getNetworkName()
const Apps = (): React.ReactElement => { 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) if (appUrl) {
const [selectedAppId, setSelectedAppId] = useState<string>() return <AppFrame appUrl={appUrl} />
const [confirmTransactionModal, setConfirmTransactionModal] = useState<ConfirmTransactionModalState>( } else {
INITIAL_CONFIRM_TX_MODAL_STATE, return <AppsList />
)
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,
)
} }
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 export default Apps

View File

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

View File

@ -1,13 +1,15 @@
import axios from 'axios' import axios from 'axios'
import memoize from 'lodash.memoize' 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 { getGnosisSafeAppsUrl } from 'src/config'
import { getContentFromENS } from 'src/logic/wallets/getWeb3' 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' import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
export const APPS_STORAGE_KEY = 'APPS_STORAGE_KEY'
const removeLastTrailingSlash = (url) => { const removeLastTrailingSlash = (url) => {
if (url.substr(-1) === '/') { if (url.substr(-1) === '/') {
return url.substr(0, url.length - 1) return url.substr(0, url.length - 1)
@ -16,7 +18,12 @@ const removeLastTrailingSlash = (url) => {
} }
const gnosisAppsUrl = removeLastTrailingSlash(getGnosisSafeAppsUrl()) 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 // 1inch
{ {
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmUDTSghr154kCCGguyA3cbG5HRVd2tQgNR7yD69bcsjm5`, url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmUDTSghr154kCCGguyA3cbG5HRVd2tQgNR7yD69bcsjm5`,
@ -57,7 +64,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean; networks: n
}, },
// Sablier // Sablier
{ {
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmfLqzEHz5TEupRLPuFp7prtcVAm6hKii5YZsVZWeM17Lr`, url: `${process.env.REACT_APP_IPFS_GATEWAY}/Qmb1Xpfu9mnX4A3trpoVeBZ9sTiNtEuRoFKEiaVXWntDxB`,
disabled: false, disabled: false,
networks: [ETHEREUM_NETWORK.MAINNET, ETHEREUM_NETWORK.RINKEBY], networks: [ETHEREUM_NETWORK.MAINNET, ETHEREUM_NETWORK.RINKEBY],
}, },
@ -81,7 +88,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean; networks: n
}, },
// TX-Builder // TX-Builder
{ {
url: `${gnosisAppsUrl}/tx-builder`, url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmXdrr9hRbXSaqMb71iKnEp66PwwsAbJDR9XdwByUYSTxB`,
disabled: false, disabled: false,
networks: [ networks: [
ETHEREUM_NETWORK.MAINNET, ETHEREUM_NETWORK.MAINNET,
@ -93,7 +100,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean; networks: n
}, },
// Wallet-Connect // Wallet-Connect
{ {
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmVWjxqMYuqZ4WvxKdrErcTt1Sx5JHxZosjYz9zHiHRAiq`, url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmWwSuByB3B3hLU5ita3RQgiSEDYtBr5LjjDCRGb8YqLKF`,
disabled: false, disabled: false,
networks: [ networks: [
ETHEREUM_NETWORK.MAINNET, 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 { try {
return JSON.parse(origin) return JSON.parse(origin)
} catch (error) { } catch (error) {
@ -132,9 +139,25 @@ export const isAppManifestValid = (appInfo: SafeApp): boolean =>
// no `error` (or `error` undefined) // no `error` (or `error` undefined)
!appInfo.error !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( export const getAppInfoFromUrl = memoize(
async (appUrl: string): Promise<SafeApp> => { 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) { if (!appUrl?.length) {
return res return res
@ -161,6 +184,7 @@ export const getAppInfoFromUrl = memoize(
...appInfo.data, ...appInfo.data,
id: JSON.stringify({ url: res.url, name: appInfo.data.name.substring(0, remainingSpace) }), id: JSON.stringify({ url: res.url, name: appInfo.data.name.substring(0, remainingSpace) }),
error: false, error: false,
loadingStatus: SAFE_APP_FETCH_STATUS.SUCCESS,
} }
if (appInfo.data.iconPath) { if (appInfo.data.iconPath) {
@ -196,10 +220,10 @@ export const getIpfsLinkFromEns = memoize(
) )
export const uniqueApp = (appList: SafeApp[]) => (url: string): string | undefined => { export const uniqueApp = (appList: SafeApp[]) => (url: string): string | undefined => {
const newUrl = new URL(url)
const exists = appList.some((a) => { const exists = appList.some((a) => {
try { try {
const currentUrl = new URL(a.url) const currentUrl = new URL(a.url)
const newUrl = new URL(url)
return currentUrl.href === newUrl.href return currentUrl.href === newUrl.href
} catch (error) { } catch (error) {
console.error('There was a problem trying to validate the URL existence.', error.message) 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 { ContractInteractionTx } from './screens/ContractInteraction'
import { CustomTxProps } from './screens/ContractInteraction/SendCustomTx' import { CustomTxProps } from './screens/ContractInteraction/SendCustomTx'
import { ReviewTxProp } from './screens/ReviewTx' 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' import { SendCollectibleTxInfo } from './screens/SendCollectible'
const ChooseTxType = React.lazy(() => import('./screens/ChooseTxType')) const ChooseTxType = React.lazy(() => import('./screens/ChooseTxType'))

View File

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

View File

@ -27,7 +27,7 @@ export interface EthAddressInputProps {
text: string text: string
} }
const EthAddressInput = ({ export const EthAddressInput = ({
isContract = true, isContract = true,
isRequired = true, isRequired = true,
name, name,
@ -57,6 +57,7 @@ const EthAddressInput = ({
scannedAddress = scannedAddress.replace('ethereum:', '') scannedAddress = scannedAddress.replace('ethereum:', '')
} }
setSelectedEntry({ address: scannedAddress })
onScannedValue(scannedAddress) onScannedValue(scannedAddress)
closeQrModal() 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 Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph' import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' 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 { safeSelector } from 'src/logic/safe/store/selectors'
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo' import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
import { ContractsAddressBookInput } from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput' import { ContractsAddressBookInput } from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
import { sm } from 'src/theme/variables' import { sm } from 'src/theme/variables'
import { sameString } from 'src/utils/strings'
import ArrowDown from '../../assets/arrow-down.svg' import ArrowDown from '../../assets/arrow-down.svg'
@ -147,7 +148,7 @@ const SendCustomTx: React.FC<Props> = ({ initialValues, onClose, onNext, contrac
{selectedEntry && selectedEntry.address ? ( {selectedEntry && selectedEntry.address ? (
<div <div
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Tab') { if (sameString(e.key, 'Tab')) {
return return
} }
setSelectedEntry(null) setSelectedEntry(null)

View File

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

View File

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

View File

@ -13,7 +13,7 @@ import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph' import Paragraph from 'src/components/layout/Paragraph'
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils' import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
import { textShortener } from 'src/utils/strings' 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) 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 { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils' import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
import { textShortener } from 'src/utils/strings' 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) const useSelectedTokenStyles = makeStyles(selectedTokenStyles)

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