commit
08bd74229f
|
@ -26,3 +26,7 @@ REACT_APP_APP_VERSION=$npm_package_version
|
|||
|
||||
# For Apps
|
||||
REACT_APP_GNOSIS_APPS_URL=https://safe-apps.staging.gnosisdev.com
|
||||
|
||||
# Contracts Addresses
|
||||
REACT_APP_SPENDING_LIMIT_MODULE_ADDRESS=0x9e9Bf12b5a66c0f0A7435835e0365477E121B110
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
!.eslintrc.js
|
||||
build
|
||||
config
|
||||
contracts
|
||||
/config
|
||||
/contracts
|
||||
flow-typed
|
||||
flow-typed/npm
|
||||
migrations
|
||||
|
@ -9,5 +9,5 @@ node_modules
|
|||
public
|
||||
scripts
|
||||
src/assets
|
||||
src/config
|
||||
src/types/contracts
|
||||
test
|
46
package.json
46
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "safe-react",
|
||||
"version": "2.15.1",
|
||||
"version": "2.16.0",
|
||||
"description": "Allowing crypto users manage funds in a safer way",
|
||||
"website": "https://github.com/gnosis/safe-react#readme",
|
||||
"bugs": {
|
||||
|
@ -16,7 +16,7 @@
|
|||
"email": "safe@gnosis.io"
|
||||
},
|
||||
"main": "public/electron.js",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"postinstall": "patch-package electron-builder install-app-deps",
|
||||
"scripts": {
|
||||
"analyze": "source-map-explorer 'build/static/js/*.js'",
|
||||
"build-desktop": "cross-env REACT_APP_BUILD_FOR_DESKTOP=true REACT_APP_ENV=production yarn build-mainnet",
|
||||
|
@ -26,11 +26,12 @@
|
|||
"electron-build": "electron-builder --mac --windows --linux",
|
||||
"electron-dev": "concurrently \"BROWSER=none yarn start\" \"wait-on http://localhost:3000 && electron .\"",
|
||||
"format:staged": "lint-staged",
|
||||
"generate-types": "yarn generate-types:contracts",
|
||||
"generate-types": "yarn generate-types:contracts && yarn generate-types:spendingLimit",
|
||||
"generate-types:contracts": "cross-env typechain --target=web3-v1 --outDir './src/types/contracts' './node_modules/@gnosis.pm/safe-contracts/build/contracts/*.json'",
|
||||
"generate-types:spendingLimit": "cross-env typechain --target=web3-v1 --outDir './src/types/contracts' ./src/logic/contracts/artifacts/*.json",
|
||||
"lint:check": "eslint './src/**/*.{js,jsx,ts,tsx}'",
|
||||
"lint:fix": "yarn lint:check --fix",
|
||||
"postinstall": "electron-builder install-app-deps && yarn generate-types",
|
||||
"postinstall": "patch-package && electron-builder install-app-deps && yarn generate-types",
|
||||
"preelectron-pack": "yarn build",
|
||||
"prettier:check": "yarn prettier --check",
|
||||
"prettier:fix": "yarn prettier --write",
|
||||
|
@ -168,16 +169,16 @@
|
|||
"dependencies": {
|
||||
"@gnosis.pm/safe-apps-sdk": "https://github.com/gnosis/safe-apps-sdk.git#3f0689f",
|
||||
"@gnosis.pm/safe-contracts": "1.1.1-dev.2",
|
||||
"@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#03ff672d6f73366297986d58631f9582fe2ed4a3",
|
||||
"@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#ff29c3c",
|
||||
"@gnosis.pm/util-contracts": "2.0.6",
|
||||
"@ledgerhq/hw-transport-node-hid-singleton": "5.29.0",
|
||||
"@ledgerhq/hw-transport-node-hid-singleton": "5.30.0",
|
||||
"@material-ui/core": "4.11.0",
|
||||
"@material-ui/icons": "4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.56",
|
||||
"@openzeppelin/contracts": "3.1.0",
|
||||
"@sentry/react": "^5.27.3",
|
||||
"@sentry/tracing": "^5.27.3",
|
||||
"@truffle/contract": "4.2.28",
|
||||
"@sentry/react": "^5.27.0",
|
||||
"@sentry/tracing": "^5.27.0",
|
||||
"@truffle/contract": "4.2.30",
|
||||
"async-sema": "^3.1.0",
|
||||
"axios": "0.21.0",
|
||||
"bignumber.js": "9.0.1",
|
||||
|
@ -209,25 +210,25 @@
|
|||
"material-ui-search-bar": "^1.0.0",
|
||||
"notistack": "https://github.com/gnosis/notistack.git#v0.9.4",
|
||||
"qrcode.react": "1.0.0",
|
||||
"query-string": "6.13.6",
|
||||
"query-string": "6.13.7",
|
||||
"react": "16.13.1",
|
||||
"react-dom": "16.13.1",
|
||||
"react-final-form": "^6.5.2",
|
||||
"react-final-form-listeners": "^1.0.2",
|
||||
"react-ga": "3.2.0",
|
||||
"react-ga": "3.2.1",
|
||||
"react-hot-loader": "4.13.0",
|
||||
"react-qr-reader": "^2.2.1",
|
||||
"react-redux": "7.2.1",
|
||||
"react-redux": "7.2.2",
|
||||
"react-router-dom": "5.2.0",
|
||||
"react-scripts": "^3.4.3",
|
||||
"react-window": "^1.8.5",
|
||||
"react-window": "^1.8.6",
|
||||
"recompose": "^0.30.0",
|
||||
"redux": "4.0.5",
|
||||
"redux-actions": "^2.6.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"reselect": "^4.0.0",
|
||||
"semver": "7.3.2",
|
||||
"styled-components": "^5.2.0",
|
||||
"styled-components": "^5.2.1",
|
||||
"web3": "1.2.11",
|
||||
"web3-core": "^1.2.11",
|
||||
"web3-eth-contract": "^1.2.11",
|
||||
|
@ -238,7 +239,7 @@
|
|||
"@storybook/addon-actions": "^5.3.19",
|
||||
"@storybook/addon-links": "^5.3.19",
|
||||
"@storybook/addons": "^5.3.19",
|
||||
"@storybook/preset-create-react-app": "^3.1.4",
|
||||
"@storybook/preset-create-react-app": "^3.1.5",
|
||||
"@storybook/react": "^5.3.19",
|
||||
"@testing-library/jest-dom": "5.11.5",
|
||||
"@testing-library/react": "10.4.9",
|
||||
|
@ -258,24 +259,25 @@
|
|||
"cross-env": "^7.0.2",
|
||||
"dotenv": "^8.2.0",
|
||||
"dotenv-expand": "^5.1.0",
|
||||
"electron": "9.3.3",
|
||||
"electron": "^9.3.3",
|
||||
"electron-builder": "22.9.1",
|
||||
"electron-notarize": "1.0.0",
|
||||
"eslint": "6.8.0",
|
||||
"eslint-config-prettier": "6.14.0",
|
||||
"eslint-plugin-import": "2.22.1",
|
||||
"eslint-config-prettier": "^6.14.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.3.1",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"eslint-plugin-react": "^7.21.5",
|
||||
"eslint-plugin-sort-destructure-keys": "1.3.5",
|
||||
"eslint-plugin-sort-destructure-keys": "^1.3.5",
|
||||
"ethereumjs-abi": "0.6.8",
|
||||
"husky": "^4.3.0",
|
||||
"lint-staged": "^10.5.1",
|
||||
"node-sass": "^4.14.1",
|
||||
"prettier": "2.1.2",
|
||||
"patch-package": "^6.2.2",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^2.1.2",
|
||||
"react-app-rewired": "^2.1.6",
|
||||
"react-docgen-typescript-loader": "^3.7.2",
|
||||
"typechain": "^2.0.0",
|
||||
"typechain": "^4.0.0",
|
||||
"typescript": "4.0.5",
|
||||
"wait-on": "5.2.0"
|
||||
}
|
||||
|
|
|
@ -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);
|
|
@ -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
|
|
@ -85,7 +85,7 @@ function getOpenedWindow(url, options) {
|
|||
function createWindow(port = DEFAULT_PORT) {
|
||||
mainWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 1024,
|
||||
width: 1366,
|
||||
height: 768,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../scripts/preload.js'),
|
||||
|
|
Before Width: | Height: | Size: 690 B After Width: | Height: | Size: 690 B |
|
@ -130,10 +130,13 @@ const SafeHeader = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* Network */}
|
||||
<StyledTextLabel size="sm" networkInfo={networkInfo}>
|
||||
{networkInfo.label}
|
||||
</StyledTextLabel>
|
||||
|
||||
<Container>
|
||||
{/* Identicon */}
|
||||
<IdenticonContainer>
|
||||
<FlexSpacer />
|
||||
<Identicon address={address} size="lg" />
|
||||
|
@ -142,6 +145,7 @@ const SafeHeader = ({
|
|||
</UnStyledButton>
|
||||
</IdenticonContainer>
|
||||
|
||||
{/* SafeInfo */}
|
||||
<Text size="xl">{safeName}</Text>
|
||||
<StyledEthHashInfo hash={address} shortenHash={4} textSize="sm" />
|
||||
<IconContainer>
|
||||
|
|
|
@ -16,7 +16,7 @@ const HelpContainer = styled.div`
|
|||
const HelpCenterLink = styled.a`
|
||||
height: 30px;
|
||||
width: 166px;
|
||||
padding: 6px 0 0 16px;
|
||||
padding: 6px 0 8px 16px;
|
||||
margin: 14px 0px;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
|
|
|
@ -19,7 +19,7 @@ const useSidebarItems = (): ListItemType[] => {
|
|||
}
|
||||
|
||||
return useMemo((): ListItemType[] => {
|
||||
if (!matchSafe || !matchSafeWithAddress) {
|
||||
if (!matchSafe || !matchSafeWithAddress || !featuresEnabled) {
|
||||
return []
|
||||
}
|
||||
|
||||
|
@ -63,7 +63,7 @@ const useSidebarItems = (): ListItemType[] => {
|
|||
},
|
||||
...safeSidebar,
|
||||
]
|
||||
}, [matchSafe, matchSafeWithAction, matchSafeWithAddress, safeAppsEnabled])
|
||||
}, [matchSafe, matchSafeWithAction, matchSafeWithAddress, safeAppsEnabled, featuresEnabled])
|
||||
}
|
||||
|
||||
export { useSidebarItems }
|
||||
|
|
|
@ -6,53 +6,62 @@ import Header from './Header'
|
|||
import Footer from './Footer'
|
||||
import Sidebar from './Sidebar'
|
||||
|
||||
const Grid = styled.div`
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
const Container = styled.div`
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
background-color: ${({ theme }) => theme.colors.background};
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-rows: 54px 1fr;
|
||||
grid-template-areas:
|
||||
'topbar topbar'
|
||||
'sidebar body';
|
||||
`
|
||||
|
||||
const GridTopbarWrapper = styled.nav`
|
||||
const HeaderWrapper = styled.nav`
|
||||
height: 54px;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
|
||||
background-color: white;
|
||||
box-shadow: 0 2px 4px 0 rgba(212, 212, 211, 0.59);
|
||||
border-bottom: 2px solid ${({ theme }) => theme.colors.separator};
|
||||
z-index: 999;
|
||||
grid-area: topbar;
|
||||
box-shadow: 0 0 4px 0 rgba(212, 212, 211, 0.59);
|
||||
`
|
||||
|
||||
const GridSidebarWrapper = styled.aside`
|
||||
width: 200px;
|
||||
padding: 62px 8px 0 8px;
|
||||
const BodyWrapper = styled.div`
|
||||
height: calc(100% - 54px);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`
|
||||
|
||||
const SidebarWrapper = styled.aside`
|
||||
height: 100%;
|
||||
width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1;
|
||||
|
||||
padding: 8px 8px 0 8px;
|
||||
background-color: ${({ theme }) => theme.colors.white};
|
||||
border-right: 2px solid ${({ theme }) => theme.colors.separator};
|
||||
`
|
||||
|
||||
const ContentWrapper = styled.section`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
grid-area: sidebar;
|
||||
`
|
||||
overflow-x: auto;
|
||||
|
||||
const GridBodyWrapper = styled.section`
|
||||
margin: 0 16px 0 16px;
|
||||
grid-area: body;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: stretch;
|
||||
`
|
||||
padding: 0 16px;
|
||||
|
||||
export const BodyWrapper = styled.div`
|
||||
flex: 1 100%;
|
||||
`
|
||||
> :nth-child(1) {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
export const FooterWrapper = styled.footer`
|
||||
margin: 0 16px;
|
||||
> :nth-child(2) {
|
||||
width: 100%;
|
||||
height: 59px;
|
||||
}
|
||||
`
|
||||
|
||||
type Props = {
|
||||
|
@ -77,29 +86,29 @@ const Layout: React.FC<Props> = ({
|
|||
children,
|
||||
sidebarItems,
|
||||
}): React.ReactElement => (
|
||||
<Grid>
|
||||
<GridTopbarWrapper>
|
||||
<Container>
|
||||
<HeaderWrapper>
|
||||
<Header />
|
||||
</GridTopbarWrapper>
|
||||
<GridSidebarWrapper>
|
||||
<Sidebar
|
||||
items={sidebarItems}
|
||||
safeAddress={safeAddress}
|
||||
safeName={safeName}
|
||||
balance={balance}
|
||||
granted={granted}
|
||||
onToggleSafeList={onToggleSafeList}
|
||||
onReceiveClick={onReceiveClick}
|
||||
onNewTransactionClick={onNewTransactionClick}
|
||||
/>
|
||||
</GridSidebarWrapper>
|
||||
<GridBodyWrapper>
|
||||
<BodyWrapper>{children}</BodyWrapper>
|
||||
<FooterWrapper>
|
||||
</HeaderWrapper>
|
||||
<BodyWrapper>
|
||||
<SidebarWrapper>
|
||||
<Sidebar
|
||||
items={sidebarItems}
|
||||
safeAddress={safeAddress}
|
||||
safeName={safeName}
|
||||
balance={balance}
|
||||
granted={granted}
|
||||
onToggleSafeList={onToggleSafeList}
|
||||
onReceiveClick={onReceiveClick}
|
||||
onNewTransactionClick={onNewTransactionClick}
|
||||
/>
|
||||
</SidebarWrapper>
|
||||
<ContentWrapper>
|
||||
<div>{children}</div>
|
||||
<Footer />
|
||||
</FooterWrapper>
|
||||
</GridBodyWrapper>
|
||||
</Grid>
|
||||
</ContentWrapper>
|
||||
</BodyWrapper>
|
||||
</Container>
|
||||
)
|
||||
|
||||
export default Layout
|
||||
|
|
|
@ -8,7 +8,6 @@ import { fetchProvider, removeProvider } from 'src/logic/wallets/store/actions'
|
|||
import transactionDataCheck from 'src/logic/wallets/transactionDataCheck'
|
||||
import { getSupportedWallets } from 'src/logic/wallets/utils/walletList'
|
||||
import { store } from 'src/store'
|
||||
import { BLOCKNATIVE_KEY } from 'src/utils/constants'
|
||||
|
||||
const networkId = getNetworkId()
|
||||
|
||||
|
@ -18,7 +17,6 @@ let providerName
|
|||
const wallets = getSupportedWallets()
|
||||
|
||||
export const onboard = Onboard({
|
||||
dappId: BLOCKNATIVE_KEY,
|
||||
networkId: networkId,
|
||||
subscriptions: {
|
||||
wallet: (wallet) => {
|
||||
|
|
|
@ -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;
|
||||
`
|
|
@ -1,52 +1,70 @@
|
|||
import Modal from '@material-ui/core/Modal'
|
||||
import { withStyles } from '@material-ui/core/styles'
|
||||
import { makeStyles, createStyles } from '@material-ui/core/styles'
|
||||
import cn from 'classnames'
|
||||
import * as React from 'react'
|
||||
import React, { ReactElement, ReactNode } from 'react'
|
||||
|
||||
import { sm } from 'src/theme/variables'
|
||||
|
||||
const styles = () => ({
|
||||
root: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
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',
|
||||
const useStyles = makeStyles(
|
||||
createStyles({
|
||||
root: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: 'flex',
|
||||
overflowY: 'scroll',
|
||||
},
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
})
|
||||
paper: {
|
||||
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 = ({
|
||||
children,
|
||||
classes,
|
||||
description,
|
||||
handleClose,
|
||||
modalClassName,
|
||||
open,
|
||||
paperClassName,
|
||||
title,
|
||||
}: any) => (
|
||||
<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>
|
||||
)
|
||||
}: GnoModalProps): ReactElement => {
|
||||
const classes = useStyles()
|
||||
|
||||
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
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useState } from 'react'
|
||||
import * as React from 'react'
|
||||
import React, { ReactElement, useState } from 'react'
|
||||
|
||||
import QRIcon from 'src/assets/icons/qrcode.svg'
|
||||
import ScanQRModal from 'src/components/ScanQRModal'
|
||||
import { ScanQRModal } from 'src/components/ScanQRModal'
|
||||
import Img from 'src/components/layout/Img'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
|
@ -12,7 +11,11 @@ const useStyles = makeStyles({
|
|||
},
|
||||
})
|
||||
|
||||
export const ScanQRWrapper = (props) => {
|
||||
type Props = {
|
||||
handleScan: (dataResult: string, closeQrModal: () => void) => void
|
||||
}
|
||||
|
||||
export const ScanQRWrapper = ({ handleScan }: Props): ReactElement => {
|
||||
const classes = useStyles()
|
||||
const [qrModalOpen, setQrModalOpen] = useState(false)
|
||||
|
||||
|
@ -25,7 +28,7 @@ export const ScanQRWrapper = (props) => {
|
|||
}
|
||||
|
||||
const onScanFinished = (value) => {
|
||||
props.handleScan(value, closeQrModal)
|
||||
handleScan(value, closeQrModal)
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -34,9 +37,7 @@ export const ScanQRWrapper = (props) => {
|
|||
alt="Scan QR"
|
||||
className={classes.qrCodeBtn}
|
||||
height={20}
|
||||
onClick={() => {
|
||||
openQrModal()
|
||||
}}
|
||||
onClick={() => openQrModal()}
|
||||
role="button"
|
||||
src={QRIcon}
|
||||
testId="qr-icon"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import CircularProgress from '@material-ui/core/CircularProgress'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import { withStyles } from '@material-ui/core/styles'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import Close from '@material-ui/icons/Close'
|
||||
import * as React from 'react'
|
||||
import QrReader from 'react-qr-reader'
|
||||
|
@ -15,11 +15,21 @@ import Col from 'src/components/layout/Col'
|
|||
import Hairline from 'src/components/layout/Hairline'
|
||||
import Paragraph from 'src/components/layout/Paragraph'
|
||||
import Row from 'src/components/layout/Row'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const { useEffect, useState } = React
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const ScanQRModal = ({ classes, isOpen, onClose, onScan }) => {
|
||||
const [hasWebcam, setHasWebcam] = useState<any>(null)
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onScan: (value: string) => void
|
||||
}
|
||||
|
||||
export const ScanQRModal = ({ isOpen, onClose, onScan }: Props): React.ReactElement => {
|
||||
const classes = useStyles()
|
||||
const [useWebcam, setUseWebcam] = useState<boolean | null>(null)
|
||||
const [fileUploadModalOpen, setFileUploadModalOpen] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const scannerRef: any = React.createRef()
|
||||
const openImageDialog = React.useCallback(() => {
|
||||
scannerRef.current.openImageDialog()
|
||||
|
@ -28,22 +38,35 @@ const ScanQRModal = ({ classes, isOpen, onClose, onScan }) => {
|
|||
useEffect(() => {
|
||||
checkWebcam(
|
||||
() => {
|
||||
setHasWebcam(true)
|
||||
setUseWebcam(true)
|
||||
},
|
||||
() => {
|
||||
setHasWebcam(false)
|
||||
setUseWebcam(false)
|
||||
},
|
||||
)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// this fires only when the hasWebcam changes to false (null > false (user doesn't have webcam)
|
||||
// , true > false (user switched from webcam to file upload))
|
||||
// Doesn't fire on re-render
|
||||
if (hasWebcam === false) {
|
||||
if (useWebcam === false && !fileUploadModalOpen && !error) {
|
||||
setFileUploadModalOpen(true)
|
||||
openImageDialog()
|
||||
}
|
||||
}, [hasWebcam, openImageDialog])
|
||||
}, [useWebcam, openImageDialog, fileUploadModalOpen, setFileUploadModalOpen, error])
|
||||
|
||||
const onFileScannedResolve = (error: string | null, successData: string | null) => {
|
||||
if (successData) {
|
||||
onScan(successData)
|
||||
}
|
||||
if (error) {
|
||||
console.error('Error uploading file', error)
|
||||
setError(`The QR could not be read`)
|
||||
}
|
||||
if (!useWebcam) {
|
||||
setError(`The QR could not be read`)
|
||||
}
|
||||
|
||||
setFileUploadModalOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal description="Receive Tokens Form" handleClose={onClose} open={isOpen} title="Receive Tokens">
|
||||
|
@ -57,19 +80,16 @@ const ScanQRModal = ({ classes, isOpen, onClose, onScan }) => {
|
|||
</Row>
|
||||
<Hairline />
|
||||
<Col className={classes.detailsContainer} layout="column" middle="xs">
|
||||
{hasWebcam === null ? (
|
||||
{error}
|
||||
{useWebcam === null ? (
|
||||
<Block className={classes.loaderContainer} justify="center">
|
||||
<CircularProgress />
|
||||
</Block>
|
||||
) : (
|
||||
<QrReader
|
||||
legacyMode={!hasWebcam}
|
||||
onError={(err) => {
|
||||
console.error(err)
|
||||
}}
|
||||
onScan={(data) => {
|
||||
if (data) onScan(data)
|
||||
}}
|
||||
legacyMode={!useWebcam}
|
||||
onError={(err) => onFileScannedResolve(err, null)}
|
||||
onScan={(data) => onFileScannedResolve(null, data)}
|
||||
ref={scannerRef}
|
||||
style={{ width: '400px', height: '400px' }}
|
||||
/>
|
||||
|
@ -85,11 +105,9 @@ const ScanQRModal = ({ classes, isOpen, onClose, onScan }) => {
|
|||
color="primary"
|
||||
minWidth={154}
|
||||
onClick={() => {
|
||||
if (hasWebcam) {
|
||||
setHasWebcam(false)
|
||||
} else {
|
||||
openImageDialog()
|
||||
}
|
||||
setUseWebcam(false)
|
||||
setError(null)
|
||||
setFileUploadModalOpen(false)
|
||||
}}
|
||||
variant="contained"
|
||||
>
|
||||
|
@ -99,5 +117,3 @@ const ScanQRModal = ({ classes, isOpen, onClose, onScan }) => {
|
|||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default withStyles(styles as any)(ScanQRModal)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { background, lg, secondaryText, sm } from 'src/theme/variables'
|
||||
import { createStyles } from '@material-ui/core'
|
||||
|
||||
export const styles = () => ({
|
||||
export const styles = createStyles({
|
||||
heading: {
|
||||
padding: lg,
|
||||
justifyContent: 'space-between',
|
||||
|
|
|
@ -169,13 +169,19 @@ describe('Forms > Validators', () => {
|
|||
it('Returns undefined for an address not contained in the passed array', async () => {
|
||||
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe']
|
||||
|
||||
expect(uniqueAddress(addresses)('0xe7e3272a84cf3fe180345b9f7234ba705eB5E2CA')).toBeUndefined()
|
||||
expect(uniqueAddress(addresses)()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('Returns an error message for an address already contained in the array', async () => {
|
||||
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe']
|
||||
it('Returns an error message for an array with duplicated values', async () => {
|
||||
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', '0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe']
|
||||
|
||||
expect(uniqueAddress(addresses)(addresses[0])).toEqual(ADDRESS_REPEATED_ERROR)
|
||||
expect(uniqueAddress(addresses)()).toEqual(ADDRESS_REPEATED_ERROR)
|
||||
})
|
||||
|
||||
it('Returns an error message for an array with duplicated checksum and not checksum values', async () => {
|
||||
const addresses = ['0xde0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', '0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae']
|
||||
|
||||
expect(uniqueAddress(addresses)()).toEqual(ADDRESS_REPEATED_ERROR)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
import { List } from 'immutable'
|
||||
import { getWeb3 } from 'src/logic/wallets/getWeb3'
|
||||
import memoize from 'lodash.memoize'
|
||||
import { isFeatureEnabled } from 'src/config'
|
||||
import { FEATURES } from 'src/config/networks/network.d'
|
||||
|
||||
import { sameAddress } from 'src/logic/wallets/ethAddresses'
|
||||
import { getWeb3 } from 'src/logic/wallets/getWeb3'
|
||||
import { List } from 'immutable'
|
||||
|
||||
type ValidatorReturnType = string | undefined
|
||||
type GenericValidatorType = (...args: unknown[]) => ValidatorReturnType
|
||||
export type GenericValidatorType = (...args: unknown[]) => ValidatorReturnType
|
||||
type AsyncValidator = (...args: unknown[]) => Promise<ValidatorReturnType>
|
||||
export type Validator = GenericValidatorType | AsyncValidator
|
||||
|
||||
|
@ -89,13 +87,18 @@ export const minMaxLength = (minLen: number, maxLen: number) => (value: string):
|
|||
|
||||
export const ADDRESS_REPEATED_ERROR = 'Address already introduced'
|
||||
|
||||
export const uniqueAddress = (addresses: string[] | List<string>): GenericValidatorType =>
|
||||
memoize(
|
||||
(value: string): ValidatorReturnType => {
|
||||
const addressAlreadyExists = addresses.some((address) => sameAddress(value, address))
|
||||
return addressAlreadyExists ? ADDRESS_REPEATED_ERROR : undefined
|
||||
},
|
||||
)
|
||||
export const uniqueAddress = (addresses: string[] | List<string>): GenericValidatorType => (): ValidatorReturnType => {
|
||||
// @ts-expect-error both list and array have signatures for map but TS thinks they're not compatible
|
||||
const lowercaseAddresses = addresses.map((address) => address.toLowerCase())
|
||||
const uniqueAddresses = new Set(lowercaseAddresses)
|
||||
const lengthPropName = 'size' in addresses ? 'size' : 'length'
|
||||
|
||||
if (uniqueAddresses.size !== addresses?.[lengthPropName]) {
|
||||
return ADDRESS_REPEATED_ERROR
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const composeValidators = (...validators: Validator[]) => (value: unknown): ValidatorReturnType =>
|
||||
validators.reduce(
|
||||
|
|
|
@ -80,7 +80,7 @@ describe('Config Services', () => {
|
|||
jest.mock('src/utils/constants', () => ({
|
||||
NODE_ENV: 'production',
|
||||
NETWORK: 'MAINNET',
|
||||
APP_ENV: 'production'
|
||||
APP_ENV: 'production',
|
||||
}))
|
||||
const { getTxServiceUrl, getGnosisSafeAppsUrl } = require('src/config')
|
||||
const TX_SERVICE_URL = mainnet.environment.production.txServiceUrl
|
||||
|
@ -100,7 +100,7 @@ describe('Config Services', () => {
|
|||
jest.mock('src/utils/constants', () => ({
|
||||
NODE_ENV: 'production',
|
||||
NETWORK: 'XDAI',
|
||||
APP_ENV: 'production'
|
||||
APP_ENV: 'production',
|
||||
}))
|
||||
const { getTxServiceUrl, getGnosisSafeAppsUrl } = require('src/config')
|
||||
const TX_SERVICE_URL = xdai.environment.production.txServiceUrl
|
||||
|
|
|
@ -32,9 +32,9 @@ const getCurrentEnvironment = (): string => {
|
|||
}
|
||||
|
||||
type NetworkSpecificConfiguration = EnvironmentSettings & {
|
||||
network: NetworkSettings,
|
||||
disabledFeatures?: SafeFeatures,
|
||||
disabledWallets?: Wallets,
|
||||
network: NetworkSettings
|
||||
disabledFeatures?: SafeFeatures
|
||||
disabledWallets?: Wallets
|
||||
}
|
||||
|
||||
const configuration = (): NetworkSpecificConfiguration => {
|
||||
|
@ -60,7 +60,7 @@ const configuration = (): NetworkSpecificConfiguration => {
|
|||
...networkBaseConfig,
|
||||
network: configFile.network,
|
||||
disabledFeatures: configFile.disabledFeatures,
|
||||
disabledWallets: configFile.disabledWallets
|
||||
disabledWallets: configFile.disabledWallets,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -137,10 +137,10 @@ const fetchContractABI = memoize(
|
|||
(url, contractAddress) => `${url}_${contractAddress}`,
|
||||
)
|
||||
|
||||
const getNetworkExplorerApiKey = (networkExplorerName: string): string | undefined=> {
|
||||
const getNetworkExplorerApiKey = (networkExplorerName: string): string | undefined => {
|
||||
switch (networkExplorerName.toLowerCase()) {
|
||||
case 'etherscan': {
|
||||
return ETHERSCAN_API_KEY
|
||||
return ETHERSCAN_API_KEY
|
||||
}
|
||||
default: {
|
||||
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 apiKey = getNetworkExplorerApiKey(name)
|
||||
|
@ -181,7 +181,7 @@ export const getExplorerInfo = (hash: string): BlockScanInfo => {
|
|||
const type = hash.length > 42 ? 'tx' : 'address'
|
||||
return () => ({
|
||||
url: `${url}/${type}/${hash}`,
|
||||
alt: name || '',
|
||||
alt: name || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,11 +41,10 @@ describe('Networks config files test', () => {
|
|||
return
|
||||
}
|
||||
|
||||
const environmentConfigKeys = Object
|
||||
.keys(networkConfigElement)
|
||||
.filter((environmentConfigKey) =>
|
||||
environmentConfigKey.endsWith('Uri') && !!networkConfigElement[environmentConfigKey]
|
||||
)
|
||||
const environmentConfigKeys = Object.keys(networkConfigElement).filter(
|
||||
(environmentConfigKey) =>
|
||||
environmentConfigKey.endsWith('Uri') && !!networkConfigElement[environmentConfigKey],
|
||||
)
|
||||
|
||||
// Then
|
||||
environmentConfigKeys.forEach((environmentConfigKey) => {
|
||||
|
@ -53,7 +52,10 @@ describe('Networks config files test', () => {
|
|||
const isValid = isValidURL(networkConfigElementUri)
|
||||
|
||||
if (!isValid) {
|
||||
console.log(`Invalid URI in "${networkFileName}" at ${environment}.${environmentConfigKey}:`, networkConfigElementUri)
|
||||
console.log(
|
||||
`Invalid URI in "${networkFileName}" at ${environment}.${environmentConfigKey}:`,
|
||||
networkConfigElementUri,
|
||||
)
|
||||
}
|
||||
|
||||
expect(isValid).toBeTruthy()
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import EwcLogo from 'src/config/assets/token_ewc.svg'
|
||||
import { EnvironmentSettings, ETHEREUM_NETWORK, FEATURES, NetworkConfig, WALLETS } from 'src/config/networks/network.d'
|
||||
import { EnvironmentSettings, ETHEREUM_NETWORK, NetworkConfig, WALLETS } from 'src/config/networks/network.d'
|
||||
|
||||
// @todo (agustin) we need to use fixed gasPrice because the oracle is not working right now and it's returning 0
|
||||
// once the oracle is fixed we need to remove the fixed value
|
||||
|
@ -61,9 +61,6 @@ const mainnet: NetworkConfig = {
|
|||
WALLETS.AUTHEREUM,
|
||||
WALLETS.LATTICE,
|
||||
],
|
||||
disabledFeatures: [
|
||||
FEATURES.ENS_LOOKUP,
|
||||
],
|
||||
}
|
||||
|
||||
export default mainnet
|
||||
|
|
|
@ -11,5 +11,5 @@ export default {
|
|||
rinkeby,
|
||||
xdai,
|
||||
energy_web_chain,
|
||||
volta
|
||||
volta,
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ const mainnet: NetworkConfig = {
|
|||
decimals: 18,
|
||||
logoUri: EtherLogo,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default mainnet
|
||||
|
|
|
@ -51,12 +51,12 @@ export enum ETHEREUM_NETWORK {
|
|||
|
||||
export type NetworkSettings = {
|
||||
// TODO: id now seems to be unnecessary
|
||||
id: ETHEREUM_NETWORK,
|
||||
backgroundColor: string,
|
||||
textColor: string,
|
||||
label: string,
|
||||
isTestNet: boolean,
|
||||
nativeCoin: Token,
|
||||
id: ETHEREUM_NETWORK
|
||||
backgroundColor: string
|
||||
textColor: string
|
||||
label: string
|
||||
isTestNet: boolean
|
||||
nativeCoin: Token
|
||||
}
|
||||
|
||||
// something around this to display or not some critical sections in the app, depending on the network support
|
||||
|
@ -73,14 +73,16 @@ export type GasPriceOracle = {
|
|||
gasParameter: string
|
||||
}
|
||||
|
||||
type GasPrice = {
|
||||
gasPrice: number
|
||||
gasPriceOracle?: GasPriceOracle
|
||||
} | {
|
||||
gasPrice?: number
|
||||
// for infura there's a REST API Token required stored in: `REACT_APP_INFURA_TOKEN`
|
||||
gasPriceOracle: GasPriceOracle
|
||||
}
|
||||
type GasPrice =
|
||||
| {
|
||||
gasPrice: number
|
||||
gasPriceOracle?: GasPriceOracle
|
||||
}
|
||||
| {
|
||||
gasPrice?: number
|
||||
// for infura there's a REST API Token required stored in: `REACT_APP_INFURA_TOKEN`
|
||||
gasPriceOracle: GasPriceOracle
|
||||
}
|
||||
|
||||
export type EnvironmentSettings = GasPrice & {
|
||||
txServiceUrl: string
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import EwcLogo from 'src/config/assets/token_ewc.svg'
|
||||
import { EnvironmentSettings, ETHEREUM_NETWORK, FEATURES, NetworkConfig, WALLETS } from 'src/config/networks/network.d'
|
||||
import { EnvironmentSettings, ETHEREUM_NETWORK, NetworkConfig, WALLETS } from 'src/config/networks/network.d'
|
||||
|
||||
const baseConfig: EnvironmentSettings = {
|
||||
txServiceUrl: 'https://safe-transaction.volta.gnosis.io/api/v1',
|
||||
|
@ -53,14 +53,10 @@ const mainnet: NetworkConfig = {
|
|||
WALLETS.TORUS,
|
||||
WALLETS.TRUST,
|
||||
WALLETS.UNILOGIN,
|
||||
WALLETS.WALLET_CONNECT,
|
||||
WALLETS.WALLET_LINK,
|
||||
WALLETS.AUTHEREUM,
|
||||
WALLETS.LATTICE,
|
||||
],
|
||||
disabledFeatures: [
|
||||
FEATURES.ENS_LOOKUP,
|
||||
],
|
||||
}
|
||||
|
||||
export default mainnet
|
||||
|
|
|
@ -14,12 +14,11 @@ const baseConfig: EnvironmentSettings = {
|
|||
const xDai: NetworkConfig = {
|
||||
environment: {
|
||||
staging: {
|
||||
...baseConfig
|
||||
...baseConfig,
|
||||
},
|
||||
production: {
|
||||
...baseConfig,
|
||||
safeAppsUrl: 'https://apps-xdai.gnosis-safe.io',
|
||||
|
||||
},
|
||||
},
|
||||
network: {
|
||||
|
@ -52,9 +51,7 @@ const xDai: NetworkConfig = {
|
|||
WALLETS.AUTHEREUM,
|
||||
WALLETS.LATTICE,
|
||||
],
|
||||
disabledFeatures: [
|
||||
FEATURES.ENS_LOOKUP,
|
||||
],
|
||||
disabledFeatures: [FEATURES.ENS_LOOKUP],
|
||||
}
|
||||
|
||||
export default xDai
|
||||
|
|
|
@ -23,7 +23,7 @@ Sentry.init({
|
|||
dsn: SENTRY_DSN,
|
||||
release: `safe-react@${process.env.REACT_APP_APP_VERSION}`,
|
||||
integrations: [new Integrations.BrowserTracing()],
|
||||
sampleRate: 1,
|
||||
sampleRate: 0.2,
|
||||
})
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
|
|
@ -8,6 +8,10 @@ import { AddressBookState } from 'src/logic/addressBook/model/addressBook'
|
|||
|
||||
export const addressBookSelector = (state: AppReduxState): AddressBookState => state[ADDRESS_BOOK_REDUCER_ID]
|
||||
|
||||
export const addressBookAddressesListSelector = createSelector(addressBookSelector, (addressBook): string[] => {
|
||||
return addressBook.map((entry) => entry.address)
|
||||
})
|
||||
|
||||
export const getNameFromAddressBookSelector = createSelector(
|
||||
addressBookSelector,
|
||||
(_, address) => address,
|
||||
|
|
|
@ -2,7 +2,6 @@ import { List } from 'immutable'
|
|||
import {
|
||||
checkIfEntryWasDeletedFromAddressBook,
|
||||
getAddressBookFromStorage,
|
||||
getAddressesListFromAddressBook,
|
||||
getNameFromAddressBook,
|
||||
getOwnersWithNameFromAddressBook,
|
||||
isValidAddressBookName,
|
||||
|
@ -28,24 +27,6 @@ const getMockOldAddressBookEntry = ({ address = '', name = '', isOwner = false }
|
|||
}
|
||||
}
|
||||
|
||||
describe('getAddressesListFromAdbk', () => {
|
||||
const entry1 = getMockAddressBookEntry('123456', 'test1')
|
||||
const entry2 = getMockAddressBookEntry('78910', 'test2')
|
||||
const entry3 = getMockAddressBookEntry('4781321', 'test3')
|
||||
|
||||
it('It should returns the list of addresses within the addressBook given a safeAddressBook', () => {
|
||||
// given
|
||||
const safeAddressBook = [entry1, entry2, entry3]
|
||||
const expectedResult = [entry1.address, entry2.address, entry3.address]
|
||||
|
||||
// when
|
||||
const result = getAddressesListFromAddressBook(safeAddressBook)
|
||||
|
||||
// then
|
||||
expect(result).toStrictEqual(expectedResult)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNameFromSafeAddressBook', () => {
|
||||
const entry1 = getMockAddressBookEntry('123456', 'test1')
|
||||
const entry2 = getMockAddressBookEntry('78910', 'test2')
|
||||
|
|
|
@ -56,9 +56,6 @@ export const saveAddressBook = async (addressBook: AddressBookState): Promise<vo
|
|||
}
|
||||
}
|
||||
|
||||
export const getAddressesListFromAddressBook = (addressBook: AddressBookState): string[] =>
|
||||
addressBook.map((entry) => entry.address)
|
||||
|
||||
type GetNameFromAddressBookOptions = {
|
||||
filterOnlyValidName: boolean
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { getNetworkId } from 'src/config'
|
||||
import { getNetworkId, getNetworkInfo } from 'src/config'
|
||||
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
|
||||
import { nftAssetsListAddressesSelector } from 'src/logic/collectibles/store/selectors'
|
||||
import { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions'
|
||||
|
@ -18,6 +18,14 @@ export const CK_ADDRESS = {
|
|||
[ETHEREUM_NETWORK.RINKEBY]: '0x16baf0de678e52367adc69fd067e5edd1d33e3bf',
|
||||
}
|
||||
|
||||
// Note: xDAI ENS is missing, once we have it we need to add it here
|
||||
const ENS_CONTRACT_ADDRESS = {
|
||||
[ETHEREUM_NETWORK.MAINNET]: '0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85',
|
||||
[ETHEREUM_NETWORK.RINKEBY]: '0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85',
|
||||
[ETHEREUM_NETWORK.ENERGY_WEB_CHAIN]: '0x0A6d64413c07E10E890220BBE1c49170080C6Ca0',
|
||||
[ETHEREUM_NETWORK.VOLTA]: '0xd7CeF70Ba7efc2035256d828d5287e2D285CD1ac',
|
||||
}
|
||||
|
||||
// safeTransferFrom(address,address,uint256)
|
||||
export const SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH = '42842e0e'
|
||||
|
||||
|
@ -50,12 +58,11 @@ export const getERC721Symbol = async (contractAddress: string): Promise<string>
|
|||
try {
|
||||
const ERC721token = await getERC721TokenContract()
|
||||
const tokenInstance = await ERC721token.at(contractAddress)
|
||||
tokenSymbol = tokenInstance.symbol()
|
||||
tokenSymbol = await tokenInstance.symbol()
|
||||
} catch (err) {
|
||||
// If the contract address is an ENS token contract, we know that the ERC721 standard is not proper implemented
|
||||
// The method symbol() is missing
|
||||
const ENS_TOKEN_CONTRACT = '0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85'
|
||||
if (sameAddress(contractAddress, ENS_TOKEN_CONTRACT)) {
|
||||
if (isENSContract(contractAddress)) {
|
||||
return 'ENS'
|
||||
}
|
||||
console.error(`Failed to retrieve token symbol for ERC721 token ${contractAddress}`)
|
||||
|
@ -64,6 +71,11 @@ export const getERC721Symbol = async (contractAddress: string): Promise<string>
|
|||
return tokenSymbol
|
||||
}
|
||||
|
||||
export const isENSContract = (contractAddress: string): boolean => {
|
||||
const { id } = getNetworkInfo()
|
||||
return sameAddress(contractAddress, ENS_CONTRACT_ADDRESS[id])
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies if the provided contract is a valid ERC721
|
||||
* @param {string} contractAddress
|
||||
|
|
|
@ -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
|
@ -14,25 +14,33 @@ import { AbiItem } from 'web3-utils'
|
|||
*/
|
||||
type MethodsArgsType = Array<string | number>
|
||||
|
||||
interface Props {
|
||||
interface Props {
|
||||
abi: AbiItem[]
|
||||
address: string
|
||||
batch?: BatchRequest
|
||||
context?: unknown
|
||||
methods: Array<string | {method: string, type?: string, args: MethodsArgsType }>
|
||||
}
|
||||
methods: Array<string | { method: string; type?: string; args: MethodsArgsType }>
|
||||
}
|
||||
|
||||
const generateBatchRequests = <ReturnValues>({ abi, address, batch, context, methods }: Props): Promise<ReturnValues> => {
|
||||
const generateBatchRequests = <ReturnValues>({
|
||||
abi,
|
||||
address,
|
||||
batch,
|
||||
context,
|
||||
methods,
|
||||
}: Props): Promise<ReturnValues> => {
|
||||
const contractInstance = new web3.eth.Contract(abi, address)
|
||||
const localBatch = new web3.BatchRequest()
|
||||
|
||||
const values = methods.map((methodObject) => {
|
||||
let method, type, args: MethodsArgsType = []
|
||||
let method,
|
||||
type,
|
||||
args: MethodsArgsType = []
|
||||
|
||||
if (typeof methodObject === 'string') {
|
||||
method = methodObject
|
||||
} else {
|
||||
({ method, type, args } = methodObject)
|
||||
;({ method, type, args } = methodObject)
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
//
|
||||
//
|
||||
|
||||
// Code of the safe v1.0.0
|
||||
const proxyCodeV10 =
|
||||
|
|
|
@ -1,78 +1,173 @@
|
|||
import {
|
||||
DataDecoded,
|
||||
SAFE_METHOD_ID_TO_NAME,
|
||||
SAFE_METHODS_NAMES,
|
||||
SPENDING_LIMIT_METHOD_ID_TO_NAME,
|
||||
SPENDING_LIMIT_METHODS_NAMES,
|
||||
TOKEN_TRANSFER_METHOD_ID_TO_NAME,
|
||||
TOKEN_TRANSFER_METHODS_NAMES,
|
||||
} from 'src/logic/safe/store/models/types/transactions.d'
|
||||
import { web3ReadOnly as web3 } from 'src/logic/wallets/getWeb3'
|
||||
import { DataDecoded, METHOD_TO_ID } from 'src/routes/safe/store/models/types/transactions.d'
|
||||
import { sameString } from 'src/utils/strings'
|
||||
|
||||
type DecodeInfoProps = {
|
||||
paramsHash: string
|
||||
params: Record<string, string>
|
||||
}
|
||||
|
||||
const decodeInfo = ({ paramsHash, params }: DecodeInfoProps): DataDecoded['parameters'] => {
|
||||
const decodedParameters = web3.eth.abi.decodeParameters(Object.values(params), paramsHash)
|
||||
|
||||
return Object.keys(params).map((name, index) => ({
|
||||
name,
|
||||
type: params[name],
|
||||
value: decodedParameters[index],
|
||||
}))
|
||||
}
|
||||
|
||||
export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null => {
|
||||
const [methodId, params] = [data.slice(0, 10) as keyof typeof METHOD_TO_ID | string, data.slice(10)]
|
||||
const [methodId, paramsHash] = [data.slice(0, 10), data.slice(10)]
|
||||
const method = SAFE_METHODS_NAMES[methodId]
|
||||
|
||||
switch (methodId) {
|
||||
// swapOwner
|
||||
case '0xe318b52b': {
|
||||
const decodedParameters = web3.eth.abi.decodeParameters(['uint', 'address', 'address'], params) as string[]
|
||||
return {
|
||||
method: METHOD_TO_ID[methodId],
|
||||
parameters: [
|
||||
{ name: 'oldOwner', type: 'address', value: decodedParameters[1] },
|
||||
{ name: 'newOwner', type: 'address', value: decodedParameters[2] },
|
||||
],
|
||||
switch (method) {
|
||||
case SAFE_METHODS_NAMES.SWAP_OWNER: {
|
||||
const params = {
|
||||
prevOwner: 'address',
|
||||
oldOwner: 'address',
|
||||
newOwner: 'address',
|
||||
}
|
||||
|
||||
// we only need to return the addresses that has been swapped, no need for the `prevOwner`
|
||||
const [, oldOwner, newOwner] = decodeInfo({ paramsHash, params })
|
||||
|
||||
return { method, parameters: [oldOwner, newOwner] }
|
||||
}
|
||||
|
||||
// addOwnerWithThreshold
|
||||
case '0x0d582f13': {
|
||||
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'uint'], params)
|
||||
return {
|
||||
method: METHOD_TO_ID[methodId],
|
||||
parameters: [
|
||||
{ name: 'owner', type: 'address', value: decodedParameters[0] },
|
||||
{ name: '_threshold', type: 'uint', value: decodedParameters[1] },
|
||||
],
|
||||
case SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD: {
|
||||
const params = {
|
||||
owner: 'address',
|
||||
_threshold: 'uint',
|
||||
}
|
||||
|
||||
const parameters = decodeInfo({ paramsHash, params })
|
||||
|
||||
return { method, parameters }
|
||||
}
|
||||
|
||||
// removeOwner
|
||||
case '0xf8dc5dd9': {
|
||||
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params)
|
||||
return {
|
||||
method: METHOD_TO_ID[methodId],
|
||||
parameters: [
|
||||
{ name: 'owner', type: 'address', value: decodedParameters[1] },
|
||||
{ name: '_threshold', type: 'uint', value: decodedParameters[2] },
|
||||
],
|
||||
case SAFE_METHODS_NAMES.REMOVE_OWNER: {
|
||||
const params = {
|
||||
prevOwner: 'address',
|
||||
owner: 'address',
|
||||
_threshold: 'uint',
|
||||
}
|
||||
|
||||
// we only need to return the removed owner and the new threshold, no need for the `prevOwner`
|
||||
const [, oldOwner, threshold] = decodeInfo({ paramsHash, params })
|
||||
|
||||
return { method, parameters: [oldOwner, threshold] }
|
||||
}
|
||||
|
||||
// changeThreshold
|
||||
case '0x694e80c3': {
|
||||
const decodedParameters = web3.eth.abi.decodeParameters(['uint'], params)
|
||||
return {
|
||||
method: METHOD_TO_ID[methodId],
|
||||
parameters: [
|
||||
{ name: '_threshold', type: 'uint', value: decodedParameters[0] },
|
||||
],
|
||||
case SAFE_METHODS_NAMES.CHANGE_THRESHOLD: {
|
||||
const params = {
|
||||
_threshold: 'uint',
|
||||
}
|
||||
|
||||
const parameters = decodeInfo({ paramsHash, params })
|
||||
|
||||
return { method, parameters }
|
||||
}
|
||||
|
||||
// enableModule
|
||||
case '0x610b5925': {
|
||||
const decodedParameters = web3.eth.abi.decodeParameters(['address'], params)
|
||||
return {
|
||||
method: METHOD_TO_ID[methodId],
|
||||
parameters: [
|
||||
{ name: 'module', type: 'address', value: decodedParameters[0] },
|
||||
],
|
||||
case SAFE_METHODS_NAMES.ENABLE_MODULE: {
|
||||
const params = {
|
||||
module: 'address',
|
||||
}
|
||||
|
||||
const parameters = decodeInfo({ paramsHash, params })
|
||||
|
||||
return { method, parameters }
|
||||
}
|
||||
|
||||
// disableModule
|
||||
case '0xe009cfde': {
|
||||
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address'], params)
|
||||
return {
|
||||
method: METHOD_TO_ID[methodId],
|
||||
parameters: [
|
||||
{ name: 'prevModule', type: 'address', value: decodedParameters[0] },
|
||||
{ name: 'module', type: 'address', value: decodedParameters[1] },
|
||||
],
|
||||
case SAFE_METHODS_NAMES.DISABLE_MODULE: {
|
||||
const params = {
|
||||
prevModule: 'address',
|
||||
module: 'address',
|
||||
}
|
||||
|
||||
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:
|
||||
|
@ -81,57 +176,53 @@ export const decodeParamsFromSafeMethod = (data: string): DataDecoded | null =>
|
|||
}
|
||||
|
||||
const isSafeMethod = (methodId: string): boolean => {
|
||||
return !!METHOD_TO_ID[methodId]
|
||||
return !!SAFE_METHOD_ID_TO_NAME[methodId]
|
||||
}
|
||||
|
||||
export const decodeMethods = (data: string): DataDecoded | null => {
|
||||
if(!data.length) {
|
||||
const isSpendingLimitMethod = (methodId: string): boolean => {
|
||||
return !!SPENDING_LIMIT_METHOD_ID_TO_NAME[methodId]
|
||||
}
|
||||
|
||||
export const decodeMethods = (data: string | null): DataDecoded | null => {
|
||||
if (!data?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [methodId, params] = [data.slice(0, 10), data.slice(10)]
|
||||
const [methodId, paramsHash] = [data.slice(0, 10), data.slice(10)]
|
||||
|
||||
if (isSafeMethod(methodId)) {
|
||||
return decodeParamsFromSafeMethod(data)
|
||||
}
|
||||
|
||||
switch (methodId) {
|
||||
// a9059cbb - transfer(address,uint256)
|
||||
case '0xa9059cbb': {
|
||||
const decodeParameters = web3.eth.abi.decodeParameters(['address', 'uint'], params)
|
||||
return {
|
||||
method: 'transfer',
|
||||
parameters: [
|
||||
{ name: 'to', type: '', value: decodeParameters[0] },
|
||||
{ name: 'value', type: '', value: decodeParameters[1] },
|
||||
],
|
||||
if (isSpendingLimitMethod(methodId)) {
|
||||
return decodeParamsFromSpendingLimit(data)
|
||||
}
|
||||
|
||||
const method = TOKEN_TRANSFER_METHOD_ID_TO_NAME[methodId]
|
||||
|
||||
switch (method) {
|
||||
case TOKEN_TRANSFER_METHODS_NAMES.TRANSFER: {
|
||||
const params = {
|
||||
to: 'address',
|
||||
value: 'uint',
|
||||
}
|
||||
|
||||
const parameters = decodeInfo({ paramsHash, params })
|
||||
|
||||
return { method, parameters }
|
||||
}
|
||||
|
||||
// 23b872dd - transferFrom(address,address,uint256)
|
||||
case '0x23b872dd': {
|
||||
const decodeParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params)
|
||||
return {
|
||||
method: 'transferFrom',
|
||||
parameters: [
|
||||
{ name: 'from', type: '', value: decodeParameters[0] },
|
||||
{ name: 'to', type: '', value: decodeParameters[1] },
|
||||
{ name: 'value', type: '', value: decodeParameters[2] },
|
||||
],
|
||||
case TOKEN_TRANSFER_METHODS_NAMES.TRANSFER_FROM:
|
||||
case TOKEN_TRANSFER_METHODS_NAMES.SAFE_TRANSFER_FROM: {
|
||||
const params = {
|
||||
from: 'address',
|
||||
to: 'address',
|
||||
value: 'uint',
|
||||
}
|
||||
}
|
||||
|
||||
// 42842e0e - safeTransferFrom(address,address,uint256)
|
||||
case '0x42842e0e': {
|
||||
const decodedParameters = web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params)
|
||||
return {
|
||||
method: 'safeTransferFrom',
|
||||
parameters: [
|
||||
{ name: 'from', type: '', value: decodedParameters[0] },
|
||||
{ name: 'to', type: '', value: decodedParameters[1] },
|
||||
{ name: 'value', type: '', value: decodedParameters[2] },
|
||||
],
|
||||
}
|
||||
const parameters = decodeInfo({ paramsHash, params })
|
||||
|
||||
return { method, parameters }
|
||||
}
|
||||
|
||||
default:
|
||||
|
|
|
@ -1,17 +1,19 @@
|
|||
import { AbiItem } from 'web3-utils'
|
||||
import GnosisSafeSol from '@gnosis.pm/safe-contracts/build/contracts/GnosisSafe.json'
|
||||
import memoize from 'lodash.memoize'
|
||||
import ProxyFactorySol from '@gnosis.pm/safe-contracts/build/contracts/GnosisSafeProxyFactory.json'
|
||||
import SafeProxy from '@gnosis.pm/safe-contracts/build/contracts/GnosisSafeProxy.json'
|
||||
import Web3 from 'web3'
|
||||
|
||||
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
|
||||
import { isProxyCode } from 'src/logic/contracts/historicProxyCode'
|
||||
import { ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses'
|
||||
import { calculateGasOf, calculateGasPrice } from 'src/logic/wallets/ethTransactions'
|
||||
import { getWeb3, getNetworkIdFrom } from 'src/logic/wallets/getWeb3'
|
||||
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
|
||||
import { GnosisSafeProxyFactory } from 'src/types/contracts/GnosisSafeProxyFactory.d'
|
||||
import { AllowanceModule } from 'src/types/contracts/AllowanceModule.d'
|
||||
import { getSafeInfo, SafeInfo } from 'src/logic/safe/utils/safeInformation'
|
||||
import { SPENDING_LIMIT_MODULE_ADDRESS } from 'src/utils/constants'
|
||||
|
||||
import SpendingLimitModule from './artifacts/AllowanceModule.json'
|
||||
|
||||
export const SENTINEL_ADDRESS = '0x0000000000000000000000000000000000000001'
|
||||
export const MULTI_SEND_ADDRESS = '0x8d29be29923b68abfdd21e541b9374737b49cdad'
|
||||
|
@ -19,7 +21,6 @@ export const SAFE_MASTER_COPY_ADDRESS = '0x34CfAC646f301356fAa8B21e94227e3583Fe3
|
|||
export const DEFAULT_FALLBACK_HANDLER_ADDRESS = '0xd5D82B6aDDc9027B22dCA772Aa68D5d74cdBdF44'
|
||||
export const SAFE_MASTER_COPY_ADDRESS_V10 = '0xb6029EA3B2c51D09a50B53CA8012FeEB05bDa35A'
|
||||
|
||||
|
||||
let proxyFactoryMaster: GnosisSafeProxyFactory
|
||||
let safeMaster: GnosisSafe
|
||||
|
||||
|
@ -28,13 +29,13 @@ let safeMaster: GnosisSafe
|
|||
* @param {Web3} web3
|
||||
* @param {ETHEREUM_NETWORK} networkId
|
||||
*/
|
||||
const createGnosisSafeContract = (web3: Web3, networkId: ETHEREUM_NETWORK) => {
|
||||
export const getGnosisSafeContract = (web3: Web3, networkId: ETHEREUM_NETWORK) => {
|
||||
const networks = GnosisSafeSol.networks
|
||||
// TODO: this may not be the most scalable approach,
|
||||
// but up until v1.2.0 the address is the same for all the networks.
|
||||
// So, if we can't find the network in the Contract artifact, we fallback to MAINNET.
|
||||
const contractAddress = networks[networkId]?.address ?? networks[ETHEREUM_NETWORK.MAINNET].address
|
||||
return new web3.eth.Contract(GnosisSafeSol.abi as AbiItem[], contractAddress) as unknown as GnosisSafe
|
||||
return (new web3.eth.Contract(GnosisSafeSol.abi as AbiItem[], contractAddress) as unknown) as GnosisSafe
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -42,56 +43,92 @@ const createGnosisSafeContract = (web3: Web3, networkId: ETHEREUM_NETWORK) => {
|
|||
* @param {Web3} web3
|
||||
* @param {ETHEREUM_NETWORK} networkId
|
||||
*/
|
||||
const createProxyFactoryContract = (web3: Web3, networkId: ETHEREUM_NETWORK): GnosisSafeProxyFactory => {
|
||||
const getProxyFactoryContract = (web3: Web3, networkId: ETHEREUM_NETWORK): GnosisSafeProxyFactory => {
|
||||
const networks = ProxyFactorySol.networks
|
||||
// TODO: this may not be the most scalable approach,
|
||||
// but up until v1.2.0 the address is the same for all the networks.
|
||||
// So, if we can't find the network in the Contract artifact, we fallback to MAINNET.
|
||||
const contractAddress = networks[networkId]?.address ?? networks[ETHEREUM_NETWORK.MAINNET].address
|
||||
return new web3.eth.Contract(ProxyFactorySol.abi as AbiItem[], contractAddress) as unknown as GnosisSafeProxyFactory
|
||||
return (new web3.eth.Contract(ProxyFactorySol.abi as AbiItem[], contractAddress) as unknown) as GnosisSafeProxyFactory
|
||||
}
|
||||
|
||||
export const getGnosisSafeContract = memoize(createGnosisSafeContract)
|
||||
/**
|
||||
* Creates a Contract instance of the GnosisSafeProxyFactory contract
|
||||
*/
|
||||
export const getSpendingLimitContract = () => {
|
||||
const web3 = getWeb3()
|
||||
return (new web3.eth.Contract(
|
||||
SpendingLimitModule.abi as AbiItem[],
|
||||
SPENDING_LIMIT_MODULE_ADDRESS,
|
||||
) as unknown) as AllowanceModule
|
||||
}
|
||||
|
||||
const getCreateProxyFactoryContract = memoize(createProxyFactoryContract)
|
||||
export const getMasterCopyAddressFromProxyAddress = async (proxyAddress: string): Promise<string | undefined> => {
|
||||
const res = await getSafeInfo(proxyAddress)
|
||||
const masterCopyAddress = (res as SafeInfo)?.masterCopy
|
||||
if (!masterCopyAddress) {
|
||||
console.error(`There was not possible to get masterCopy address from proxy ${proxyAddress}.`)
|
||||
return
|
||||
}
|
||||
return masterCopyAddress
|
||||
}
|
||||
|
||||
const instantiateMasterCopies = async () => {
|
||||
export const instantiateSafeContracts = async () => {
|
||||
const web3 = getWeb3()
|
||||
const networkId = await getNetworkIdFrom(web3)
|
||||
|
||||
// Create ProxyFactory Master Copy
|
||||
proxyFactoryMaster = getCreateProxyFactoryContract(web3, networkId)
|
||||
proxyFactoryMaster = getProxyFactoryContract(web3, networkId)
|
||||
|
||||
// Create Safe Master copy
|
||||
safeMaster = getGnosisSafeContract(web3, networkId)
|
||||
}
|
||||
|
||||
export const initContracts = instantiateMasterCopies
|
||||
|
||||
export const getSafeMasterContract = async () => {
|
||||
await initContracts()
|
||||
|
||||
await instantiateSafeContracts()
|
||||
return safeMaster
|
||||
}
|
||||
|
||||
export const getSafeDeploymentTransaction = (safeAccounts, numConfirmations) => {
|
||||
export const getSafeDeploymentTransaction = (
|
||||
safeAccounts: string[],
|
||||
numConfirmations: number,
|
||||
safeCreationSalt: number,
|
||||
) => {
|
||||
const gnosisSafeData = safeMaster.methods
|
||||
.setup(safeAccounts, numConfirmations, ZERO_ADDRESS, '0x', DEFAULT_FALLBACK_HANDLER_ADDRESS, ZERO_ADDRESS, 0, ZERO_ADDRESS)
|
||||
.setup(
|
||||
safeAccounts,
|
||||
numConfirmations,
|
||||
ZERO_ADDRESS,
|
||||
'0x',
|
||||
DEFAULT_FALLBACK_HANDLER_ADDRESS,
|
||||
ZERO_ADDRESS,
|
||||
0,
|
||||
ZERO_ADDRESS,
|
||||
)
|
||||
.encodeABI()
|
||||
|
||||
return proxyFactoryMaster.methods.createProxy(safeMaster.options.address, gnosisSafeData)
|
||||
return proxyFactoryMaster.methods.createProxyWithNonce(safeMaster.options.address, gnosisSafeData, safeCreationSalt)
|
||||
}
|
||||
|
||||
export const estimateGasForDeployingSafe = async (
|
||||
safeAccounts,
|
||||
numConfirmations,
|
||||
userAccount,
|
||||
safeAccounts: string[],
|
||||
numConfirmations: number,
|
||||
userAccount: string,
|
||||
safeCreationSalt: number,
|
||||
) => {
|
||||
const gnosisSafeData = await safeMaster.methods
|
||||
.setup(safeAccounts, numConfirmations, ZERO_ADDRESS, '0x', DEFAULT_FALLBACK_HANDLER_ADDRESS, ZERO_ADDRESS, 0, ZERO_ADDRESS)
|
||||
.setup(
|
||||
safeAccounts,
|
||||
numConfirmations,
|
||||
ZERO_ADDRESS,
|
||||
'0x',
|
||||
DEFAULT_FALLBACK_HANDLER_ADDRESS,
|
||||
ZERO_ADDRESS,
|
||||
0,
|
||||
ZERO_ADDRESS,
|
||||
)
|
||||
.encodeABI()
|
||||
const proxyFactoryData = proxyFactoryMaster.methods
|
||||
.createProxy(safeMaster.options.address, gnosisSafeData)
|
||||
.createProxyWithNonce(safeMaster.options.address, gnosisSafeData, safeCreationSalt)
|
||||
.encodeABI()
|
||||
const gas = await calculateGasOf(proxyFactoryData, userAccount, proxyFactoryMaster.options.address)
|
||||
const gasPrice = await calculateGasPrice()
|
||||
|
@ -101,29 +138,5 @@ export const estimateGasForDeployingSafe = async (
|
|||
|
||||
export const getGnosisSafeInstanceAt = (safeAddress: string): GnosisSafe => {
|
||||
const web3 = getWeb3()
|
||||
return new web3.eth.Contract(GnosisSafeSol.abi as AbiItem[], safeAddress) as unknown as GnosisSafe
|
||||
}
|
||||
|
||||
const cleanByteCodeMetadata = (bytecode: string): string => {
|
||||
const metaData = 'a165'
|
||||
return bytecode.substring(0, bytecode.lastIndexOf(metaData))
|
||||
}
|
||||
|
||||
export const validateProxy = async (safeAddress: string): Promise<boolean> => {
|
||||
// https://solidity.readthedocs.io/en/latest/metadata.html#usage-for-source-code-verification
|
||||
const web3 = getWeb3()
|
||||
const code = await web3.eth.getCode(safeAddress)
|
||||
const codeWithoutMetadata = cleanByteCodeMetadata(code)
|
||||
const supportedProxies = [SafeProxy]
|
||||
for (let i = 0; i < supportedProxies.length; i += 1) {
|
||||
const proxy = supportedProxies[i]
|
||||
const proxyCode = proxy.deployedBytecode
|
||||
const proxyCodeWithoutMetadata = cleanByteCodeMetadata(proxyCode)
|
||||
if (codeWithoutMetadata === proxyCodeWithoutMetadata) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return isProxyCode(codeWithoutMetadata)
|
||||
return (new web3.eth.Contract(GnosisSafeSol.abi as AbiItem[], safeAddress) as unknown) as GnosisSafe
|
||||
}
|
||||
|
|
|
@ -99,6 +99,28 @@ const settingsChangeTxNotificationsQueue = {
|
|||
afterExecutionError: NOTIFICATIONS.SETTINGS_CHANGE_FAILED_MSG,
|
||||
}
|
||||
|
||||
const newSpendingLimitTxNotificationsQueue = {
|
||||
beforeExecution: NOTIFICATIONS.SIGN_NEW_SPENDING_LIMIT_MSG,
|
||||
pendingExecution: NOTIFICATIONS.NEW_SPENDING_LIMIT_PENDING_MSG,
|
||||
afterRejection: NOTIFICATIONS.NEW_SPENDING_LIMIT_REJECTED_MSG,
|
||||
afterExecution: {
|
||||
noMoreConfirmationsNeeded: NOTIFICATIONS.NEW_SPENDING_LIMIT_EXECUTED_MSG,
|
||||
moreConfirmationsNeeded: NOTIFICATIONS.NEW_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG,
|
||||
},
|
||||
afterExecutionError: NOTIFICATIONS.NEW_SPENDING_LIMIT_FAILED_MSG,
|
||||
}
|
||||
|
||||
const removeSpendingLimitTxNotificationsQueue = {
|
||||
beforeExecution: NOTIFICATIONS.SIGN_REMOVE_SPENDING_LIMIT_MSG,
|
||||
pendingExecution: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_PENDING_MSG,
|
||||
afterRejection: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_REJECTED_MSG,
|
||||
afterExecution: {
|
||||
noMoreConfirmationsNeeded: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_EXECUTED_MSG,
|
||||
moreConfirmationsNeeded: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG,
|
||||
},
|
||||
afterExecutionError: NOTIFICATIONS.REMOVE_SPENDING_LIMIT_FAILED_MSG,
|
||||
}
|
||||
|
||||
const defaultNotificationsQueue = {
|
||||
beforeExecution: NOTIFICATIONS.SIGN_TX_MSG,
|
||||
pendingExecution: NOTIFICATIONS.TX_PENDING_MSG,
|
||||
|
@ -166,6 +188,14 @@ export const getNotificationsFromTxType: any = (txType, origin) => {
|
|||
notificationsQueue = settingsChangeTxNotificationsQueue
|
||||
break
|
||||
}
|
||||
case TX_NOTIFICATION_TYPES.NEW_SPENDING_LIMIT_TX: {
|
||||
notificationsQueue = newSpendingLimitTxNotificationsQueue
|
||||
break
|
||||
}
|
||||
case TX_NOTIFICATION_TYPES.REMOVE_SPENDING_LIMIT_TX: {
|
||||
notificationsQueue = removeSpendingLimitTxNotificationsQueue
|
||||
break
|
||||
}
|
||||
case TX_NOTIFICATION_TYPES.SAFE_NAME_CHANGE_TX: {
|
||||
notificationsQueue = safeNameChangeNotificationsQueue
|
||||
break
|
||||
|
|
|
@ -46,6 +46,18 @@ const NOTIFICATION_IDS = {
|
|||
SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG: 'SETTINGS_CHANGE_EXECUTED_MORE_CONFIRMATIONS_MSG',
|
||||
SETTINGS_CHANGE_FAILED_MSG: 'SETTINGS_CHANGE_FAILED_MSG',
|
||||
TESTNET_VERSION_MSG: 'TESTNET_VERSION_MSG',
|
||||
SIGN_NEW_SPENDING_LIMIT_MSG: 'SIGN_NEW_SPENDING_LIMIT_MSG',
|
||||
NEW_SPENDING_LIMIT_PENDING_MSG: 'NEW_SPENDING_LIMIT_PENDING_MSG',
|
||||
NEW_SPENDING_LIMIT_REJECTED_MSG: 'NEW_SPENDING_LIMIT_REJECTED_MSG',
|
||||
NEW_SPENDING_LIMIT_EXECUTED_MSG: 'NEW_SPENDING_LIMIT_EXECUTED_MSG',
|
||||
NEW_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG: 'NEW_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG',
|
||||
NEW_SPENDING_LIMIT_FAILED_MSG: 'NEW_SPENDING_LIMIT_FAILED_MSG',
|
||||
SIGN_REMOVE_SPENDING_LIMIT_MSG: 'SIGN_REMOVE_SPENDING_LIMIT_MSG',
|
||||
REMOVE_SPENDING_LIMIT_PENDING_MSG: 'REMOVE_SPENDING_LIMIT_PENDING_MSG',
|
||||
REMOVE_SPENDING_LIMIT_REJECTED_MSG: 'REMOVE_SPENDING_LIMIT_REJECTED_MSG',
|
||||
REMOVE_SPENDING_LIMIT_EXECUTED_MSG: 'REMOVE_SPENDING_LIMIT_EXECUTED_MSG',
|
||||
REMOVE_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG: 'REMOVE_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG',
|
||||
REMOVE_SPENDING_LIMIT_FAILED_MSG: 'REMOVE_SPENDING_LIMIT_FAILED_MSG',
|
||||
WRONG_NETWORK_MSG: 'WRONG_NETWORK_MSG',
|
||||
ADDRESS_BOOK_NEW_ENTRY_SUCCESS: 'ADDRESS_BOOK_NEW_ENTRY_SUCCESS',
|
||||
ADDRESS_BOOK_EDIT_ENTRY_SUCCESS: 'ADDRESS_BOOK_EDIT_ENTRY_SUCCESS',
|
||||
|
@ -191,6 +203,56 @@ export const NOTIFICATIONS: Record<NotificationId, Notification> = {
|
|||
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
|
||||
},
|
||||
|
||||
// Spending Limit
|
||||
SIGN_NEW_SPENDING_LIMIT_MSG: {
|
||||
message: 'Please sign the new Spending Limit',
|
||||
options: { variant: INFO, persist: true },
|
||||
},
|
||||
NEW_SPENDING_LIMIT_PENDING_MSG: {
|
||||
message: 'New Spending Limit pending',
|
||||
options: { variant: INFO, persist: true },
|
||||
},
|
||||
NEW_SPENDING_LIMIT_REJECTED_MSG: {
|
||||
message: 'New Spending Limit rejected',
|
||||
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
|
||||
},
|
||||
NEW_SPENDING_LIMIT_EXECUTED_MSG: {
|
||||
message: 'New Spending Limit successfully executed',
|
||||
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
|
||||
},
|
||||
NEW_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG: {
|
||||
message: 'New Spending Limit successfully created. More confirmations needed to execute',
|
||||
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
|
||||
},
|
||||
NEW_SPENDING_LIMIT_FAILED_MSG: {
|
||||
message: 'New Spending Limit failed',
|
||||
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
|
||||
},
|
||||
SIGN_REMOVE_SPENDING_LIMIT_MSG: {
|
||||
message: 'Please sign the remove Spending Limit',
|
||||
options: { variant: INFO, persist: true },
|
||||
},
|
||||
REMOVE_SPENDING_LIMIT_PENDING_MSG: {
|
||||
message: 'Remove Spending Limit pending',
|
||||
options: { variant: INFO, persist: true },
|
||||
},
|
||||
REMOVE_SPENDING_LIMIT_REJECTED_MSG: {
|
||||
message: 'Remove Spending Limit rejected',
|
||||
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
|
||||
},
|
||||
REMOVE_SPENDING_LIMIT_EXECUTED_MSG: {
|
||||
message: 'Remove Spending Limit successfully executed',
|
||||
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
|
||||
},
|
||||
REMOVE_SPENDING_LIMIT_EXECUTED_MORE_CONFIRMATIONS_MSG: {
|
||||
message: 'Remove Spending Limit successfully created. More confirmations needed to execute',
|
||||
options: { variant: SUCCESS, persist: false, autoHideDuration: longDuration },
|
||||
},
|
||||
REMOVE_SPENDING_LIMIT_FAILED_MSG: {
|
||||
message: 'Remove Spending Limit failed',
|
||||
options: { variant: ERROR, persist: false, autoHideDuration: longDuration },
|
||||
},
|
||||
|
||||
// Network
|
||||
TESTNET_VERSION_MSG: {
|
||||
message: "Testnet Version: Don't send production assets to this Safe",
|
||||
|
|
|
@ -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
|
|
@ -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)
|
|
@ -41,7 +41,7 @@ import { PayableTx } from 'src/types/contracts/types.d'
|
|||
import { AppReduxState } from 'src/store'
|
||||
import { Dispatch, DispatchReturn } from './types'
|
||||
|
||||
interface CreateTransactionArgs {
|
||||
export interface CreateTransactionArgs {
|
||||
navigateToTransactionsTab?: boolean
|
||||
notifiedTransaction: string
|
||||
operation?: number
|
||||
|
|
|
@ -18,6 +18,7 @@ import { AppReduxState } from 'src/store'
|
|||
import { latestMasterContractVersionSelector } from 'src/logic/safe/store/selectors'
|
||||
import { getSafeInfo } from 'src/logic/safe/utils/safeInformation'
|
||||
import { getModules } from 'src/logic/safe/utils/modules'
|
||||
import { getSpendingLimits } from 'src/logic/safe/utils/spendingLimits'
|
||||
|
||||
const buildOwnersFrom = (safeOwners: string[], localSafe?: SafeRecordProps): List<SafeOwner> => {
|
||||
const ownersList = safeOwners.map((ownerAddress) => {
|
||||
|
@ -71,6 +72,7 @@ export const buildSafe = async (
|
|||
const needsUpdate = safeNeedsUpdate(currentVersion, latestMasterContractVersion)
|
||||
const featuresEnabled = enabledFeatures(currentVersion)
|
||||
const modules = await getModules(safeInfo)
|
||||
const spendingLimits = safeInfo ? await getSpendingLimits(safeInfo.modules, safeAddress) : null
|
||||
|
||||
return {
|
||||
address: safeAddress,
|
||||
|
@ -89,6 +91,7 @@ export const buildSafe = async (
|
|||
blacklistedAssets: Set(),
|
||||
blacklistedTokens: Set(),
|
||||
modules,
|
||||
spendingLimits,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -106,6 +109,9 @@ export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: Dispatch
|
|||
getLocalSafe(safeAddress),
|
||||
])
|
||||
|
||||
// request SpendingLimit info
|
||||
const spendingLimits = safeInfo ? await getSpendingLimits(safeInfo.modules, safeAddress) : null
|
||||
|
||||
// Converts from [ { address, ownerName} ] to address array
|
||||
const localOwners = localSafe ? localSafe.owners.map((localOwner) => localOwner.address) : []
|
||||
|
||||
|
@ -116,6 +122,7 @@ export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: Dispatch
|
|||
address: safeAddress,
|
||||
name: localSafe?.name,
|
||||
modules,
|
||||
spendingLimits,
|
||||
nonce: Number(remoteNonce),
|
||||
threshold: Number(remoteThreshold),
|
||||
featuresEnabled: localSafe?.currentVersion
|
||||
|
|
|
@ -2,19 +2,27 @@ import axios from 'axios'
|
|||
|
||||
import { buildTxServiceUrl } from 'src/logic/safe/transactions'
|
||||
import { buildIncomingTxServiceUrl } from 'src/logic/safe/transactions/incomingTxHistory'
|
||||
import { buildModuleTxServiceUrl } from 'src/logic/safe/transactions/moduleTxHistory'
|
||||
import { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions'
|
||||
import { IncomingTxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadIncomingTransactions'
|
||||
import { TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
|
||||
import { ModuleTxServiceModel } from './loadModuleTransactions'
|
||||
|
||||
const getServiceUrl = (txType: string, safeAddress: string): string => {
|
||||
return {
|
||||
[TransactionTypes.INCOMING]: buildIncomingTxServiceUrl,
|
||||
[TransactionTypes.OUTGOING]: buildTxServiceUrl,
|
||||
[TransactionTypes.MODULE]: buildModuleTxServiceUrl,
|
||||
}[txType](safeAddress)
|
||||
}
|
||||
|
||||
// TODO: Remove this magic
|
||||
/* eslint-disable */
|
||||
async function fetchTransactions(
|
||||
txType: TransactionTypes.MODULE,
|
||||
safeAddress: string,
|
||||
eTag: string | null,
|
||||
): Promise<{ eTag: string | null; results: ModuleTxServiceModel[] }>
|
||||
async function fetchTransactions(
|
||||
txType: TransactionTypes.INCOMING,
|
||||
safeAddress: string,
|
||||
|
@ -26,10 +34,10 @@ async function fetchTransactions(
|
|||
eTag: string | null,
|
||||
): Promise<{ eTag: string | null; results: TxServiceModel[] }>
|
||||
async function fetchTransactions(
|
||||
txType: TransactionTypes.INCOMING | TransactionTypes.OUTGOING,
|
||||
txType: TransactionTypes.MODULE | TransactionTypes.INCOMING | TransactionTypes.OUTGOING,
|
||||
safeAddress: string,
|
||||
eTag: string | null,
|
||||
): Promise<{ eTag: string | null; results: TxServiceModel[] | IncomingTxServiceModel[] }> {
|
||||
): Promise<{ eTag: string | null; results: ModuleTxServiceModel[] | TxServiceModel[] | IncomingTxServiceModel[] }> {
|
||||
/* eslint-enable */
|
||||
try {
|
||||
const url = getServiceUrl(txType, safeAddress)
|
||||
|
|
|
@ -3,9 +3,11 @@ import { ThunkAction, ThunkDispatch } from 'redux-thunk'
|
|||
import { AnyAction } from 'redux'
|
||||
import { backOff } from 'exponential-backoff'
|
||||
|
||||
import { addIncomingTransactions } from '../../addIncomingTransactions'
|
||||
import { addIncomingTransactions } from 'src/logic/safe/store/actions/addIncomingTransactions'
|
||||
import { addModuleTransactions } from 'src/logic/safe/store/actions/addModuleTransactions'
|
||||
|
||||
import { loadIncomingTransactions } from './loadIncomingTransactions'
|
||||
import { loadModuleTransactions } from './loadModuleTransactions'
|
||||
import { loadOutgoingTransactions } from './loadOutgoingTransactions'
|
||||
|
||||
import { addOrUpdateCancellationTransactions } from 'src/logic/safe/store/actions/transactions/addOrUpdateCancellationTransactions'
|
||||
|
@ -44,6 +46,12 @@ export default (safeAddress: string): ThunkAction<Promise<void>, AppReduxState,
|
|||
if (safeIncomingTxs?.size) {
|
||||
dispatch(addIncomingTransactions(incomingTransactions))
|
||||
}
|
||||
|
||||
const moduleTransactions = await loadModuleTransactions(safeAddress)
|
||||
|
||||
if (moduleTransactions.length) {
|
||||
dispatch(addModuleTransactions({ modules: moduleTransactions, safeAddress }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error fetching transactions:', error)
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { web3ReadOnly } from 'src/logic/wallets/getWeb3'
|
|||
import { makeIncomingTransaction } from 'src/logic/safe/store/models/incomingTransaction'
|
||||
import fetchTransactions from 'src/logic/safe/store/actions/transactions/fetchTransactions/fetchTransactions'
|
||||
import { TransactionTypes } from 'src/logic/safe/store/models/types/transaction'
|
||||
import { isENSContract } from 'src/logic/collectibles/utils'
|
||||
|
||||
export type IncomingTxServiceModel = {
|
||||
blockNumber: number
|
||||
|
@ -76,12 +77,18 @@ const batchIncomingTxsTokenDataRequest = (txs: IncomingTxServiceModel[]) => {
|
|||
batch.execute()
|
||||
|
||||
return Promise.all(whenTxsValues).then((txsValues) =>
|
||||
txsValues.map(([tx, symbol, decimals, ethTx, ethTxReceipt]) => [
|
||||
tx,
|
||||
symbol ? symbol : nativeCoin.symbol,
|
||||
decimals ? decimals : nativeCoin.decimals,
|
||||
new bn(ethTx?.gasPrice ?? 0).times(ethTxReceipt?.gasUsed ?? 0),
|
||||
]),
|
||||
txsValues.map(([tx, symbolFetched, decimals, ethTx, ethTxReceipt]) => {
|
||||
let symbol = symbolFetched
|
||||
if (!symbolFetched) {
|
||||
symbol = isENSContract(tx.tokenAddress) ? 'ENS' : nativeCoin.symbol
|
||||
}
|
||||
return [
|
||||
tx,
|
||||
symbol,
|
||||
decimals ? decimals : nativeCoin.decimals,
|
||||
new bn(ethTx?.gasPrice ?? 0).times(ethTxReceipt?.gasUsed ?? 0),
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -6,7 +6,22 @@ export type SafeOwner = {
|
|||
address: string
|
||||
}
|
||||
|
||||
export type ModulePair = [string, string]
|
||||
export type ModulePair = [
|
||||
// previous module
|
||||
string,
|
||||
// module
|
||||
string,
|
||||
]
|
||||
|
||||
export type SpendingLimit = {
|
||||
delegate: string
|
||||
token: string
|
||||
amount: string
|
||||
spent: string
|
||||
resetTimeMin: string
|
||||
lastResetMin: string
|
||||
nonce: string
|
||||
}
|
||||
|
||||
export type SafeRecordProps = {
|
||||
name: string
|
||||
|
@ -15,6 +30,7 @@ export type SafeRecordProps = {
|
|||
ethBalance: string
|
||||
owners: List<SafeOwner>
|
||||
modules?: ModulePair[] | null
|
||||
spendingLimits?: SpendingLimit[] | null
|
||||
activeTokens: Set<string>
|
||||
activeAssets: Set<string>
|
||||
blacklistedTokens: Set<string>
|
||||
|
@ -35,6 +51,7 @@ const makeSafe = Record<SafeRecordProps>({
|
|||
ethBalance: '0',
|
||||
owners: List([]),
|
||||
modules: [],
|
||||
spendingLimits: [],
|
||||
activeTokens: Set(),
|
||||
activeAssets: Set(),
|
||||
blacklistedTokens: Set(),
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { List, Map, RecordOf } from 'immutable'
|
||||
|
||||
import { ModuleTxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadModuleTransactions'
|
||||
import { Token } from 'src/logic/tokens/store/model/token'
|
||||
import { Confirmation } from './confirmation'
|
||||
import { GnosisSafe } from 'src/types/contracts/GnosisSafe.d'
|
||||
import { DataDecoded, Transfer } from './transactions'
|
||||
|
@ -14,6 +17,8 @@ export enum TransactionTypes {
|
|||
UPGRADE = 'upgrade',
|
||||
TOKEN = 'token',
|
||||
COLLECTIBLE = 'collectible',
|
||||
MODULE = 'module',
|
||||
SPENDING_LIMIT = 'spendingLimit',
|
||||
}
|
||||
export type TransactionTypeValues = typeof TransactionTypes[keyof typeof TransactionTypes]
|
||||
|
||||
|
@ -47,7 +52,7 @@ export type TransactionProps = {
|
|||
data: string | null
|
||||
dataDecoded: DataDecoded | null
|
||||
decimals?: (number | string) | null
|
||||
decodedParams: DecodedParams | null
|
||||
decodedParams: DecodedParams
|
||||
executionDate?: string | null
|
||||
executionTxHash?: string | null
|
||||
executor: string
|
||||
|
@ -101,3 +106,17 @@ export type TxArgs = {
|
|||
to: string
|
||||
valueInWei: string
|
||||
}
|
||||
|
||||
type SafeModuleCompatibilityTypes = {
|
||||
nonce?: string // not required for this tx: added for compatibility
|
||||
fee?: number // not required for this tx: added for compatibility
|
||||
executionTxHash?: string // not required for this tx: added for compatibility
|
||||
safeTxHash: string // table uses this key as a unique row identifier, added for compatibility
|
||||
}
|
||||
|
||||
export type SafeModuleTransaction = ModuleTxServiceModel &
|
||||
SafeModuleCompatibilityTypes & {
|
||||
status: TransactionStatus
|
||||
type: TransactionTypes
|
||||
tokenInfo?: Token
|
||||
}
|
||||
|
|
|
@ -228,16 +228,30 @@ export const SAFE_METHODS_NAMES = {
|
|||
SWAP_OWNER: 'swapOwner',
|
||||
ENABLE_MODULE: 'enableModule',
|
||||
DISABLE_MODULE: 'disableModule',
|
||||
}
|
||||
} as const
|
||||
|
||||
export const METHOD_TO_ID = {
|
||||
export const SAFE_METHOD_ID_TO_NAME = {
|
||||
'0xe318b52b': SAFE_METHODS_NAMES.SWAP_OWNER,
|
||||
'0x0d582f13': SAFE_METHODS_NAMES.ADD_OWNER_WITH_THRESHOLD,
|
||||
'0xf8dc5dd9': SAFE_METHODS_NAMES.REMOVE_OWNER,
|
||||
'0x694e80c3': SAFE_METHODS_NAMES.CHANGE_THRESHOLD,
|
||||
'0x610b5925': SAFE_METHODS_NAMES.ENABLE_MODULE,
|
||||
'0xe009cfde': SAFE_METHODS_NAMES.DISABLE_MODULE,
|
||||
}
|
||||
} as const
|
||||
|
||||
export const SPENDING_LIMIT_METHODS_NAMES = {
|
||||
ADD_DELEGATE: 'addDelegate',
|
||||
SET_ALLOWANCE: 'setAllowance',
|
||||
EXECUTE_ALLOWANCE_TRANSFER: 'executeAllowanceTransfer',
|
||||
DELETE_ALLOWANCE: 'deleteAllowance',
|
||||
} as const
|
||||
|
||||
export const SPENDING_LIMIT_METHOD_ID_TO_NAME = {
|
||||
'0xe71bdf41': SPENDING_LIMIT_METHODS_NAMES.ADD_DELEGATE,
|
||||
'0xbeaeb388': SPENDING_LIMIT_METHODS_NAMES.SET_ALLOWANCE,
|
||||
'0x4515641a': SPENDING_LIMIT_METHODS_NAMES.EXECUTE_ALLOWANCE_TRANSFER,
|
||||
'0x885133e3': SPENDING_LIMIT_METHODS_NAMES.DELETE_ALLOWANCE,
|
||||
} as const
|
||||
|
||||
export type SafeMethods = typeof SAFE_METHODS_NAMES[keyof typeof SAFE_METHODS_NAMES]
|
||||
|
||||
|
@ -247,13 +261,19 @@ export const TOKEN_TRANSFER_METHODS_NAMES = {
|
|||
SAFE_TRANSFER_FROM: 'safeTransferFrom',
|
||||
} as const
|
||||
|
||||
export const TOKEN_TRANSFER_METHOD_ID_TO_NAME = {
|
||||
'0xa9059cbb': TOKEN_TRANSFER_METHODS_NAMES.TRANSFER,
|
||||
'0x23b872dd': TOKEN_TRANSFER_METHODS_NAMES.TRANSFER_FROM,
|
||||
'0x42842e0e': TOKEN_TRANSFER_METHODS_NAMES.SAFE_TRANSFER_FROM,
|
||||
} as const
|
||||
|
||||
type TokenMethods = typeof TOKEN_TRANSFER_METHODS_NAMES[keyof typeof TOKEN_TRANSFER_METHODS_NAMES]
|
||||
|
||||
type SafeDecodedParams = {
|
||||
export type SafeDecodedParams = {
|
||||
[key in SafeMethods]?: Record<string, string>
|
||||
}
|
||||
|
||||
type TokenDecodedParams = {
|
||||
export type TokenDecodedParams = {
|
||||
[key in TokenMethods]?: Record<string, string>
|
||||
}
|
||||
|
||||
|
|
|
@ -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],
|
||||
}
|
||||
},
|
||||
},
|
||||
{},
|
||||
)
|
|
@ -208,6 +208,19 @@ export const safeModulesSelector = createSelector(safeSelector, safeFieldSelecto
|
|||
|
||||
export const safeFeaturesEnabledSelector = createSelector(safeSelector, safeFieldSelector('featuresEnabled'))
|
||||
|
||||
export const safeSpendingLimitsSelector = createSelector(safeSelector, safeFieldSelector('spendingLimits'))
|
||||
|
||||
export const safeOwnersAddressesListSelector = createSelector(
|
||||
safeOwnersSelector,
|
||||
(owners): List<string> => {
|
||||
if (!owners) {
|
||||
return List([])
|
||||
}
|
||||
|
||||
return owners?.map(({ address }) => address)
|
||||
},
|
||||
)
|
||||
|
||||
export const getActiveTokensAddressesForAllSafes = createSelector(safesListSelector, (safes) => {
|
||||
const addresses = Set().withMutations((set) => {
|
||||
safes.forEach((safe) => {
|
||||
|
|
|
@ -2,10 +2,13 @@ import { List } from 'immutable'
|
|||
import { createSelector } from 'reselect'
|
||||
|
||||
import { safeIncomingTransactionsSelector, safeTransactionsSelector } from 'src/logic/safe/store/selectors'
|
||||
import { Transaction } from 'src/logic/safe/store/models/types/transaction'
|
||||
import { Transaction, SafeModuleTransaction } from 'src/logic/safe/store/models/types/transaction'
|
||||
import { safeModuleTransactionsSelector } from 'src/routes/safe/container/selector'
|
||||
|
||||
export const extendedTransactionsSelector = createSelector(
|
||||
safeTransactionsSelector,
|
||||
safeIncomingTransactionsSelector,
|
||||
(transactions, incomingTransactions): List<Transaction> => List([...transactions, ...incomingTransactions]),
|
||||
safeModuleTransactionsSelector,
|
||||
(transactions, incomingTransactions, moduleTransactions): List<Transaction | SafeModuleTransaction> =>
|
||||
List([...transactions, ...incomingTransactions, ...moduleTransactions]),
|
||||
)
|
||||
|
|
|
@ -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/`
|
||||
}
|
|
@ -1,11 +1,14 @@
|
|||
export const TX_NOTIFICATION_TYPES: any = {
|
||||
export const TX_NOTIFICATION_TYPES = {
|
||||
STANDARD_TX: 'STANDARD_TX',
|
||||
CONFIRMATION_TX: 'CONFIRMATION_TX',
|
||||
CANCELLATION_TX: 'CANCELLATION_TX',
|
||||
WAITING_TX: 'WAITING_TX',
|
||||
SETTINGS_CHANGE_TX: 'SETTINGS_CHANGE_TX',
|
||||
NEW_SPENDING_LIMIT_TX: 'NEW_SPENDING_LIMIT_TX',
|
||||
REMOVE_SPENDING_LIMIT_TX: 'REMOVE_SPENDING_LIMIT_TX',
|
||||
SAFE_NAME_CHANGE_TX: 'SAFE_NAME_CHANGE_TX',
|
||||
OWNER_NAME_CHANGE_TX: 'OWNER_NAME_CHANGE_TX',
|
||||
ADDRESSBOOK_NEW_ENTRY: 'ADDRESSBOOK_NEW_ENTRY',
|
||||
ADDRESSBOOK_EDIT_ENTRY: 'ADDRESSBOOK_EDIT_ENTRY',
|
||||
ADDRESSBOOK_DELETE_ENTRY: 'ADDRESSBOOK_DELETE_ENTRY',
|
||||
}
|
||||
|
|
|
@ -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'],
|
||||
])
|
||||
})
|
||||
})
|
|
@ -1,7 +1,9 @@
|
|||
import semverLessThan from 'semver/functions/lt'
|
||||
|
||||
import { getGnosisSafeInstanceAt, SENTINEL_ADDRESS } from 'src/logic/contracts/safeContracts'
|
||||
import { CreateTransactionArgs } from 'src/logic/safe/store/actions/createTransaction'
|
||||
import { ModulePair } from 'src/logic/safe/store/models/safe'
|
||||
import { CALL, TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
|
||||
import { SafeInfo } from 'src/logic/safe/utils/safeInformation'
|
||||
|
||||
type ModulesPaginated = {
|
||||
|
@ -9,11 +11,32 @@ type ModulesPaginated = {
|
|||
next: string
|
||||
}
|
||||
|
||||
const buildModulesLinkedList = (modules: string[], nextModule: string = SENTINEL_ADDRESS): Array<ModulePair> | null => {
|
||||
/**
|
||||
* Builds a collection of tuples with (prev, module) module addresses
|
||||
*
|
||||
* The `modules` param, is organized from the most recently added to the oldest.
|
||||
*
|
||||
* By assuming this, we are able to recreate the linked list that's defined at contract level
|
||||
* considering `0x1` (SENTINEL_ADDRESS) address as the list's initial node.
|
||||
*
|
||||
* Given this scenario, we have a linked list in the form of
|
||||
*
|
||||
* **`0x1->modules[n]->module[n-1]->module[0]->0x1`**
|
||||
*
|
||||
* So,
|
||||
* - if we want to disable `module[n]`, we need to pass `(module[n], 0x1)` as arguments,
|
||||
* - if we want to disable `module[n-1]`, we need to pass `(module[n-1], module[n])`,
|
||||
* - ... and so on
|
||||
* @param {Array<string>} modules
|
||||
* @returns null | Array<ModulePair>
|
||||
*/
|
||||
export const buildModulesLinkedList = (modules: string[]): Array<ModulePair> | null => {
|
||||
if (modules?.length) {
|
||||
return modules.map((moduleAddress, index, modules) => {
|
||||
const prevModule = modules[index + 1]
|
||||
return [moduleAddress, prevModule !== undefined ? prevModule : nextModule]
|
||||
if (index === 0) {
|
||||
return [SENTINEL_ADDRESS, moduleAddress]
|
||||
}
|
||||
return [modules[index - 1], moduleAddress]
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -58,10 +81,12 @@ export const getModules = async (safeInfo: SafeInfo | void): Promise<Array<Modul
|
|||
// as we're not sure if there are more than 10 modules enabled for the current Safe
|
||||
const safeInstance = getGnosisSafeInstanceAt(safeInfo.address)
|
||||
|
||||
// TODO: 100 is an arbitrary large number, to avoid the need for pagination. But pagination must be properly handled
|
||||
// TODO: 100 is an arbitrary large number, to avoid the need for pagination.
|
||||
// But pagination must be properly handled
|
||||
// if `modules.next !== SENTINEL_ADDRESS`, then we have more modules to retrieve
|
||||
const modules: ModulesPaginated = await safeInstance.methods.getModulesPaginated(SENTINEL_ADDRESS, 100).call()
|
||||
|
||||
return buildModulesLinkedList(modules.array, modules.next)
|
||||
return buildModulesLinkedList(modules.array)
|
||||
} catch (e) {
|
||||
console.error('Failed to retrieve Safe modules', e)
|
||||
}
|
||||
|
@ -69,8 +94,25 @@ export const getModules = async (safeInfo: SafeInfo | void): Promise<Array<Modul
|
|||
}
|
||||
|
||||
export const getDisableModuleTxData = (modulePair: ModulePair, safeAddress: string): string => {
|
||||
const [module, previousModule] = modulePair
|
||||
const [previousModule, module] = modulePair
|
||||
const safeInstance = getGnosisSafeInstanceAt(safeAddress)
|
||||
|
||||
return safeInstance.methods.disableModule(previousModule, module).encodeABI()
|
||||
}
|
||||
|
||||
type EnableModuleParams = {
|
||||
moduleAddress: string
|
||||
safeAddress: string
|
||||
}
|
||||
export const enableModuleTx = ({ moduleAddress, safeAddress }: EnableModuleParams): CreateTransactionArgs => {
|
||||
const safeInstance = getGnosisSafeInstanceAt(safeAddress)
|
||||
|
||||
return {
|
||||
safeAddress,
|
||||
to: safeAddress,
|
||||
operation: CALL,
|
||||
valueInWei: '0',
|
||||
txData: safeInstance.methods.enableModule(moduleAddress).encodeABI(),
|
||||
notifiedTransaction: TX_NOTIFICATION_TYPES.SETTINGS_CHANGE_TX,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,17 +36,20 @@ export const safeNeedsUpdate = (currentVersion?: string, latestVersion?: string)
|
|||
export const getCurrentSafeVersion = (gnosisSafeInstance: GnosisSafe): Promise<string> =>
|
||||
gnosisSafeInstance.methods.VERSION().call()
|
||||
|
||||
const checkFeatureEnabledByVersion = (featureConfig: FeatureConfigByVersion, version: string) => {
|
||||
const checkFeatureEnabledByVersion = (featureConfig: FeatureConfigByVersion, version?: string) => {
|
||||
if (!version) {
|
||||
return false
|
||||
}
|
||||
return featureConfig.validVersion ? semverSatisfies(version, featureConfig.validVersion) : true
|
||||
}
|
||||
|
||||
export const enabledFeatures = (version?: string): FEATURES[] => {
|
||||
return FEATURES_BY_VERSION.reduce((acc: FEATURES[], feature: Feature) => {
|
||||
if (isFeatureEnabled(feature.name) && version && checkFeatureEnabledByVersion(feature, version)) {
|
||||
return FEATURES_BY_VERSION.reduce((acc, feature: Feature) => {
|
||||
if (isFeatureEnabled(feature.name) && checkFeatureEnabledByVersion(feature, version)) {
|
||||
acc.push(feature.name)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
}, [] as FEATURES[])
|
||||
}
|
||||
|
||||
interface SafeVersionInfo {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -10,7 +10,7 @@ import { DELEGATE_CALL } from 'src/logic/safe/transactions'
|
|||
import { getWeb3 } from 'src/logic/wallets/getWeb3'
|
||||
import { MultiSend } from 'src/types/contracts/MultiSend.d'
|
||||
|
||||
interface MultiSendTx {
|
||||
export interface MultiSendTx {
|
||||
operation: number
|
||||
to: string
|
||||
value: number
|
||||
|
|
|
@ -6,7 +6,7 @@ export type TokenProps = {
|
|||
symbol: string
|
||||
decimals: number | string
|
||||
logoUri: string
|
||||
balance?: number | string
|
||||
balance: number | string
|
||||
}
|
||||
|
||||
export const makeToken = Record<TokenProps>({
|
||||
|
@ -15,7 +15,7 @@ export const makeToken = Record<TokenProps>({
|
|||
symbol: '',
|
||||
decimals: 0,
|
||||
logoUri: '',
|
||||
balance: undefined,
|
||||
balance: 0,
|
||||
})
|
||||
// balance is only set in extendedSafeTokensSelector when we display user's token balances
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { List } from 'immutable'
|
||||
import { AbiItem } from 'web3-utils'
|
||||
|
||||
import { getNetworkInfo } from 'src/config'
|
||||
|
@ -9,6 +10,8 @@ import { ALTERNATIVE_TOKEN_ABI } from 'src/logic/tokens/utils/alternativeAbi'
|
|||
import { web3ReadOnly as web3 } from 'src/logic/wallets/getWeb3'
|
||||
import { isEmptyData } from 'src/logic/safe/store/actions/transactions/utils/transactionHelpers'
|
||||
import { TxServiceModel } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadOutgoingTransactions'
|
||||
import { CALL } from 'src/logic/safe/transactions'
|
||||
import { sameAddress } from 'src/logic/wallets/ethAddresses'
|
||||
|
||||
export const getEthAsToken = (balance: string | number): Token => {
|
||||
const { nativeCoin } = getNetworkInfo()
|
||||
|
@ -33,7 +36,13 @@ export const isAddressAToken = async (tokenAddress: string): Promise<boolean> =>
|
|||
}
|
||||
|
||||
export const isTokenTransfer = (tx: TxServiceModel): boolean => {
|
||||
return !isEmptyData(tx.data) && tx.data?.substring(0, 10) === '0xa9059cbb' && Number(tx.value) === 0
|
||||
return (
|
||||
!isEmptyData(tx.data) &&
|
||||
// Check if contains 'transfer' method code
|
||||
tx.data?.substring(0, 10) === '0xa9059cbb' &&
|
||||
Number(tx.value) === 0 &&
|
||||
tx.operation === CALL
|
||||
)
|
||||
}
|
||||
|
||||
export const getERC20DecimalsAndSymbol = async (
|
||||
|
@ -75,3 +84,32 @@ export const isSendERC20Transaction = async (tx: TxServiceModel): Promise<boolea
|
|||
|
||||
return isSendTokenTx
|
||||
}
|
||||
|
||||
export type GetTokenByAddress = {
|
||||
tokenAddress: string
|
||||
tokens: List<Token>
|
||||
}
|
||||
|
||||
export type TokenFound = {
|
||||
balance: string | number
|
||||
decimals: string | number
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and returns a Token object by the provided address
|
||||
* @param {string} tokenAddress
|
||||
* @param {List<Token>} tokens
|
||||
* @returns Token | undefined
|
||||
*/
|
||||
export const getBalanceAndDecimalsFromToken = ({ tokenAddress, tokens }: GetTokenByAddress): TokenFound | undefined => {
|
||||
const token = tokens?.find(({ address }) => sameAddress(address, tokenAddress))
|
||||
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
balance: token.balance ?? 0,
|
||||
decimals: token.decimals ?? 0,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,4 +40,4 @@ export const isUserAnOwner = (safe: SafeRecord, userAccount: string): boolean =>
|
|||
export const isUserAnOwnerOfAnySafe = (safes: List<SafeRecord> | SafeRecord[], userAccount: string): boolean =>
|
||||
safes.some((safe: SafeRecord) => isUserAnOwner(safe, userAccount))
|
||||
|
||||
export const isValidEnsName = (name: string): boolean => /^([\w-]+\.)+(eth|test|xyz|luxe)$/.test(name)
|
||||
export const isValidEnsName = (name: string): boolean => /^([\w-]+\.)+(eth|test|xyz|luxe|ewc)$/.test(name)
|
||||
|
|
|
@ -20,10 +20,9 @@ import {
|
|||
import Block from 'src/components/layout/Block'
|
||||
import Col from 'src/components/layout/Col'
|
||||
import Paragraph from 'src/components/layout/Paragraph'
|
||||
import { SAFE_MASTER_COPY_ADDRESS_V10, getSafeMasterContract, validateProxy } from 'src/logic/contracts/safeContracts'
|
||||
import { getWeb3 } from 'src/logic/wallets/getWeb3'
|
||||
import { FIELD_LOAD_ADDRESS, FIELD_LOAD_NAME } from 'src/routes/load/components/fields'
|
||||
import { secondary } from 'src/theme/variables'
|
||||
import { getSafeInfo } from 'src/logic/safe/utils/safeInformation'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
|
@ -42,42 +41,23 @@ const useStyles = makeStyles({
|
|||
},
|
||||
})
|
||||
|
||||
export const SAFE_INSTANCE_ERROR = 'Address given is not a Safe instance'
|
||||
export const SAFE_MASTERCOPY_ERROR = 'Address is not a Safe or mastercopy is not supported'
|
||||
export const SAFE_ADDRESS_NOT_VALID = 'Address given is not a valid Safe address'
|
||||
|
||||
// In case of an error here, it will be swallowed by final-form
|
||||
// So if you're experiencing any strang behaviours like freeze or hanging
|
||||
// Don't mind to check if everything is OK inside this function :)
|
||||
export const safeFieldsValidation = async (values): Promise<Record<string, string>> => {
|
||||
const errors = {}
|
||||
const web3 = getWeb3()
|
||||
const safeAddress = values[FIELD_LOAD_ADDRESS]
|
||||
const address = values[FIELD_LOAD_ADDRESS]
|
||||
|
||||
if (!safeAddress || mustBeEthereumAddress(safeAddress) !== undefined) {
|
||||
if (!address || mustBeEthereumAddress(address) !== undefined) {
|
||||
return errors
|
||||
}
|
||||
|
||||
const isValidProxy = await validateProxy(safeAddress)
|
||||
if (!isValidProxy) {
|
||||
errors[FIELD_LOAD_ADDRESS] = SAFE_INSTANCE_ERROR
|
||||
return errors
|
||||
}
|
||||
|
||||
// check mastercopy
|
||||
const proxyAddressFromStorage = await web3.eth.getStorageAt(safeAddress, 0)
|
||||
// https://www.reddit.com/r/ethereum/comments/6l3da1/how_long_are_ethereum_addresses/
|
||||
// ganache returns plain address
|
||||
// rinkeby returns 0x0000000000000+{40 address charachers}
|
||||
// address comes last so we just get last 40 charachers (1byte = 2hex chars)
|
||||
const checksummedProxyAddress = web3.utils.toChecksumAddress(
|
||||
`0x${proxyAddressFromStorage.substr(proxyAddressFromStorage.length - 40)}`,
|
||||
)
|
||||
const safeMaster = await getSafeMasterContract()
|
||||
const masterCopy = safeMaster.options.address
|
||||
const sameMasterCopy =
|
||||
checksummedProxyAddress === masterCopy || checksummedProxyAddress === SAFE_MASTER_COPY_ADDRESS_V10
|
||||
if (!sameMasterCopy) {
|
||||
errors[FIELD_LOAD_ADDRESS] = SAFE_MASTERCOPY_ERROR
|
||||
// if getSafeInfo does not provide data, it's not a valid safe.
|
||||
const safeInfo = await getSafeInfo(address)
|
||||
if (!safeInfo) {
|
||||
errors[FIELD_LOAD_ADDRESS] = SAFE_ADDRESS_NOT_VALID
|
||||
}
|
||||
|
||||
return errors
|
||||
|
|
|
@ -6,12 +6,13 @@ import Stepper, { StepperPage } from 'src/components/Stepper'
|
|||
import Block from 'src/components/layout/Block'
|
||||
import Heading from 'src/components/layout/Heading'
|
||||
import Row from 'src/components/layout/Row'
|
||||
import { initContracts } from 'src/logic/contracts/safeContracts'
|
||||
import Review from 'src/routes/open/components/ReviewInformation'
|
||||
import { instantiateSafeContracts } from 'src/logic/contracts/safeContracts'
|
||||
import { Review } from 'src/routes/open/components/ReviewInformation'
|
||||
import SafeNameField from 'src/routes/open/components/SafeNameForm'
|
||||
import SafeOwnersFields from 'src/routes/open/components/SafeOwnersConfirmationsForm'
|
||||
import { SafeOwnersPage } from 'src/routes/open/components/SafeOwnersConfirmationsForm'
|
||||
import {
|
||||
FIELD_CONFIRMATIONS,
|
||||
FIELD_CREATION_PROXY_SALT,
|
||||
FIELD_SAFE_NAME,
|
||||
getOwnerAddressBy,
|
||||
getOwnerNameBy,
|
||||
|
@ -40,6 +41,7 @@ type InitialValuesForm = {
|
|||
owner0Name?: string
|
||||
confirmations: string
|
||||
safeName?: string
|
||||
safeCreationSalt: number
|
||||
}
|
||||
|
||||
const useInitialValuesFrom = (userAccount: string, safeProps?: SafeProps): InitialValuesForm => {
|
||||
|
@ -51,6 +53,7 @@ const useInitialValuesFrom = (userAccount: string, safeProps?: SafeProps): Initi
|
|||
[getOwnerNameBy(0)]: ownerName || 'My Wallet',
|
||||
[getOwnerAddressBy(0)]: userAccount,
|
||||
[FIELD_CONFIRMATIONS]: '1',
|
||||
[FIELD_CREATION_PROXY_SALT]: Date.now(),
|
||||
}
|
||||
}
|
||||
let obj = {}
|
||||
|
@ -68,6 +71,7 @@ const useInitialValuesFrom = (userAccount: string, safeProps?: SafeProps): Initi
|
|||
...obj,
|
||||
[FIELD_CONFIRMATIONS]: threshold || '1',
|
||||
[FIELD_SAFE_NAME]: name,
|
||||
[FIELD_CREATION_PROXY_SALT]: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -92,7 +96,7 @@ type LayoutProps = {
|
|||
safeProps?: SafeProps
|
||||
}
|
||||
|
||||
const Layout = (props: LayoutProps): React.ReactElement => {
|
||||
export const Layout = (props: LayoutProps): React.ReactElement => {
|
||||
const { onCallSafeContractSubmit, safeProps } = props
|
||||
|
||||
const provider = useSelector(providerNameSelector)
|
||||
|
@ -101,7 +105,7 @@ const Layout = (props: LayoutProps): React.ReactElement => {
|
|||
|
||||
useEffect(() => {
|
||||
if (provider) {
|
||||
initContracts()
|
||||
instantiateSafeContracts()
|
||||
}
|
||||
}, [provider])
|
||||
|
||||
|
@ -129,7 +133,7 @@ const Layout = (props: LayoutProps): React.ReactElement => {
|
|||
testId="create-safe-form"
|
||||
>
|
||||
<StepperPage component={SafeNameField} />
|
||||
<StepperPage component={SafeOwnersFields} />
|
||||
<StepperPage component={SafeOwnersPage} />
|
||||
<StepperPage network={network} userAccount={userAccount} component={Review} />
|
||||
</Stepper>
|
||||
</Block>
|
||||
|
@ -139,5 +143,3 @@ const Layout = (props: LayoutProps): React.ReactElement => {
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
|
|
|
@ -13,7 +13,7 @@ import Row from 'src/components/layout/Row'
|
|||
import OpenPaper from 'src/components/Stepper/OpenPaper'
|
||||
import { estimateGasForDeployingSafe } from 'src/logic/contracts/safeContracts'
|
||||
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
|
||||
import { getAccountsFrom, getNamesFrom } from 'src/routes/open/utils/safeDataExtractor'
|
||||
import { getAccountsFrom, getNamesFrom, getSafeCreationSaltFrom } from 'src/routes/open/utils/safeDataExtractor'
|
||||
|
||||
import { FIELD_CONFIRMATIONS, FIELD_NAME, getNumOwnersFrom } from '../fields'
|
||||
import { useStyles } from './styles'
|
||||
|
@ -33,20 +33,23 @@ const ReviewComponent = ({ userAccount, values }: ReviewComponentProps) => {
|
|||
const names = getNamesFrom(values)
|
||||
const addresses = getAccountsFrom(values)
|
||||
const numOwners = getNumOwnersFrom(values)
|
||||
const safeCreationSalt = getSafeCreationSaltFrom(values)
|
||||
|
||||
useEffect(() => {
|
||||
const estimateGas = async () => {
|
||||
if (!addresses.length || !numOwners || !userAccount) {
|
||||
return
|
||||
}
|
||||
const estimatedGasCosts = (await estimateGasForDeployingSafe(addresses, numOwners, userAccount)).toString()
|
||||
const estimatedGasCosts = (
|
||||
await estimateGasForDeployingSafe(addresses, numOwners, userAccount, safeCreationSalt)
|
||||
).toString()
|
||||
const gasCosts = fromTokenUnit(estimatedGasCosts, nativeCoin.decimals)
|
||||
const formattedGasCosts = formatAmount(gasCosts)
|
||||
setGasCosts(formattedGasCosts)
|
||||
}
|
||||
|
||||
estimateGas()
|
||||
}, [addresses, numOwners, userAccount])
|
||||
}, [addresses, numOwners, safeCreationSalt, userAccount])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -140,7 +143,7 @@ const ReviewComponent = ({ userAccount, values }: ReviewComponentProps) => {
|
|||
)
|
||||
}
|
||||
|
||||
const Review = () =>
|
||||
export const Review = () =>
|
||||
function ReviewPage(controls, props): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
|
@ -150,5 +153,3 @@ const Review = () =>
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Review
|
||||
|
|
|
@ -8,7 +8,7 @@ import { getAddressValidator } from './validators'
|
|||
|
||||
import QRIcon from 'src/assets/icons/qrcode.svg'
|
||||
import trash from 'src/assets/icons/trash.svg'
|
||||
import ScanQRModal from 'src/components/ScanQRModal'
|
||||
import { ScanQRModal } from 'src/components/ScanQRModal'
|
||||
import OpenPaper from 'src/components/Stepper/OpenPaper'
|
||||
import AddressInput from 'src/components/forms/AddressInput'
|
||||
import Field from 'src/components/forms/Field'
|
||||
|
@ -97,10 +97,10 @@ const SafeOwnersForm = (props): React.ReactElement => {
|
|||
setNumOwners(numOwners + 1)
|
||||
}
|
||||
|
||||
const handleScan = (value) => {
|
||||
const handleScan = (value: string | null) => {
|
||||
let scannedAddress = value
|
||||
|
||||
if (scannedAddress.startsWith('ethereum:')) {
|
||||
if (scannedAddress?.startsWith('ethereum:')) {
|
||||
scannedAddress = scannedAddress.replace('ethereum:', '')
|
||||
}
|
||||
|
||||
|
@ -236,21 +236,13 @@ const SafeOwnersForm = (props): React.ReactElement => {
|
|||
)
|
||||
}
|
||||
|
||||
const SafeOwnersPage = ({ updateInitialProps }) =>
|
||||
export const SafeOwnersPage = () =>
|
||||
function OpenSafeOwnersPage(controls, { errors, form, values }) {
|
||||
return (
|
||||
<>
|
||||
<OpenPaper controls={controls} padding={false}>
|
||||
<SafeOwnersForm
|
||||
errors={errors}
|
||||
form={form}
|
||||
otherAccounts={getAccountsFrom(values)}
|
||||
updateInitialProps={updateInitialProps}
|
||||
values={values}
|
||||
/>
|
||||
<SafeOwnersForm errors={errors} form={form} otherAccounts={getAccountsFrom(values)} values={values} />
|
||||
</OpenPaper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SafeOwnersPage
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { uniqueAddress } from 'src/components/forms/validator'
|
||||
import { GenericValidatorType, uniqueAddress } from 'src/components/forms/validator'
|
||||
|
||||
export const getAddressValidator = (addresses, position) => {
|
||||
export const getAddressValidator = (addresses: string[], position: number): GenericValidatorType => {
|
||||
// thanks Rich Harris
|
||||
// https://twitter.com/Rich_Harris/status/1125850391155965952
|
||||
const copy = addresses.slice()
|
||||
|
|
|
@ -2,6 +2,7 @@ export const FIELD_NAME = 'name'
|
|||
export const FIELD_CONFIRMATIONS = 'confirmations'
|
||||
export const FIELD_OWNERS = 'owners'
|
||||
export const FIELD_SAFE_NAME = 'safeName'
|
||||
export const FIELD_CREATION_PROXY_SALT = 'safeCreationSalt'
|
||||
|
||||
export const getOwnerNameBy = (index) => `owner${index}Name`
|
||||
export const getOwnerAddressBy = (index) => `owner${index}Address`
|
||||
|
|
|
@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react'
|
|||
import ReactGA from 'react-ga'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import Opening from 'src/routes/opening'
|
||||
import Layout from 'src/routes/open/components/Layout'
|
||||
import { Layout } from 'src/routes/open/components/Layout'
|
||||
import Page from 'src/components/layout/Page'
|
||||
import { getSafeDeploymentTransaction } from 'src/logic/contracts/safeContracts'
|
||||
import { checkReceiptStatus } from 'src/logic/wallets/ethTransactions'
|
||||
|
@ -12,6 +12,7 @@ import {
|
|||
getAccountsFrom,
|
||||
getNamesFrom,
|
||||
getOwnersFrom,
|
||||
getSafeCreationSaltFrom,
|
||||
getSafeNameFrom,
|
||||
getThresholdFrom,
|
||||
} from 'src/routes/open/utils/safeDataExtractor'
|
||||
|
@ -58,9 +59,9 @@ export const createSafe = (values, userAccount) => {
|
|||
const name = getSafeNameFrom(values)
|
||||
const ownersNames = getNamesFrom(values)
|
||||
const ownerAddresses = getAccountsFrom(values)
|
||||
const safeCreationSalt = getSafeCreationSaltFrom(values)
|
||||
|
||||
const deploymentTx = getSafeDeploymentTransaction(ownerAddresses, confirmations)
|
||||
|
||||
const deploymentTx = getSafeDeploymentTransaction(ownerAddresses, confirmations, safeCreationSalt)
|
||||
const promiEvent = deploymentTx.send({ from: userAccount })
|
||||
|
||||
promiEvent
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { List } from 'immutable'
|
||||
|
||||
import { makeOwner } from 'src/logic/safe/store/models/owner'
|
||||
import { SafeOwner } from '../../../logic/safe/store/models/safe'
|
||||
import { SafeOwner } from 'src/logic/safe/store/models/safe'
|
||||
|
||||
export const getAccountsFrom = (values) => {
|
||||
const accounts = Object.keys(values)
|
||||
|
@ -28,3 +28,5 @@ export const getOwnersFrom = (names, addresses): List<SafeOwner> => {
|
|||
export const getThresholdFrom = (values) => Number(values.confirmations)
|
||||
|
||||
export const getSafeNameFrom = (values) => values.name
|
||||
|
||||
export const getSafeCreationSaltFrom = (values) => values.safeCreationSalt
|
||||
|
|
|
@ -9,7 +9,7 @@ import Button from 'src/components/layout/Button'
|
|||
import Heading from 'src/components/layout/Heading'
|
||||
import Img from 'src/components/layout/Img'
|
||||
import Paragraph from 'src/components/layout/Paragraph'
|
||||
import { initContracts } from 'src/logic/contracts/safeContracts'
|
||||
import { instantiateSafeContracts } from 'src/logic/contracts/safeContracts'
|
||||
import { EMPTY_DATA } from 'src/logic/wallets/ethTransactions'
|
||||
import { getWeb3 } from 'src/logic/wallets/getWeb3'
|
||||
import { background, connected } from 'src/theme/variables'
|
||||
|
@ -152,7 +152,7 @@ const SafeDeployment = ({ creationTxHash, onCancel, onRetry, onSuccess, submitte
|
|||
|
||||
useEffect(() => {
|
||||
const loadContracts = async () => {
|
||||
await initContracts()
|
||||
await instantiateSafeContracts()
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
|
|
|
@ -19,8 +19,7 @@ import Col from 'src/components/layout/Col'
|
|||
import Hairline from 'src/components/layout/Hairline'
|
||||
import Paragraph from 'src/components/layout/Paragraph'
|
||||
import Row from 'src/components/layout/Row'
|
||||
import { addressBookSelector } from 'src/logic/addressBook/store/selectors'
|
||||
import { getAddressesListFromAddressBook } from 'src/logic/addressBook/utils'
|
||||
import { addressBookAddressesListSelector } from 'src/logic/addressBook/store/selectors'
|
||||
|
||||
export const CREATE_ENTRY_INPUT_NAME_ID = 'create-entry-input-name'
|
||||
export const CREATE_ENTRY_INPUT_ADDRESS_ID = 'create-entry-input-address'
|
||||
|
@ -42,8 +41,7 @@ const CreateEditEntryModalComponent = ({
|
|||
}
|
||||
}
|
||||
|
||||
const addressBook = useSelector(addressBookSelector)
|
||||
const addressBookAddressesList = getAddressesListFromAddressBook(addressBook)
|
||||
const addressBookAddressesList = useSelector(addressBookAddressesListSelector)
|
||||
const entryDoesntExist = uniqueAddress(addressBookAddressesList)
|
||||
|
||||
const formMutators = {
|
||||
|
|
|
@ -63,12 +63,14 @@ export const EllipsisTransactionDetails = ({
|
|||
<div className={classes.container} role="menu" tabIndex={0}>
|
||||
<MoreHorizIcon onClick={handleClick} onKeyDown={handleClick} />
|
||||
<Menu anchorEl={anchorEl} id="simple-menu" keepMounted onClose={closeMenuHandler} open={Boolean(anchorEl)}>
|
||||
{sendModalOpenHandler ? (
|
||||
<>
|
||||
<MenuItem onClick={sendModalOpenHandler}>Send Again</MenuItem>
|
||||
<Divider />
|
||||
</>
|
||||
) : null}
|
||||
{sendModalOpenHandler
|
||||
? [
|
||||
<MenuItem key="send-again-button" onClick={sendModalOpenHandler}>
|
||||
Send Again
|
||||
</MenuItem>,
|
||||
<Divider key="divider" />,
|
||||
]
|
||||
: null}
|
||||
{knownAddress ? (
|
||||
<MenuItem onClick={addOrEditEntryHandler}>Edit Address book Entry</MenuItem>
|
||||
) : (
|
||||
|
|
|
@ -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
|
|
@ -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 |
|
@ -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
|
|
@ -1,19 +1,20 @@
|
|||
import { Text, TextField } from '@gnosis.pm/safe-react-components'
|
||||
import React from 'react'
|
||||
import { TextField } from '@gnosis.pm/safe-react-components'
|
||||
import React, { useState, ReactElement } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import AppAgreement from './AppAgreement'
|
||||
import AppUrl, { AppInfoUpdater, appUrlResolver } from './AppUrl'
|
||||
import SubmitButtonStatus from './SubmitButtonStatus'
|
||||
|
||||
import { SafeApp } from 'src/routes/safe/components/Apps/types.d'
|
||||
import GnoForm from 'src/components/forms/GnoForm'
|
||||
import Img from 'src/components/layout/Img'
|
||||
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
margin-bottom: 19px;
|
||||
`
|
||||
import AppAgreement from './AppAgreement'
|
||||
import AppUrl, { AppInfoUpdater, appUrlResolver } from './AppUrl'
|
||||
import FormButtons from './FormButtons'
|
||||
import { APPS_STORAGE_KEY, getEmptySafeApp } from 'src/routes/safe/components/Apps/utils'
|
||||
import { saveToStorage } from 'src/utils/storage'
|
||||
import { SAFELIST_ADDRESS } from 'src/routes/routes'
|
||||
import { useHistory, useRouteMatch } from 'react-router-dom'
|
||||
|
||||
const FORM_ID = 'add-apps-form'
|
||||
|
||||
const StyledTextFileAppName = styled(TextField)`
|
||||
&& {
|
||||
|
@ -39,38 +40,34 @@ const INITIAL_VALUES: AddAppFormValues = {
|
|||
agreementAccepted: false,
|
||||
}
|
||||
|
||||
const APP_INFO: SafeApp = {
|
||||
id: '',
|
||||
url: '',
|
||||
name: '',
|
||||
iconUrl: appsIconSvg,
|
||||
error: false,
|
||||
description: '',
|
||||
}
|
||||
const APP_INFO = getEmptySafeApp()
|
||||
|
||||
interface AddAppProps {
|
||||
appList: SafeApp[]
|
||||
closeModal: () => void
|
||||
formId: string
|
||||
onAppAdded: (app: SafeApp) => void
|
||||
setIsSubmitDisabled: (disabled: boolean) => void
|
||||
}
|
||||
|
||||
const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }: AddAppProps): React.ReactElement => {
|
||||
const [appInfo, setAppInfo] = React.useState<SafeApp>(APP_INFO)
|
||||
const AddApp = ({ appList, closeModal }: AddAppProps): ReactElement => {
|
||||
const [appInfo, setAppInfo] = useState<SafeApp>(APP_INFO)
|
||||
const history = useHistory()
|
||||
const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
|
||||
|
||||
const handleSubmit = () => {
|
||||
closeModal()
|
||||
onAppAdded(appInfo)
|
||||
const newAppList = [
|
||||
{ url: appInfo.url, disabled: false },
|
||||
...appList.map(({ url, disabled }) => ({ url, disabled })),
|
||||
]
|
||||
saveToStorage(APPS_STORAGE_KEY, newAppList)
|
||||
const goToApp = `${matchSafeWithAddress?.url}/apps?appUrl=${encodeURI(appInfo.url)}`
|
||||
history.push(goToApp)
|
||||
}
|
||||
|
||||
return (
|
||||
<GnoForm decorators={[appUrlResolver]} initialValues={INITIAL_VALUES} onSubmit={handleSubmit} testId={formId}>
|
||||
<GnoForm decorators={[appUrlResolver]} initialValues={INITIAL_VALUES} onSubmit={handleSubmit} testId={FORM_ID}>
|
||||
{() => (
|
||||
<>
|
||||
<StyledText size="xl">Add custom app</StyledText>
|
||||
|
||||
<AppUrl appList={appList} />
|
||||
|
||||
{/* Fetch app from url and return a SafeApp */}
|
||||
<AppInfoUpdater onAppInfo={setAppInfo} />
|
||||
|
||||
|
@ -81,7 +78,7 @@ const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }
|
|||
|
||||
<AppAgreement />
|
||||
|
||||
<SubmitButtonStatus onSubmitButtonStatusChange={setIsSubmitDisabled} appInfo={appInfo} />
|
||||
<FormButtons appInfo={appInfo} onCancel={closeModal} />
|
||||
</>
|
||||
)}
|
||||
</GnoForm>
|
|
@ -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}
|
||||
/>
|
||||
)
|
|
@ -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
|
|
@ -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;
|
||||
`
|
|
@ -1,92 +1,289 @@
|
|||
import React, { forwardRef } from 'react'
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { FixedIcon, Loader, Title } from '@gnosis.pm/safe-react-components'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import {
|
||||
FixedIcon,
|
||||
Loader,
|
||||
Title,
|
||||
Text,
|
||||
Card,
|
||||
GenericModal,
|
||||
ModalFooterConfirmation,
|
||||
Menu,
|
||||
ButtonLink,
|
||||
} from '@gnosis.pm/safe-react-components'
|
||||
import { useHistory, useRouteMatch } from 'react-router-dom'
|
||||
import { useSelector } from 'react-redux'
|
||||
import {
|
||||
INTERFACE_MESSAGES,
|
||||
Transaction,
|
||||
RequestId,
|
||||
LowercaseNetworks,
|
||||
SendTransactionParams,
|
||||
} from '@gnosis.pm/safe-apps-sdk'
|
||||
|
||||
import {
|
||||
safeEthBalanceSelector,
|
||||
safeParamAddressFromStateSelector,
|
||||
safeNameSelector,
|
||||
} from 'src/logic/safe/store/selectors'
|
||||
import { grantedSelector } from 'src/routes/safe/container/selector'
|
||||
import { getNetworkName } from 'src/config'
|
||||
import { SAFELIST_ADDRESS } from 'src/routes/routes'
|
||||
import { isSameURL } from 'src/utils/url'
|
||||
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
|
||||
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
|
||||
import { staticAppsList } from 'src/routes/safe/components/Apps/utils'
|
||||
|
||||
import ConfirmTransactionModal from '../components/ConfirmTransactionModal'
|
||||
import { useIframeMessageHandler } from '../hooks/useIframeMessageHandler'
|
||||
import { useLegalConsent } from '../hooks/useLegalConsent'
|
||||
import { SafeApp } from '../types'
|
||||
import LegalDisclaimer from './LegalDisclaimer'
|
||||
import { APPS_STORAGE_KEY, getAppInfoFromUrl } from '../utils'
|
||||
import { SafeApp, StoredSafeApp } from '../types.d'
|
||||
import { LoadingContainer } from 'src/components/LoaderContainer'
|
||||
|
||||
const StyledIframe = styled.iframe`
|
||||
padding: 15px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
const IframeWrapper = styled.div`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const Centered = styled.div`
|
||||
const OwnerDisclaimer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
height: 476px;
|
||||
`
|
||||
|
||||
type AppFrameProps = {
|
||||
selectedApp: SafeApp | undefined
|
||||
safeAddress: string
|
||||
network: string
|
||||
granted: boolean
|
||||
appIsLoading: boolean
|
||||
onIframeLoad: () => void
|
||||
const AppWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const StyledCard = styled(Card)`
|
||||
flex-grow: 1;
|
||||
`
|
||||
|
||||
const StyledIframe = styled.iframe`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
`
|
||||
|
||||
const Breadcrumb = styled.div`
|
||||
height: 51px;
|
||||
`
|
||||
|
||||
type ConfirmTransactionModalState = {
|
||||
isOpen: boolean
|
||||
txs: Transaction[]
|
||||
requestId?: RequestId
|
||||
params?: SendTransactionParams
|
||||
}
|
||||
|
||||
const AppFrame = forwardRef<HTMLIFrameElement, AppFrameProps>(function AppFrameComponent(
|
||||
{ selectedApp, safeAddress, network, appIsLoading, granted, onIframeLoad },
|
||||
iframeRef,
|
||||
): React.ReactElement {
|
||||
type Props = {
|
||||
appUrl: string
|
||||
}
|
||||
|
||||
const NETWORK_NAME = getNetworkName()
|
||||
|
||||
const INITIAL_CONFIRM_TX_MODAL_STATE: ConfirmTransactionModalState = {
|
||||
isOpen: false,
|
||||
txs: [],
|
||||
requestId: undefined,
|
||||
params: undefined,
|
||||
}
|
||||
|
||||
const AppFrame = ({ appUrl }: Props): React.ReactElement => {
|
||||
const granted = useSelector(grantedSelector)
|
||||
const safeAddress = useSelector(safeParamAddressFromStateSelector)
|
||||
const ethBalance = useSelector(safeEthBalanceSelector)
|
||||
const safeName = useSelector(safeNameSelector)
|
||||
const { trackEvent } = useAnalytics()
|
||||
const history = useHistory()
|
||||
const { consentReceived, onConsentReceipt } = useLegalConsent()
|
||||
|
||||
const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const [confirmTransactionModal, setConfirmTransactionModal] = useState<ConfirmTransactionModalState>(
|
||||
INITIAL_CONFIRM_TX_MODAL_STATE,
|
||||
)
|
||||
const [appIsLoading, setAppIsLoading] = useState<boolean>(true)
|
||||
const [safeApp, setSafeApp] = useState<SafeApp | undefined>()
|
||||
const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false)
|
||||
const [isAppDeletable, setIsAppDeletable] = useState<boolean | undefined>()
|
||||
|
||||
const redirectToBalance = () => history.push(`${SAFELIST_ADDRESS}/${safeAddress}/balances`)
|
||||
|
||||
if (!selectedApp) {
|
||||
return <div />
|
||||
const openConfirmationModal = useCallback(
|
||||
(txs: Transaction[], params: SendTransactionParams | undefined, requestId: RequestId) =>
|
||||
setConfirmTransactionModal({
|
||||
isOpen: true,
|
||||
txs,
|
||||
requestId,
|
||||
params,
|
||||
}),
|
||||
[setConfirmTransactionModal],
|
||||
)
|
||||
const closeConfirmationModal = useCallback(() => setConfirmTransactionModal(INITIAL_CONFIRM_TX_MODAL_STATE), [
|
||||
setConfirmTransactionModal,
|
||||
])
|
||||
|
||||
const { sendMessageToIframe } = useIframeMessageHandler(
|
||||
safeApp,
|
||||
openConfirmationModal,
|
||||
closeConfirmationModal,
|
||||
iframeRef,
|
||||
)
|
||||
|
||||
const onIframeLoad = useCallback(() => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe || !isSameURL(iframe.src, appUrl as string)) {
|
||||
return
|
||||
}
|
||||
|
||||
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} />
|
||||
}
|
||||
|
||||
if (network === 'UNKNOWN' || !granted) {
|
||||
if (NETWORK_NAME === 'UNKNOWN' || !granted) {
|
||||
return (
|
||||
<Centered style={{ height: '476px' }}>
|
||||
<OwnerDisclaimer>
|
||||
<FixedIcon type="notOwner" />
|
||||
<Title size="xs">To use apps, you must be an owner of this Safe</Title>
|
||||
</Centered>
|
||||
</OwnerDisclaimer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<IframeWrapper>
|
||||
{appIsLoading && (
|
||||
<LoadingContainer>
|
||||
<Loader size="md" />
|
||||
</LoadingContainer>
|
||||
<AppWrapper>
|
||||
<Menu>
|
||||
<Breadcrumb />
|
||||
{isAppDeletable && (
|
||||
<ButtonLink color="error" iconType="delete" onClick={openRemoveModal}>
|
||||
Remove app
|
||||
</ButtonLink>
|
||||
)}
|
||||
</Menu>
|
||||
|
||||
<StyledCard>
|
||||
{appIsLoading && (
|
||||
<LoadingContainer>
|
||||
<Loader size="md" />
|
||||
</LoadingContainer>
|
||||
)}
|
||||
|
||||
<StyledIframe
|
||||
frameBorder="0"
|
||||
id={`iframe-${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"
|
||||
id={`iframe-${selectedApp.name}`}
|
||||
ref={iframeRef}
|
||||
src={selectedApp.url}
|
||||
title={selectedApp.name}
|
||||
onLoad={onIframeLoad}
|
||||
|
||||
<ConfirmTransactionModal
|
||||
isOpen={confirmTransactionModal.isOpen}
|
||||
app={safeApp as SafeApp}
|
||||
safeAddress={safeAddress}
|
||||
ethBalance={ethBalance as string}
|
||||
safeName={safeName as string}
|
||||
txs={confirmTransactionModal.txs}
|
||||
onClose={closeConfirmationModal}
|
||||
onUserConfirm={onUserTxConfirm}
|
||||
params={confirmTransactionModal.params}
|
||||
onTxReject={onTxReject}
|
||||
/>
|
||||
</IframeWrapper>
|
||||
</AppWrapper>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export default AppFrame
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -1,141 +1,62 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
|
||||
import { getAppInfoFromUrl, staticAppsList } from '../utils'
|
||||
import { SafeApp, StoredSafeApp } from '../types'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { loadFromStorage } from 'src/utils/storage'
|
||||
import { APPS_STORAGE_KEY, getAppInfoFromUrl, getEmptySafeApp, staticAppsList } from '../utils'
|
||||
import { SafeApp, StoredSafeApp, SAFE_APP_FETCH_STATUS } from '../types.d'
|
||||
import { getNetworkId } from 'src/config'
|
||||
|
||||
const APPS_STORAGE_KEY = 'APPS_STORAGE_KEY'
|
||||
|
||||
type onAppToggleHandler = (appId: string, enabled: boolean) => Promise<void>
|
||||
type onAppAddedHandler = (app: SafeApp) => void
|
||||
type onAppRemovedHandler = (appId: string) => void
|
||||
|
||||
type UseAppListReturnType = {
|
||||
appList: SafeApp[]
|
||||
loadingAppList: boolean
|
||||
onAppToggle: onAppToggleHandler
|
||||
onAppAdded: onAppAddedHandler
|
||||
onAppRemoved: onAppRemovedHandler
|
||||
}
|
||||
|
||||
const useAppList = (): UseAppListReturnType => {
|
||||
const [appList, setAppList] = useState<SafeApp[]>([])
|
||||
const [loadingAppList, setLoadingAppList] = useState<boolean>(true)
|
||||
|
||||
// Load apps list
|
||||
// for each URL we return a mocked safe-app with a loading status
|
||||
// it was developed to speed up initial page load, otherwise the
|
||||
// app renders a loading until all the safe-apps are fetched.
|
||||
useEffect(() => {
|
||||
const loadApps = async () => {
|
||||
// recover apps from storage:
|
||||
// * third-party apps added by the user
|
||||
// * disabled status for both static and third-party apps
|
||||
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || []
|
||||
let list: (StoredSafeApp & { isDeletable: boolean; networks?: number[] })[] = persistedAppList.map((a) => ({
|
||||
...a,
|
||||
isDeletable: true,
|
||||
}))
|
||||
|
||||
// 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 })
|
||||
}
|
||||
const fetchAppCallback = (res: SafeApp) => {
|
||||
setAppList((prevStatus) => {
|
||||
const cpPrevStatus = [...prevStatus]
|
||||
const appIndex = cpPrevStatus.findIndex((a) => a.url === res.url)
|
||||
const newStatus = res.error ? SAFE_APP_FETCH_STATUS.ERROR : SAFE_APP_FETCH_STATUS.SUCCESS
|
||||
cpPrevStatus[appIndex] = { ...res, fetchStatus: newStatus }
|
||||
return cpPrevStatus.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
|
||||
})
|
||||
|
||||
// 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(
|
||||
async (appId, enabled) => {
|
||||
// update in-memory list
|
||||
const appListCopy = [...appList]
|
||||
// backward compatibility. In a previous implementation a safe app could be disabled, that state was
|
||||
// persisted in the storage.
|
||||
const customApps = persistedAppList.filter(
|
||||
(persistedApp) => !staticAppsList.some((staticApp) => staticApp.url === persistedApp.url),
|
||||
)
|
||||
|
||||
const app = appListCopy.find((a) => a.id === appId)
|
||||
if (!app) {
|
||||
return
|
||||
}
|
||||
app.disabled = !enabled
|
||||
const apps: SafeApp[] = [...staticAppsList, ...customApps]
|
||||
// if the app does not expose supported networks, include them. (backward compatible)
|
||||
.filter((app) => (!app.networks ? true : app.networks.includes(getNetworkId())))
|
||||
.map((app) => ({
|
||||
...getEmptySafeApp(),
|
||||
url: app.url.trim(),
|
||||
}))
|
||||
|
||||
setAppList(appListCopy)
|
||||
setAppList(apps)
|
||||
|
||||
// update storage list
|
||||
const listToPersist: StoredSafeApp[] = appListCopy.map(({ url, disabled }) => ({ url, disabled }))
|
||||
saveToStorage(APPS_STORAGE_KEY, listToPersist)
|
||||
},
|
||||
[appList],
|
||||
)
|
||||
apps.forEach((app) => getAppInfoFromUrl(app.url).then(fetchAppCallback))
|
||||
}
|
||||
|
||||
const onAppAdded: onAppAddedHandler = useCallback(
|
||||
(app) => {
|
||||
const newAppList = [
|
||||
{ url: app.url, disabled: false },
|
||||
...appList.map((a) => ({
|
||||
url: a.url,
|
||||
disabled: a.disabled,
|
||||
})),
|
||||
]
|
||||
saveToStorage(APPS_STORAGE_KEY, newAppList)
|
||||
|
||||
setAppList([...appList, { ...app, isDeletable: true }])
|
||||
},
|
||||
[appList],
|
||||
)
|
||||
|
||||
const onAppRemoved: onAppRemovedHandler = useCallback(
|
||||
(appId) => {
|
||||
const appListCopy = appList.filter((a) => a.id !== appId)
|
||||
|
||||
setAppList(appListCopy)
|
||||
|
||||
const listToPersist: StoredSafeApp[] = appListCopy.map(({ url, disabled }) => ({ url, disabled }))
|
||||
saveToStorage(APPS_STORAGE_KEY, listToPersist)
|
||||
},
|
||||
[appList],
|
||||
)
|
||||
if (!appList.length) {
|
||||
loadApps()
|
||||
}
|
||||
}, [appList])
|
||||
|
||||
return {
|
||||
appList,
|
||||
loadingAppList,
|
||||
onAppToggle,
|
||||
onAppAdded,
|
||||
onAppRemoved,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,8 +3,8 @@ import { loadFromStorage, saveToStorage } from 'src/utils/storage'
|
|||
|
||||
const APPS_LEGAL_CONSENT_RECEIVED = 'APPS_LEGAL_CONSENT_RECEIVED'
|
||||
|
||||
const useLegalConsent = (): { consentReceived: boolean; onConsentReceipt: () => void } => {
|
||||
const [consentReceived, setConsentReceived] = useState<boolean>(false)
|
||||
const useLegalConsent = (): { consentReceived: boolean | undefined; onConsentReceipt: () => void } => {
|
||||
const [consentReceived, setConsentReceived] = useState<boolean | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
const checkLegalDisclaimer = async () => {
|
||||
|
@ -12,6 +12,8 @@ const useLegalConsent = (): { consentReceived: boolean; onConsentReceipt: () =>
|
|||
|
||||
if (storedConsentReceived) {
|
||||
setConsentReceived(true)
|
||||
} else {
|
||||
setConsentReceived(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,234 +1,23 @@
|
|||
import React, { useCallback, useEffect, useRef, useState, useMemo } from 'react'
|
||||
import {
|
||||
INTERFACE_MESSAGES,
|
||||
Transaction,
|
||||
RequestId,
|
||||
LowercaseNetworks,
|
||||
SendTransactionParams,
|
||||
} from '@gnosis.pm/safe-apps-sdk'
|
||||
import { Card, IconText, Loader, Menu, Title } from '@gnosis.pm/safe-react-components'
|
||||
import { useSelector } from 'react-redux'
|
||||
import styled, { css } from 'styled-components'
|
||||
import React from 'react'
|
||||
|
||||
import ManageApps from './components/ManageApps'
|
||||
import AppFrame from './components/AppFrame'
|
||||
import { useAppList } from './hooks/useAppList'
|
||||
import { SafeApp } from './types.d'
|
||||
import AppsList from './components/AppsList'
|
||||
|
||||
import LCL from 'src/components/ListContentLayout'
|
||||
import { grantedSelector } from 'src/routes/safe/container/selector'
|
||||
import {
|
||||
safeEthBalanceSelector,
|
||||
safeParamAddressFromStateSelector,
|
||||
safeNameSelector,
|
||||
} from 'src/logic/safe/store/selectors'
|
||||
import { isSameURL } from 'src/utils/url'
|
||||
import { useIframeMessageHandler } from './hooks/useIframeMessageHandler'
|
||||
import ConfirmTransactionModal from './components/ConfirmTransactionModal'
|
||||
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
|
||||
import { getNetworkName } from 'src/config'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
const centerCSS = css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
${centerCSS};
|
||||
`
|
||||
|
||||
const StyledCard = styled(Card)`
|
||||
margin-bottom: 24px;
|
||||
${centerCSS};
|
||||
`
|
||||
|
||||
const CenteredMT = styled.div`
|
||||
${centerCSS};
|
||||
margin-top: 16px;
|
||||
`
|
||||
|
||||
type ConfirmTransactionModalState = {
|
||||
isOpen: boolean
|
||||
txs: Transaction[]
|
||||
requestId?: RequestId
|
||||
params?: SendTransactionParams
|
||||
const useQuery = () => {
|
||||
return new URLSearchParams(useLocation().search)
|
||||
}
|
||||
|
||||
const INITIAL_CONFIRM_TX_MODAL_STATE: ConfirmTransactionModalState = {
|
||||
isOpen: false,
|
||||
txs: [],
|
||||
requestId: undefined,
|
||||
params: undefined,
|
||||
}
|
||||
|
||||
const NETWORK_NAME = getNetworkName()
|
||||
|
||||
const Apps = (): React.ReactElement => {
|
||||
const { appList, loadingAppList, onAppToggle, onAppAdded, onAppRemoved } = useAppList()
|
||||
const query = useQuery()
|
||||
const appUrl = query.get('appUrl')
|
||||
|
||||
const [appIsLoading, setAppIsLoading] = useState<boolean>(true)
|
||||
const [selectedAppId, setSelectedAppId] = useState<string>()
|
||||
const [confirmTransactionModal, setConfirmTransactionModal] = useState<ConfirmTransactionModalState>(
|
||||
INITIAL_CONFIRM_TX_MODAL_STATE,
|
||||
)
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
const { trackEvent } = useAnalytics()
|
||||
const granted = useSelector(grantedSelector)
|
||||
const safeAddress = useSelector(safeParamAddressFromStateSelector)
|
||||
const safeName = useSelector(safeNameSelector)
|
||||
const ethBalance = useSelector(safeEthBalanceSelector)
|
||||
|
||||
const openConfirmationModal = useCallback(
|
||||
(txs: Transaction[], params: SendTransactionParams | undefined, requestId: RequestId) =>
|
||||
setConfirmTransactionModal({
|
||||
isOpen: true,
|
||||
txs,
|
||||
requestId,
|
||||
params,
|
||||
}),
|
||||
[setConfirmTransactionModal],
|
||||
)
|
||||
const closeConfirmationModal = useCallback(() => setConfirmTransactionModal(INITIAL_CONFIRM_TX_MODAL_STATE), [
|
||||
setConfirmTransactionModal,
|
||||
])
|
||||
|
||||
const selectedApp = useMemo(() => appList.find((app) => app.id === selectedAppId), [appList, selectedAppId])
|
||||
const enabledApps = useMemo(() => appList.filter((a) => !a.disabled), [appList])
|
||||
const { sendMessageToIframe } = useIframeMessageHandler(
|
||||
selectedApp,
|
||||
openConfirmationModal,
|
||||
closeConfirmationModal,
|
||||
iframeRef,
|
||||
)
|
||||
|
||||
const onUserTxConfirm = (safeTxHash: string) => {
|
||||
sendMessageToIframe(
|
||||
{ messageId: INTERFACE_MESSAGES.TRANSACTION_CONFIRMED, data: { safeTxHash } },
|
||||
confirmTransactionModal.requestId,
|
||||
)
|
||||
if (appUrl) {
|
||||
return <AppFrame appUrl={appUrl} />
|
||||
} else {
|
||||
return <AppsList />
|
||||
}
|
||||
|
||||
const onTxReject = () => {
|
||||
sendMessageToIframe(
|
||||
{ messageId: INTERFACE_MESSAGES.TRANSACTION_REJECTED, data: {} },
|
||||
confirmTransactionModal.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
const onSelectApp = useCallback(
|
||||
(appId) => {
|
||||
if (selectedAppId === appId) {
|
||||
return
|
||||
}
|
||||
|
||||
setAppIsLoading(true)
|
||||
setSelectedAppId(appId)
|
||||
},
|
||||
[selectedAppId],
|
||||
)
|
||||
|
||||
// Auto Select app first App
|
||||
useEffect(() => {
|
||||
const selectFirstEnabledApp = () => {
|
||||
const firstEnabledApp = appList.find((a) => !a.disabled)
|
||||
if (firstEnabledApp) {
|
||||
setSelectedAppId(firstEnabledApp.id)
|
||||
}
|
||||
}
|
||||
|
||||
const initialSelect = appList.length && !selectedAppId
|
||||
const currentAppWasDisabled = selectedApp?.disabled
|
||||
if (initialSelect || currentAppWasDisabled) {
|
||||
selectFirstEnabledApp()
|
||||
}
|
||||
}, [appList, selectedApp, selectedAppId, trackEvent])
|
||||
|
||||
// track GA
|
||||
useEffect(() => {
|
||||
if (selectedApp) {
|
||||
trackEvent({ category: SAFE_NAVIGATION_EVENT, action: 'Apps', label: selectedApp.name })
|
||||
}
|
||||
}, [selectedApp, trackEvent])
|
||||
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe || !selectedApp || !isSameURL(iframe.src, selectedApp.url)) {
|
||||
return
|
||||
}
|
||||
|
||||
setAppIsLoading(false)
|
||||
sendMessageToIframe({
|
||||
messageId: INTERFACE_MESSAGES.ON_SAFE_INFO,
|
||||
data: {
|
||||
safeAddress: safeAddress as string,
|
||||
network: NETWORK_NAME.toLowerCase() as LowercaseNetworks,
|
||||
ethBalance: ethBalance as string,
|
||||
},
|
||||
})
|
||||
}, [ethBalance, safeAddress, selectedApp, sendMessageToIframe])
|
||||
|
||||
if (loadingAppList || !appList.length || !safeAddress) {
|
||||
return (
|
||||
<LoadingContainer>
|
||||
<Loader size="md" />
|
||||
</LoadingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu>
|
||||
<ManageApps appList={appList} onAppAdded={onAppAdded} onAppToggle={onAppToggle} onAppRemoved={onAppRemoved} />
|
||||
</Menu>
|
||||
{enabledApps.length ? (
|
||||
<LCL.Wrapper>
|
||||
<LCL.Menu>
|
||||
<LCL.List activeItem={selectedAppId} items={enabledApps} onItemClick={onSelectApp} />
|
||||
</LCL.Menu>
|
||||
<LCL.Content>
|
||||
<AppFrame
|
||||
ref={iframeRef}
|
||||
granted={granted}
|
||||
selectedApp={selectedApp}
|
||||
safeAddress={safeAddress}
|
||||
network={NETWORK_NAME}
|
||||
appIsLoading={appIsLoading}
|
||||
onIframeLoad={handleIframeLoad}
|
||||
/>
|
||||
</LCL.Content>
|
||||
</LCL.Wrapper>
|
||||
) : (
|
||||
<StyledCard>
|
||||
<Title size="xs">No Apps Enabled</Title>
|
||||
</StyledCard>
|
||||
)}
|
||||
<CenteredMT>
|
||||
<IconText
|
||||
color="secondary"
|
||||
iconSize="sm"
|
||||
iconType="info"
|
||||
text="These are third-party apps, which means they are not owned, controlled, maintained or audited by Gnosis. Interacting with the apps is at your own risk."
|
||||
textSize="sm"
|
||||
/>
|
||||
</CenteredMT>
|
||||
<ConfirmTransactionModal
|
||||
isOpen={confirmTransactionModal.isOpen}
|
||||
app={selectedApp as SafeApp}
|
||||
safeAddress={safeAddress}
|
||||
ethBalance={ethBalance as string}
|
||||
safeName={safeName as string}
|
||||
txs={confirmTransactionModal.txs}
|
||||
onClose={closeConfirmationModal}
|
||||
onUserConfirm={onUserTxConfirm}
|
||||
params={confirmTransactionModal.params}
|
||||
onTxReject={onTxReject}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Apps
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
export enum SAFE_APP_FETCH_STATUS {
|
||||
LOADING = 'LOADING',
|
||||
SUCCESS = 'SUCCESS',
|
||||
ERROR = 'ERROR',
|
||||
}
|
||||
|
||||
export type SafeApp = {
|
||||
id: string
|
||||
url: string
|
||||
name: string
|
||||
iconUrl: string
|
||||
disabled?: boolean
|
||||
isDeletable?: boolean
|
||||
error: boolean
|
||||
description: string
|
||||
error: boolean
|
||||
fetchStatus: SAFE_APP_FETCH_STATUS
|
||||
}
|
||||
|
||||
export type StoredSafeApp = {
|
||||
url: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import axios from 'axios'
|
||||
import memoize from 'lodash.memoize'
|
||||
|
||||
import { SafeApp } from './types.d'
|
||||
import { SafeApp, SAFE_APP_FETCH_STATUS } from './types.d'
|
||||
|
||||
import { getGnosisSafeAppsUrl } from 'src/config'
|
||||
import { getContentFromENS } from 'src/logic/wallets/getWeb3'
|
||||
import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg'
|
||||
import appsIconSvg from 'src/assets/icons/apps.svg'
|
||||
import { ETHEREUM_NETWORK } from 'src/config/networks/network.d'
|
||||
|
||||
export const APPS_STORAGE_KEY = 'APPS_STORAGE_KEY'
|
||||
|
||||
const removeLastTrailingSlash = (url) => {
|
||||
if (url.substr(-1) === '/') {
|
||||
return url.substr(0, url.length - 1)
|
||||
|
@ -16,7 +18,12 @@ const removeLastTrailingSlash = (url) => {
|
|||
}
|
||||
|
||||
const gnosisAppsUrl = removeLastTrailingSlash(getGnosisSafeAppsUrl())
|
||||
export const staticAppsList: Array<{ url: string; disabled: boolean; networks: number[] }> = [
|
||||
export type StaticAppInfo = {
|
||||
url: string
|
||||
disabled: boolean
|
||||
networks: number[]
|
||||
}
|
||||
export const staticAppsList: Array<StaticAppInfo> = [
|
||||
// 1inch
|
||||
{
|
||||
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmUDTSghr154kCCGguyA3cbG5HRVd2tQgNR7yD69bcsjm5`,
|
||||
|
@ -57,7 +64,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean; networks: n
|
|||
},
|
||||
// Sablier
|
||||
{
|
||||
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmfLqzEHz5TEupRLPuFp7prtcVAm6hKii5YZsVZWeM17Lr`,
|
||||
url: `${process.env.REACT_APP_IPFS_GATEWAY}/Qmb1Xpfu9mnX4A3trpoVeBZ9sTiNtEuRoFKEiaVXWntDxB`,
|
||||
disabled: false,
|
||||
networks: [ETHEREUM_NETWORK.MAINNET, ETHEREUM_NETWORK.RINKEBY],
|
||||
},
|
||||
|
@ -81,7 +88,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean; networks: n
|
|||
},
|
||||
// TX-Builder
|
||||
{
|
||||
url: `${gnosisAppsUrl}/tx-builder`,
|
||||
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmXdrr9hRbXSaqMb71iKnEp66PwwsAbJDR9XdwByUYSTxB`,
|
||||
disabled: false,
|
||||
networks: [
|
||||
ETHEREUM_NETWORK.MAINNET,
|
||||
|
@ -93,7 +100,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean; networks: n
|
|||
},
|
||||
// Wallet-Connect
|
||||
{
|
||||
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmVWjxqMYuqZ4WvxKdrErcTt1Sx5JHxZosjYz9zHiHRAiq`,
|
||||
url: `${process.env.REACT_APP_IPFS_GATEWAY}/QmWwSuByB3B3hLU5ita3RQgiSEDYtBr5LjjDCRGb8YqLKF`,
|
||||
disabled: false,
|
||||
networks: [
|
||||
ETHEREUM_NETWORK.MAINNET,
|
||||
|
@ -111,7 +118,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean; networks: n
|
|||
},
|
||||
]
|
||||
|
||||
export const getAppInfoFromOrigin = (origin: string): Record<string, string> | null => {
|
||||
export const getAppInfoFromOrigin = (origin: string): { url: string; name: string } | null => {
|
||||
try {
|
||||
return JSON.parse(origin)
|
||||
} catch (error) {
|
||||
|
@ -132,9 +139,25 @@ export const isAppManifestValid = (appInfo: SafeApp): boolean =>
|
|||
// no `error` (or `error` undefined)
|
||||
!appInfo.error
|
||||
|
||||
export const getEmptySafeApp = (): SafeApp => {
|
||||
return {
|
||||
id: Math.random().toString(),
|
||||
url: '',
|
||||
name: 'unknown',
|
||||
iconUrl: appsIconSvg,
|
||||
error: false,
|
||||
description: '',
|
||||
fetchStatus: SAFE_APP_FETCH_STATUS.LOADING,
|
||||
}
|
||||
}
|
||||
|
||||
export const getAppInfoFromUrl = memoize(
|
||||
async (appUrl: string): Promise<SafeApp> => {
|
||||
let res = { id: '', url: appUrl, name: 'unknown', iconUrl: appsIconSvg, error: true, description: '' }
|
||||
let res = {
|
||||
...getEmptySafeApp(),
|
||||
error: true,
|
||||
loadingStatus: SAFE_APP_FETCH_STATUS.ERROR,
|
||||
}
|
||||
|
||||
if (!appUrl?.length) {
|
||||
return res
|
||||
|
@ -161,6 +184,7 @@ export const getAppInfoFromUrl = memoize(
|
|||
...appInfo.data,
|
||||
id: JSON.stringify({ url: res.url, name: appInfo.data.name.substring(0, remainingSpace) }),
|
||||
error: false,
|
||||
loadingStatus: SAFE_APP_FETCH_STATUS.SUCCESS,
|
||||
}
|
||||
|
||||
if (appInfo.data.iconPath) {
|
||||
|
@ -196,10 +220,10 @@ export const getIpfsLinkFromEns = memoize(
|
|||
)
|
||||
|
||||
export const uniqueApp = (appList: SafeApp[]) => (url: string): string | undefined => {
|
||||
const newUrl = new URL(url)
|
||||
const exists = appList.some((a) => {
|
||||
try {
|
||||
const currentUrl = new URL(a.url)
|
||||
const newUrl = new URL(url)
|
||||
return currentUrl.href === newUrl.href
|
||||
} catch (error) {
|
||||
console.error('There was a problem trying to validate the URL existence.', error.message)
|
||||
|
|
|
@ -9,7 +9,7 @@ import { CustomTx } from './screens/ContractInteraction/ReviewCustomTx'
|
|||
import { ContractInteractionTx } from './screens/ContractInteraction'
|
||||
import { CustomTxProps } from './screens/ContractInteraction/SendCustomTx'
|
||||
import { ReviewTxProp } from './screens/ReviewTx'
|
||||
import { NFTToken } from 'src/logic/collectibles/sources/collectibles'
|
||||
import { NFTToken } from 'src/logic/collectibles/sources/collectibles.d'
|
||||
import { SendCollectibleTxInfo } from './screens/SendCollectible'
|
||||
|
||||
const ChooseTxType = React.lazy(() => import('./screens/ChooseTxType'))
|
||||
|
|
|
@ -20,6 +20,7 @@ import { trimSpaces } from 'src/utils/strings'
|
|||
|
||||
export interface AddressBookProps {
|
||||
fieldMutator: (address: string) => void
|
||||
label?: string
|
||||
pristine?: boolean
|
||||
recipientAddress?: string
|
||||
setIsValidAddress: (valid: boolean) => void
|
||||
|
@ -36,6 +37,7 @@ export interface BaseAddressBookInputProps extends AddressBookProps {
|
|||
const BaseAddressBookInput = ({
|
||||
addressBookEntries,
|
||||
fieldMutator,
|
||||
label = 'Recipient',
|
||||
setIsValidAddress,
|
||||
setSelectedEntry,
|
||||
setValidationText,
|
||||
|
@ -137,7 +139,7 @@ const BaseAddressBookInput = ({
|
|||
fullWidth
|
||||
id="filled-error-helper-text"
|
||||
variant="filled"
|
||||
label={validationText ? validationText : 'Recipient'}
|
||||
label={validationText ? validationText : label}
|
||||
InputLabelProps={{ shrink: true, required: true, classes: labelStyles }}
|
||||
InputProps={{ ...params.InputProps, classes: inputStyles }}
|
||||
/>
|
||||
|
|
|
@ -27,7 +27,7 @@ export interface EthAddressInputProps {
|
|||
text: string
|
||||
}
|
||||
|
||||
const EthAddressInput = ({
|
||||
export const EthAddressInput = ({
|
||||
isContract = true,
|
||||
isRequired = true,
|
||||
name,
|
||||
|
@ -57,6 +57,7 @@ const EthAddressInput = ({
|
|||
scannedAddress = scannedAddress.replace('ethereum:', '')
|
||||
}
|
||||
|
||||
setSelectedEntry({ address: scannedAddress })
|
||||
onScannedValue(scannedAddress)
|
||||
closeQrModal()
|
||||
}
|
||||
|
@ -97,5 +98,3 @@ const EthAddressInput = ({
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EthAddressInput
|
||||
|
|
|
@ -22,11 +22,12 @@ import Hairline from 'src/components/layout/Hairline'
|
|||
import Img from 'src/components/layout/Img'
|
||||
import Paragraph from 'src/components/layout/Paragraph'
|
||||
import Row from 'src/components/layout/Row'
|
||||
import ScanQRModal from 'src/components/ScanQRModal'
|
||||
import { ScanQRModal } from 'src/components/ScanQRModal'
|
||||
import { safeSelector } from 'src/logic/safe/store/selectors'
|
||||
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
|
||||
import { ContractsAddressBookInput } from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
|
||||
import { sm } from 'src/theme/variables'
|
||||
import { sameString } from 'src/utils/strings'
|
||||
|
||||
import ArrowDown from '../../assets/arrow-down.svg'
|
||||
|
||||
|
@ -147,7 +148,7 @@ const SendCustomTx: React.FC<Props> = ({ initialValues, onClose, onNext, contrac
|
|||
{selectedEntry && selectedEntry.address ? (
|
||||
<div
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Tab') {
|
||||
if (sameString(e.key, 'Tab')) {
|
||||
return
|
||||
}
|
||||
setSelectedEntry(null)
|
||||
|
|
|
@ -11,7 +11,7 @@ import { safeSelector } from 'src/logic/safe/store/selectors'
|
|||
import Paragraph from 'src/components/layout/Paragraph'
|
||||
import Buttons from './Buttons'
|
||||
import ContractABI from './ContractABI'
|
||||
import EthAddressInput from './EthAddressInput'
|
||||
import { EthAddressInput } from './EthAddressInput'
|
||||
import FormDivisor from './FormDivisor'
|
||||
import FormErrorMessage from './FormErrorMessage'
|
||||
import Header from './Header'
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import IconButton from '@material-ui/core/IconButton'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import Close from '@material-ui/icons/Close'
|
||||
import { BigNumber } from 'bignumber.js'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { toTokenUnit, fromTokenUnit } from 'src/logic/tokens/utils/humanReadableValue'
|
||||
|
@ -16,17 +15,21 @@ import Hairline from 'src/components/layout/Hairline'
|
|||
import Img from 'src/components/layout/Img'
|
||||
import Paragraph from 'src/components/layout/Paragraph'
|
||||
import Row from 'src/components/layout/Row'
|
||||
import { getSpendingLimitContract } from 'src/logic/contracts/safeContracts'
|
||||
import createTransaction from 'src/logic/safe/store/actions/createTransaction'
|
||||
import { safeSelector } from 'src/logic/safe/store/selectors'
|
||||
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
|
||||
import { estimateTxGasCosts } from 'src/logic/safe/transactions/gas'
|
||||
import { getHumanFriendlyToken } from 'src/logic/tokens/store/actions/fetchTokens'
|
||||
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
|
||||
import { sameAddress, ZERO_ADDRESS } from 'src/logic/wallets/ethAddresses'
|
||||
import { EMPTY_DATA } from 'src/logic/wallets/ethTransactions'
|
||||
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
|
||||
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
|
||||
import { extendedSafeTokensSelector } from 'src/routes/safe/container/selector'
|
||||
import { SpendingLimit } from 'src/logic/safe/store/models/safe'
|
||||
import { sm } from 'src/theme/variables'
|
||||
import { sameString } from 'src/utils/strings'
|
||||
|
||||
import ArrowDown from '../assets/arrow-down.svg'
|
||||
|
||||
|
@ -42,6 +45,8 @@ export type ReviewTxProp = {
|
|||
amount: string
|
||||
txRecipient: string
|
||||
token: string
|
||||
txType?: string
|
||||
tokenSpendingLimit?: SpendingLimit
|
||||
}
|
||||
|
||||
type ReviewTxProps = {
|
||||
|
@ -58,8 +63,8 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
|
|||
const [gasCosts, setGasCosts] = useState('< 0.001')
|
||||
const [data, setData] = useState('')
|
||||
|
||||
const txToken = useMemo(() => tokens.find((token) => token.address === tx.token), [tokens, tx.token])
|
||||
const isSendingETH = txToken?.address === nativeCoin.address
|
||||
const txToken = useMemo(() => tokens.find((token) => sameAddress(token.address, tx.token)), [tokens, tx.token])
|
||||
const isSendingETH = sameAddress(txToken?.address, nativeCoin.address)
|
||||
const txRecipient = isSendingETH ? tx.recipientAddress : txToken?.address
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -75,8 +80,7 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
|
|||
if (!isSendingETH) {
|
||||
const StandardToken = await getHumanFriendlyToken()
|
||||
const tokenInstance = await StandardToken.at(txToken.address as string)
|
||||
const decimals = await tokenInstance.decimals()
|
||||
const txAmount = new BigNumber(tx.amount).times(10 ** decimals.toNumber()).toString()
|
||||
const txAmount = toTokenUnit(tx.amount, txToken.decimals)
|
||||
|
||||
txData = tokenInstance.contract.methods.transfer(tx.recipientAddress, txAmount).encodeABI()
|
||||
}
|
||||
|
@ -99,12 +103,34 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
|
|||
}, [isSendingETH, safeAddress, tx.amount, tx.recipientAddress, txRecipient, txToken])
|
||||
|
||||
const submitTx = async () => {
|
||||
const isSpendingLimit = sameString(tx.txType, 'spendingLimit')
|
||||
// txAmount should be 0 if we send tokens
|
||||
// the real value is encoded in txData and will be used by the contract
|
||||
// if txAmount > 0 it would send ETH from the Safe
|
||||
const txAmount = isSendingETH ? toTokenUnit(tx.amount, nativeCoin.decimals) : '0'
|
||||
|
||||
if (safeAddress) {
|
||||
if (!safeAddress) {
|
||||
console.error('There was an error trying to submit the transaction, the safeAddress was not found')
|
||||
return
|
||||
}
|
||||
|
||||
if (isSpendingLimit && txToken && tx.tokenSpendingLimit) {
|
||||
const spendingLimit = getSpendingLimitContract()
|
||||
spendingLimit.methods
|
||||
.executeAllowanceTransfer(
|
||||
safeAddress,
|
||||
sameAddress(txToken.address, nativeCoin.address) ? ZERO_ADDRESS : txToken.address,
|
||||
tx.recipientAddress,
|
||||
toTokenUnit(tx.amount, txToken.decimals),
|
||||
ZERO_ADDRESS,
|
||||
0,
|
||||
tx.tokenSpendingLimit.delegate,
|
||||
EMPTY_DATA,
|
||||
)
|
||||
.send({ from: tx.tokenSpendingLimit.delegate })
|
||||
.on('transactionHash', () => onClose())
|
||||
.catch(console.error)
|
||||
} else {
|
||||
dispatch(
|
||||
createTransaction({
|
||||
safeAddress: safeAddress,
|
||||
|
@ -114,10 +140,8 @@ const ReviewTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactElement =>
|
|||
notifiedTransaction: TX_NOTIFICATION_TYPES.STANDARD_TX,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
console.error('There was an error trying to submit the transaction, the safeAddress was not found')
|
||||
onClose()
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -13,7 +13,7 @@ import Img from 'src/components/layout/Img'
|
|||
import Paragraph from 'src/components/layout/Paragraph'
|
||||
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
|
||||
import { textShortener } from 'src/utils/strings'
|
||||
import { NFTToken } from 'src/logic/collectibles/sources/collectibles'
|
||||
import { NFTToken } from 'src/logic/collectibles/sources/collectibles.d'
|
||||
|
||||
const useSelectedCollectibleStyles = makeStyles(selectedTokenStyles)
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue