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