Merge pull request #129 from gnosis/79-tx-list

Feature #79: Transaction list
This commit is contained in:
Mikhail Mikheev 2019-07-18 13:03:39 +04:00 committed by GitHub
commit 36aff894d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
95 changed files with 3906 additions and 1589 deletions

View File

@ -1,8 +1,5 @@
{ {
"extends": [ "extends": ["airbnb", "plugin:flowtype/recommended"],
"airbnb",
"plugin:flowtype/recommended"
],
"parser": "babel-eslint", "parser": "babel-eslint",
"plugins": ["jest", "flowtype"], "plugins": ["jest", "flowtype"],
"rules": { "rules": {
@ -27,19 +24,23 @@
"import/extensions": 0, "import/extensions": 0,
"import/prefer-default-export": 0, "import/prefer-default-export": 0,
"jsx-a11y/label-has-for": 0, "jsx-a11y/label-has-for": 0,
"indent": ["error", 2], "indent": ["error", 2, { "SwitchCase": 1 }],
"no-console": ["error", { "allow": ["warn", "error"] }], "no-console": ["error", { "allow": ["warn", "error"] }],
"flowtype/require-valid-file-annotation": [ "flowtype/require-valid-file-annotation": [
2, 2,
"always", { "always",
{
"annotationStyle": "line" "annotationStyle": "line"
} }
], ],
"jsx-a11y/anchor-is-valid": [ "error", { "jsx-a11y/anchor-is-valid": [
"error",
{
"components": ["Link"], "components": ["Link"],
"specialLink": ["to", "hrefLeft", "hrefRight"], "specialLink": ["to", "hrefLeft", "hrefRight"],
"aspects": ["noHref", "invalidHref", "preferButton"] "aspects": ["noHref", "invalidHref", "preferButton"]
}], }
],
"react/require-default-props": 0, "react/require-default-props": 0,
"react/no-array-index-key": 0 "react/no-array-index-key": 0
}, },

20
.github/ISSUE_TEMPLATE/epic.md vendored Normal file
View File

@ -0,0 +1,20 @@
---
name: Epic
about: Create an epic for the Gnosis Safe
---
# What is this feature about? (1 sentence)
# Why is it needed? What is the value? For whom do we build it?
# High-level overview of the feature
-
# Screens
- Link to Google docs with screens. Template can be found on [Google drive](https://drive.google.com/drive/u/0/folders/1d7LH762wUBCSf2Shmb-f2O5OdAaQcOAW)
# Other things
-

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ build_webpack/
build_storybook/ build_storybook/
.DS_Store .DS_Store
build/ build/
yarn-error.log

View File

@ -32,29 +32,31 @@
"dependencies": { "dependencies": {
"@gnosis.pm/safe-contracts": "^1.0.0", "@gnosis.pm/safe-contracts": "^1.0.0",
"@gnosis.pm/util-contracts": "2.0.1", "@gnosis.pm/util-contracts": "2.0.1",
"@material-ui/core": "4.1.3", "@material-ui/core": "4.2.0",
"@material-ui/icons": "4.2.1", "@material-ui/icons": "4.2.1",
"@testing-library/jest-dom": "^4.0.0",
"@welldone-software/why-did-you-render": "3.2.1", "@welldone-software/why-did-you-render": "3.2.1",
"axios": "0.19.0", "axios": "0.19.0",
"bignumber.js": "9.0.0", "bignumber.js": "9.0.0",
"connected-react-router": "^6.3.1", "connected-react-router": "6.5.2",
"final-form": "4.16.1", "date-fns": "1.30.1",
"final-form": "4.18.2",
"history": "^4.7.2", "history": "^4.7.2",
"immortal-db": "^1.0.2", "immortal-db": "^1.0.2",
"immutable": "^4.0.0-rc.9", "immutable": "^4.0.0-rc.9",
"material-ui-search-bar": "^1.0.0-beta.13", "material-ui-search-bar": "^1.0.0-beta.13",
"optimize-css-assets-webpack-plugin": "^5.0.1", "optimize-css-assets-webpack-plugin": "5.0.3",
"qrcode.react": "^0.9.3", "qrcode.react": "^0.9.3",
"react": "^16.8.6", "react": "^16.8.6",
"react-dom": "^16.8.6", "react-dom": "^16.8.6",
"react-final-form": "6.3.0", "react-final-form": "6.3.0",
"react-final-form-listeners": "^1.0.2", "react-final-form-listeners": "^1.0.2",
"react-hot-loader": "4.12.3", "react-hot-loader": "4.12.7",
"react-infinite-scroll-component": "^4.5.2", "react-infinite-scroll-component": "^4.5.2",
"react-redux": "7.1.0", "react-redux": "7.1.0",
"react-router-dom": "^4.3.1", "react-router-dom": "^4.3.1",
"recompose": "^0.30.0", "recompose": "^0.30.0",
"redux": "^4.0.1", "redux": "4.0.4",
"redux-actions": "^2.3.0", "redux-actions": "^2.3.0",
"redux-thunk": "^2.2.0", "redux-thunk": "^2.2.0",
"reselect": "^4.0.0", "reselect": "^4.0.0",
@ -62,27 +64,27 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "7.5.0", "@babel/cli": "7.5.0",
"@babel/core": "7.5.0", "@babel/core": "7.5.4",
"@babel/plugin-proposal-class-properties": "7.5.0", "@babel/plugin-proposal-class-properties": "7.5.0",
"@babel/plugin-proposal-decorators": "7.4.4", "@babel/plugin-proposal-decorators": "7.4.4",
"@babel/plugin-proposal-do-expressions": "^7.0.0", "@babel/plugin-proposal-do-expressions": "7.5.0",
"@babel/plugin-proposal-export-default-from": "^7.0.0", "@babel/plugin-proposal-export-default-from": "7.5.2",
"@babel/plugin-proposal-export-namespace-from": "^7.0.0", "@babel/plugin-proposal-export-namespace-from": "7.5.2",
"@babel/plugin-proposal-function-bind": "^7.0.0", "@babel/plugin-proposal-function-bind": "^7.0.0",
"@babel/plugin-proposal-function-sent": "^7.0.0", "@babel/plugin-proposal-function-sent": "7.5.0",
"@babel/plugin-proposal-json-strings": "^7.0.0", "@babel/plugin-proposal-json-strings": "^7.0.0",
"@babel/plugin-proposal-logical-assignment-operators": "^7.0.0", "@babel/plugin-proposal-logical-assignment-operators": "^7.0.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "7.4.4", "@babel/plugin-proposal-nullish-coalescing-operator": "7.4.4",
"@babel/plugin-proposal-numeric-separator": "^7.0.0", "@babel/plugin-proposal-numeric-separator": "^7.0.0",
"@babel/plugin-proposal-optional-chaining": "^7.0.0", "@babel/plugin-proposal-optional-chaining": "^7.0.0",
"@babel/plugin-proposal-pipeline-operator": "^7.0.0", "@babel/plugin-proposal-pipeline-operator": "7.5.0",
"@babel/plugin-proposal-throw-expressions": "^7.0.0", "@babel/plugin-proposal-throw-expressions": "^7.0.0",
"@babel/plugin-syntax-dynamic-import": "^7.0.0", "@babel/plugin-syntax-dynamic-import": "^7.0.0",
"@babel/plugin-syntax-import-meta": "^7.0.0", "@babel/plugin-syntax-import-meta": "^7.0.0",
"@babel/plugin-transform-member-expression-literals": "^7.2.0", "@babel/plugin-transform-member-expression-literals": "^7.2.0",
"@babel/plugin-transform-property-literals": "^7.2.0", "@babel/plugin-transform-property-literals": "^7.2.0",
"@babel/polyfill": "7.4.4", "@babel/polyfill": "7.4.4",
"@babel/preset-env": "7.5.0", "@babel/preset-env": "7.5.4",
"@babel/preset-flow": "^7.0.0-beta.40", "@babel/preset-flow": "^7.0.0-beta.40",
"@babel/preset-react": "^7.0.0-beta.40", "@babel/preset-react": "^7.0.0-beta.40",
"@sambego/storybook-state": "^1.0.7", "@sambego/storybook-state": "^1.0.7",
@ -90,8 +92,8 @@
"@storybook/addon-knobs": "5.1.9", "@storybook/addon-knobs": "5.1.9",
"@storybook/addon-links": "5.1.9", "@storybook/addon-links": "5.1.9",
"@storybook/react": "5.1.9", "@storybook/react": "5.1.9",
"@testing-library/react": "^8.0.1", "@testing-library/react": "8.0.5",
"autoprefixer": "9.6.0", "autoprefixer": "9.6.1",
"babel-core": "^7.0.0-bridge.0", "babel-core": "^7.0.0-bridge.0",
"babel-eslint": "10.0.2", "babel-eslint": "10.0.2",
"babel-jest": "24.8.0", "babel-jest": "24.8.0",
@ -102,12 +104,12 @@
"classnames": "^2.2.5", "classnames": "^2.2.5",
"css-loader": "3.0.0", "css-loader": "3.0.0",
"detect-port": "^1.2.2", "detect-port": "^1.2.2",
"eslint": "^5.16.0", "eslint": "6.0.1",
"eslint-config-airbnb": "^17.1.0", "eslint-config-airbnb": "17.1.1",
"eslint-plugin-flowtype": "3.11.1", "eslint-plugin-flowtype": "3.11.1",
"eslint-plugin-import": "2.18.0", "eslint-plugin-import": "2.18.0",
"eslint-plugin-jest": "22.7.2", "eslint-plugin-jest": "22.9.0",
"eslint-plugin-jsx-a11y": "^6.0.3", "eslint-plugin-jsx-a11y": "6.2.3",
"eslint-plugin-react": "7.14.2", "eslint-plugin-react": "7.14.2",
"ethereumjs-abi": "^0.6.7", "ethereumjs-abi": "^0.6.7",
"extract-text-webpack-plugin": "^4.0.0-beta.0", "extract-text-webpack-plugin": "^4.0.0-beta.0",
@ -117,25 +119,25 @@
"html-loader": "^0.5.5", "html-loader": "^0.5.5",
"html-webpack-plugin": "^3.0.4", "html-webpack-plugin": "^3.0.4",
"jest": "24.8.0", "jest": "24.8.0",
"jest-dom": "^3.4.0", "jest-dom": "4.0.0",
"json-loader": "^0.5.7", "json-loader": "^0.5.7",
"mini-css-extract-plugin": "0.7.0", "mini-css-extract-plugin": "0.8.0",
"postcss-loader": "^3.0.0", "postcss-loader": "^3.0.0",
"postcss-mixins": "^6.2.0", "postcss-mixins": "^6.2.0",
"postcss-simple-vars": "^5.0.2", "postcss-simple-vars": "^5.0.2",
"pre-commit": "^1.2.2", "pre-commit": "^1.2.2",
"prettier-eslint-cli": "5.0.0", "prettier-eslint-cli": "5.0.0",
"run-with-testrpc": "0.3.1", "run-with-testrpc": "0.3.1",
"storybook-host": "^5.0.3", "storybook-host": "5.1.0",
"storybook-router": "^0.3.3", "storybook-router": "^0.3.3",
"style-loader": "^0.23.1", "style-loader": "^0.23.1",
"truffle": "5.0.26", "truffle": "5.0.27",
"truffle-contract": "4.0.23", "truffle-contract": "4.0.24",
"truffle-solidity-loader": "0.1.25", "truffle-solidity-loader": "0.1.26",
"uglifyjs-webpack-plugin": "2.1.3", "uglifyjs-webpack-plugin": "2.1.3",
"webpack": "4.35.2", "webpack": "4.35.3",
"webpack-bundle-analyzer": "3.3.2", "webpack-bundle-analyzer": "3.3.2",
"webpack-cli": "3.3.5", "webpack-cli": "3.3.6",
"webpack-dev-server": "3.7.2", "webpack-dev-server": "3.7.2",
"webpack-manifest-plugin": "^2.0.0-rc.2" "webpack-manifest-plugin": "^2.0.0-rc.2"
} }

View File

@ -0,0 +1,31 @@
// @flow
import React from 'react'
import { connect } from 'react-redux'
import OpenInNew from '@material-ui/icons/OpenInNew'
import { getEtherScanLink } from '~/logic/wallets/getWeb3'
import { shortVersionOf } from '~/logic/wallets/ethAddresses'
import { secondary } from '~/theme/variables'
import { networkSelector } from '~/logic/wallets/store/selectors'
const openIconStyle = {
height: '13px',
color: secondary,
}
type EtherscanLinkProps = {
type: 'tx' | 'address',
value: string,
currentNetwork: string,
}
const EtherscanLink = ({ type, value, currentNetwork }: EtherscanLinkProps) => (
<a href={getEtherScanLink(type, value, currentNetwork)} target="_blank" rel="noopener noreferrer">
{shortVersionOf(value, 4)}
<OpenInNew style={openIconStyle} />
</a>
)
export default connect<Object, Object, ?Function, ?Object>(
state => ({ currentNetwork: networkSelector(state) }),
null,
)(EtherscanLink)

View File

@ -9,7 +9,7 @@ import styles from './index.scss'
const Footer = () => ( const Footer = () => (
<Block className={styles.footer}> <Block className={styles.footer}>
<Link to={WELCOME_ADDRESS}> <Link to={WELCOME_ADDRESS}>
<Paragraph size="sm" color="primary" noMargin>Welcome</Paragraph> <Paragraph size="sm" color="primary" noMargin>Add Safe</Paragraph>
</Link> </Link>
<Link to={SAFELIST_ADDRESS}> <Link to={SAFELIST_ADDRESS}>
<Paragraph size="sm" color="primary" noMargin>Safe List</Paragraph> <Paragraph size="sm" color="primary" noMargin>Safe List</Paragraph>

View File

@ -1,8 +1,8 @@
// @flow // @flow
import * as React from 'react' import * as React from 'react'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
import Block from '~/components/layout/Block'
import Dot from '@material-ui/icons/FiberManualRecord' import Dot from '@material-ui/icons/FiberManualRecord'
import Block from '~/components/layout/Block'
import Img from '~/components/layout/Img' import Img from '~/components/layout/Img'
import { fancy, border, warning } from '~/theme/variables' import { fancy, border, warning } from '~/theme/variables'

View File

@ -3,11 +3,11 @@ import * as React from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import OpenInNew from '@material-ui/icons/OpenInNew' import OpenInNew from '@material-ui/icons/OpenInNew'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
import Dot from '@material-ui/icons/FiberManualRecord'
import Paragraph from '~/components/layout/Paragraph' import Paragraph from '~/components/layout/Paragraph'
import Link from '~/components/layout/Link' import Link from '~/components/layout/Link'
import Button from '~/components/layout/Button' import Button from '~/components/layout/Button'
import Identicon from '~/components/Identicon' import Identicon from '~/components/Identicon'
import Dot from '@material-ui/icons/FiberManualRecord'
import Hairline from '~/components/layout/Hairline' import Hairline from '~/components/layout/Hairline'
import Img from '~/components/layout/Img' import Img from '~/components/layout/Img'
import Row from '~/components/layout/Row' import Row from '~/components/layout/Row'
@ -123,7 +123,7 @@ const UserDetails = ({
{address} {address}
</Paragraph> </Paragraph>
{userAddress && ( {userAddress && (
<Link className={classes.open} to={getEtherScanLink(userAddress, network)} target="_blank"> <Link className={classes.open} to={getEtherScanLink('address', userAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
</Link> </Link>
)} )}

View File

@ -5,11 +5,18 @@ import { toDataUrl } from './blockies'
type Props = { type Props = {
address: string, address: string,
diameter: number, diameter: number,
className?: string,
} }
type IdenticonRef = { current: null | HTMLDivElement } type IdenticonRef = { current: null | HTMLDivElement }
export default class Identicon extends React.PureComponent<Props> { export default class Identicon extends React.PureComponent<Props> {
static defaultProps = {
className: '',
}
identicon: IdenticonRef
constructor(props: Props) { constructor(props: Props) {
super(props) super(props)
@ -55,13 +62,13 @@ export default class Identicon extends React.PureComponent<Props> {
return image return image
} }
identicon: IdenticonRef
render() { render() {
const style = this.getStyleFrom(this.props.diameter) const { diameter, className } = this.props
const style = this.getStyleFrom(diameter)
return ( return (
<div style={style} ref={this.identicon} /> <div className={className} style={style} ref={this.identicon} />
) )
} }
} }

View File

@ -21,6 +21,7 @@ type Props<K> = {
children: Function, children: Function,
size: number, size: number,
defaultFixed?: boolean, defaultFixed?: boolean,
defaultOrder?: 'desc' | 'asc',
noBorder: boolean, noBorder: boolean,
} }
@ -59,21 +60,48 @@ const styles = {
const FIXED_HEIGHT = 49 const FIXED_HEIGHT = 49
const backProps = {
'aria-label': 'Previous Page',
}
const nextProps = {
'aria-label': 'Next Page',
}
class GnoTable<K> extends React.Component<Props<K>, State> { class GnoTable<K> extends React.Component<Props<K>, State> {
state = { state = {
page: 0, page: 0,
order: 'asc', order: undefined,
orderBy: undefined, orderBy: undefined,
fixed: undefined, fixed: undefined,
orderProp: false, orderProp: false,
rowsPerPage: 5, rowsPerPage: 5,
} }
componentDidMount() {
const { defaultOrderBy, columns } = this.props
if (defaultOrderBy && columns) {
const defaultOrderCol = columns.find(({ id }) => id === defaultOrderBy)
if (defaultOrderCol.order) {
this.setState({
orderProp: true,
})
}
}
}
onSort = (newOrderBy: string, orderProp: boolean) => { onSort = (newOrderBy: string, orderProp: boolean) => {
const { order, orderBy } = this.state const { order, orderBy } = this.state
const { defaultOrder } = this.props
let newOrder = 'desc' let newOrder = 'desc'
if (orderBy === newOrderBy && order === 'desc') { // if table was previously sorted by the user
if (order && orderBy === newOrderBy && order === 'desc') {
newOrder = 'asc'
} else if (!order && defaultOrder === 'desc') {
// if it was not sorted and defaultOrder is used
newOrder = 'asc' newOrder = 'asc'
} }
@ -100,29 +128,22 @@ class GnoTable<K> extends React.Component<Props<K>, State> {
render() { render() {
const { const {
data, label, columns, classes, children, size, defaultOrderBy, defaultFixed, noBorder, data, label, columns, classes, children, size, defaultOrderBy, defaultOrder, defaultFixed, noBorder,
} = this.props } = this.props
const { const {
order, orderBy, page, orderProp, rowsPerPage, fixed, order, orderBy, page, orderProp, rowsPerPage, fixed,
} = this.state } = this.state
const orderByParam = orderBy || defaultOrderBy const orderByParam = orderBy || defaultOrderBy
const orderParam = order || defaultOrder
const fixedParam = typeof fixed !== 'undefined' ? fixed : !!defaultFixed const fixedParam = typeof fixed !== 'undefined' ? fixed : !!defaultFixed
const backProps = {
'aria-label': 'Previous Page',
}
const nextProps = {
'aria-label': 'Next Page',
}
const paginationClasses = { const paginationClasses = {
selectRoot: classes.selectRoot, selectRoot: classes.selectRoot,
root: !noBorder && classes.paginationRoot, root: !noBorder && classes.paginationRoot,
input: classes.white, input: classes.white,
} }
const sortedData = stableSort(data, getSorting(order, orderByParam, orderProp), fixedParam).slice( const sortedData = stableSort(data, getSorting(orderParam, orderByParam, orderProp), fixedParam).slice(
page * rowsPerPage, page * rowsPerPage,
page * rowsPerPage + rowsPerPage, page * rowsPerPage + rowsPerPage,
) )
@ -163,4 +184,8 @@ class GnoTable<K> extends React.Component<Props<K>, State> {
} }
} }
GnoTable.defaultProps = {
defaultOrder: 'asc',
}
export default withStyles(styles)(GnoTable) export default withStyles(styles)(GnoTable)

View File

@ -1,31 +1,27 @@
// @flow // @flow
import * as React from 'react' import * as React from 'react'
import Button from '@material-ui/core/Button' import Button from '@material-ui/core/Button'
import { withStyles } from '@material-ui/core/styles'
const styles = {
root: {
borderRadius: 0,
},
}
type Props = { type Props = {
minWidth?: number, minWidth?: number,
minHeight?: number, minHeight?: number,
testId: string, rounded?: boolean,
testId?: string,
style?: Object,
} }
const calculateStyleBased = (minWidth, minHeight) => ({ const calculateStyleBased = (minWidth, minHeight, rounded) => ({
minWidth: minWidth && `${minWidth}px`, minWidth: minWidth && `${minWidth}px`,
minHeight: minHeight && `${minHeight}px`, minHeight: minHeight && `${minHeight}px`,
borderRadius: rounded ? '4px' : 0,
}) })
const GnoButton = ({ const GnoButton = ({
minWidth, minHeight, testId = '', ...props minWidth, minHeight = 27, testId = '', rounded, style = {}, ...props
}: Props) => { }: Props) => {
const style = calculateStyleBased(minWidth, minHeight) const calculatedStyle = calculateStyleBased(minWidth, minHeight, rounded)
return <Button style={style} data-testid={testId} {...props} /> return <Button style={{ ...calculatedStyle, ...style }} data-testid={testId} {...props} />
} }
export default withStyles(styles)(GnoButton) export default GnoButton

View File

@ -7,7 +7,7 @@ import styles from './index.scss'
const cx = cn.bind(styles) const cx = cn.bind(styles)
type Props = { type Props = {
type: 'button' | 'submit' | 'reset', type?: 'button' | 'submit' | 'reset',
size?: 'sm' | 'md' | 'lg' | 'xl' | 'xxl', size?: 'sm' | 'md' | 'lg' | 'xl' | 'xxl',
weight?: 'light' | 'regular' | 'bolder' | 'bold', weight?: 'light' | 'regular' | 'bolder' | 'bold',
color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled' | 'error', color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled' | 'error',

View File

@ -1,19 +1,15 @@
// @flow // @flow
import React, { PureComponent } from 'react' import * as React from 'react'
type Props = { type Props = {
children: React.Node children: React.Node,
} }
class Span extends PureComponent<Props> { class Span extends React.PureComponent<Props> {
render() { render() {
const { children, ...props } = this.props const { children, ...props } = this.props
return ( return <span {...props}>{children}</span>
<span {...props}>
{ children }
</span>
)
} }
} }

View File

@ -7,10 +7,10 @@ import {
} from '~/config/names' } from '~/config/names'
const devConfig = { const devConfig = {
[TX_SERVICE_HOST]: 'http://localhost:8000/api/v1/', [TX_SERVICE_HOST]: 'https://safe-transaction-service.dev.gnosisdev.com/api/v1/',
[ENABLED_TX_SERVICE_REMOVAL_SENDER]: false, [ENABLED_TX_SERVICE_REMOVAL_SENDER]: false,
[SIGNATURES_VIA_METAMASK]: false, [SIGNATURES_VIA_METAMASK]: false,
[RELAY_API_URL]: 'https://safe-relay.staging.gnosisdev.com/api/v1', [RELAY_API_URL]: 'https://safe-relay.staging.gnosisdev.com/api/v1/',
} }
export default devConfig export default devConfig

View File

@ -7,10 +7,10 @@ import {
} from '~/config/names' } from '~/config/names'
const prodConfig = { const prodConfig = {
[TX_SERVICE_HOST]: 'https://safe-transaction-history.dev.gnosisdev.com/api/v1/', [TX_SERVICE_HOST]: 'https://safe-transaction-service.dev.gnosisdev.com/api/v1/',
[ENABLED_TX_SERVICE_REMOVAL_SENDER]: false, [ENABLED_TX_SERVICE_REMOVAL_SENDER]: false,
[SIGNATURES_VIA_METAMASK]: false, [SIGNATURES_VIA_METAMASK]: false,
[RELAY_API_URL]: 'https://safe-relay.staging.gnosisdev.com/api/v1', [RELAY_API_URL]: 'https://safe-relay.staging.gnosisdev.com/api/v1/',
} }
export default prodConfig export default prodConfig

View File

@ -7,7 +7,7 @@ import {
} from '~/config/names' } from '~/config/names'
const testConfig = { const testConfig = {
[TX_SERVICE_HOST]: 'http://localhost:8000/api/v1/', [TX_SERVICE_HOST]: 'https://safe-transaction-service.dev.gnosisdev.com/api/v1/',
[ENABLED_TX_SERVICE_REMOVAL_SENDER]: false, [ENABLED_TX_SERVICE_REMOVAL_SENDER]: false,
[SIGNATURES_VIA_METAMASK]: false, [SIGNATURES_VIA_METAMASK]: false,
[RELAY_API_URL]: 'https://safe-relay.staging.gnosisdev.com/api/v1', [RELAY_API_URL]: 'https://safe-relay.staging.gnosisdev.com/api/v1',

View File

@ -0,0 +1,85 @@
// @flow
import { getWeb3 } from '~/logic/wallets/getWeb3'
// SAFE METHODS TO ITS ID
// https://github.com/gnosis/safe-contracts/blob/development/test/safeMethodNaming.js
// https://github.com/gnosis/safe-contracts/blob/development/contracts/GnosisSafe.sol
// [
// { name: "addOwnerWithThreshold", id: "0x0d582f13" },
// { name: "DOMAIN_SEPARATOR_TYPEHASH", id: "0x1db61b54" },
// { name: "isOwner", id: "0x2f54bf6e" },
// { name: "execTransactionFromModule", id: "0x468721a7" },
// { name: "signedMessages", id: "0x5ae6bd37" },
// { name: "enableModule", id: "0x610b5925" },
// { name: "changeThreshold", id: "0x694e80c3" },
// { name: "approvedHashes", id: "0x7d832974" },
// { name: "changeMasterCopy", id: "0x7de7edef" },
// { name: "SENTINEL_MODULES", id: "0x85e332cd" },
// { name: "SENTINEL_OWNERS", id: "0x8cff6355" },
// { name: "getOwners", id: "0xa0e67e2b" },
// { name: "NAME", id: "0xa3f4df7e" },
// { name: "nonce", id: "0xaffed0e0" },
// { name: "getModules", id: "0xb2494df3" },
// { name: "SAFE_MSG_TYPEHASH", id: "0xc0856ffc" },
// { name: "SAFE_TX_TYPEHASH", id: "0xccafc387" },
// { name: "disableModule", id: "0xe009cfde" },
// { name: "swapOwner", id: "0xe318b52b" },
// { name: "getThreshold", id: "0xe75235b8" },
// { name: "domainSeparator", id: "0xf698da25" },
// { name: "removeOwner", id: "0xf8dc5dd9" },
// { name: "VERSION", id: "0xffa1ad74" },
// { name: "setup", id: "0xa97ab18a" },
// { name: "execTransaction", id: "0x6a761202" },
// { name: "requiredTxGas", id: "0xc4ca3a9c" },
// { name: "approveHash", id: "0xd4d9bdcd" },
// { name: "signMessage", id: "0x85a5affe" },
// { name: "isValidSignature", id: "0x20c13b0b" },
// { name: "getMessageHash", id: "0x0a1028c4" },
// { name: "encodeTransactionData", id: "0xe86637db" },
// { name: "getTransactionHash", id: "0xd8d11f78" }
// ]
const METHOD_TO_ID = {
'0xe318b52b': 'swapOwner',
'0x0d582f13': 'addOwnerWithThreshold',
'0xf8dc5dd9': 'removeOwner',
'0x694e80c3': 'changeThreshold',
}
export const decodeParamsFromSafeMethod = async (data: string) => {
const web3 = await getWeb3()
const [methodId, params] = [data.slice(0, 10), data.slice(10)]
switch (methodId) {
// swapOwner
case '0xe318b52b':
return {
methodName: METHOD_TO_ID[methodId],
args: web3.eth.abi.decodeParameters(['address', 'address', 'address'], params),
}
// addOwnerWithThreshold
case '0x0d582f13':
return {
methodName: METHOD_TO_ID[methodId],
args: web3.eth.abi.decodeParameters(['address', 'uint'], params),
}
// removeOwner
case '0xf8dc5dd9':
return {
methodName: METHOD_TO_ID[methodId],
args: web3.eth.abi.decodeParameters(['address', 'address', 'uint'], params),
}
// changeThreshold
case '0x694e80c3':
return {
methodName: METHOD_TO_ID[methodId],
args: web3.eth.abi.decodeParameters(['uint'], params),
}
default:
return {}
}
}

View File

@ -1,7 +1,7 @@
// @flow // @flow
import contract from 'truffle-contract' import contract from 'truffle-contract'
import GnosisSafeSol from '@gnosis.pm/safe-contracts/build/contracts/GnosisSafe.json'
import ProxyFactorySol from '@gnosis.pm/safe-contracts/build/contracts/ProxyFactory.json' import ProxyFactorySol from '@gnosis.pm/safe-contracts/build/contracts/ProxyFactory.json'
import GnosisSafeSol from '@gnosis.pm/safe-contracts/build/contracts/GnosisSafe.json'
import { ensureOnce } from '~/utils/singleton' import { ensureOnce } from '~/utils/singleton'
import { getWeb3 } from '~/logic/wallets/getWeb3' import { getWeb3 } from '~/logic/wallets/getWeb3'
import { calculateGasOf, calculateGasPrice } from '~/logic/wallets/ethTransactions' import { calculateGasOf, calculateGasPrice } from '~/logic/wallets/ethTransactions'

View File

@ -1,88 +0,0 @@
// @flow
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { getTxServiceUriFrom, getTxServiceHost } from '~/config'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
export type TxServiceType = 'confirmation' | 'execution' | 'initialised'
export type Operation = 0 | 1 | 2
const calculateBodyFrom = async (
safeAddress: string,
to: string,
valueInWei: number,
data: string,
operation: Operation,
nonce: number,
transactionHash: string,
sender: string,
type: TxServiceType,
) => {
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
const contractTransactionHash = await gnosisSafe.getTransactionHash(
to,
valueInWei,
data,
operation,
0,
0,
0,
0,
0,
nonce,
)
return JSON.stringify({
to: getWeb3().toChecksumAddress(to),
value: valueInWei,
data,
operation,
nonce,
safeTxGas: 0,
dataGas: 0,
gasPrice: 0,
gasToken: null,
refundReceiver: null,
contractTransactionHash,
transactionHash,
sender: getWeb3().toChecksumAddress(sender),
type,
})
}
export const buildTxServiceUrlFrom = (safeAddress: string) => {
const host = getTxServiceHost()
const address = getWeb3().toChecksumAddress(safeAddress)
const base = getTxServiceUriFrom(address)
return `${host}${base}`
}
export const submitOperation = async (
safeAddress: string,
to: string,
valueInWei: number,
data: string,
operation: Operation,
nonce: number,
txHash: string,
sender: string,
type: TxServiceType,
) => {
const url = buildTxServiceUrlFrom(safeAddress)
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
}
const body = await calculateBodyFrom(safeAddress, to, valueInWei, data, operation, nonce, txHash, sender, type)
const response = await fetch(url, {
method: 'POST',
headers,
body,
})
if (response.status !== 202) {
return Promise.reject(new Error('Error submitting the transaction'))
}
return Promise.resolve()
}

View File

@ -3,3 +3,4 @@ export * from './gas'
export * from './send' export * from './send'
export * from './safeBlockchainOperations' export * from './safeBlockchainOperations'
export * from './safeTxSignerEIP712' export * from './safeTxSignerEIP712'
export * from './txHistory'

View File

@ -1,60 +1,14 @@
// @flow // // @flow
import { List } from 'immutable' // import { List } from 'immutable'
import { calculateGasOf, checkReceiptStatus, calculateGasPrice } from '~/logic/wallets/ethTransactions' // import { calculateGasOf, checkReceiptStatus, calculateGasPrice } from '~/logic/wallets/ethTransactions'
import { type Operation, submitOperation } from '~/logic/safe/safeTxHistory' // import { type Operation, saveTxToHistory } from '~/logic/safe/transactions'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts' // import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { buildSignaturesFrom } from '~/logic/safe/safeTxSigner' // import { buildSignaturesFrom } from '~/logic/safe/safeTxSigner'
import { generateMetamaskSignature, generateTxGasEstimateFrom, estimateDataGas } from '~/logic/safe/transactions' // import { generateMetamaskSignature, generateTxGasEstimateFrom, estimateDataGas } from '~/logic/safe/transactions'
import { storeSignature, getSignaturesFrom } from '~/utils/storage/signatures' // import { storeSignature, getSignaturesFrom } from '~/utils/storage/signatures'
import { signaturesViaMetamask } from '~/config' // import { signaturesViaMetamask } from '~/config'
export const approveTransaction = async ( // export const approveTransaction = async (
safeAddress: string,
to: string,
valueInWei: number,
data: string,
operation: Operation,
nonce: number,
sender: string,
) => {
const gasPrice = await calculateGasPrice()
if (signaturesViaMetamask()) {
// return executeTransaction(safeAddress, to, valueInWei, data, operation, nonce, sender)
const safe = await getGnosisSafeInstanceAt(safeAddress)
const txGasEstimate = await generateTxGasEstimateFrom(safe, safeAddress, data, to, valueInWei, operation)
const signature = await generateMetamaskSignature(
safe,
safeAddress,
sender,
to,
valueInWei,
nonce,
data,
operation,
txGasEstimate,
)
storeSignature(safeAddress, nonce, signature)
return undefined
}
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
const contractTxHash = await gnosisSafe.getTransactionHash(to, valueInWei, data, operation, 0, 0, 0, 0, 0, nonce)
const approveData = gnosisSafe.contract.methods.approveHash(contractTxHash).encodeABI()
const gas = await calculateGasOf(approveData, sender, safeAddress)
const txReceipt = await gnosisSafe.approveHash(contractTxHash, { from: sender, gas, gasPrice })
const txHash = txReceipt.tx
await checkReceiptStatus(txHash)
await submitOperation(safeAddress, to, valueInWei, data, operation, nonce, txHash, sender, 'confirmation')
return txHash
}
// export const executeTransaction = async (
// safeAddress: string, // safeAddress: string,
// to: string, // to: string,
// valueInWei: number, // valueInWei: number,
@ -62,17 +16,17 @@ export const approveTransaction = async (
// operation: Operation, // operation: Operation,
// nonce: number, // nonce: number,
// sender: string, // sender: string,
// ownersWhoHasSigned: List<string>,
// ) => { // ) => {
// const gasPrice = await calculateGasPrice() // const gasPrice = await calculateGasPrice()
// if (signaturesViaMetamask()) { // if (signaturesViaMetamask()) {
// const safe = await getSafeEthereumInstance(safeAddress) // // return executeTransaction(safeAddress, to, valueInWei, data, operation, nonce, sender)
// const safe = await getGnosisSafeInstanceAt(safeAddress)
// const txGasEstimate = await generateTxGasEstimateFrom(safe, safeAddress, data, to, valueInWei, operation) // const txGasEstimate = await generateTxGasEstimateFrom(safe, safeAddress, data, to, valueInWei, operation)
// const signature = await generateMetamaskSignature( // const signature = await generateMetamaskSignature(
// safe, // safe,
// safeAddress, // safeAddress,
// sender, // // sender
// to, // to,
// valueInWei, // valueInWei,
// nonce, // nonce,
@ -82,61 +36,107 @@ export const approveTransaction = async (
// ) // )
// storeSignature(safeAddress, nonce, signature) // storeSignature(safeAddress, nonce, signature)
// const sigs = getSignaturesFrom(safeAddress, nonce) // return undefined
// const threshold = await safe.getThreshold() // }
// const gas = await estimateDataGas(
// safe,
// to,
// valueInWei,
// data,
// operation,
// txGasEstimate,
// 0,
// nonce,
// Number(threshold),
// 0,
// )
// const numOwners = await safe.getOwners()
// const gasIncludingRemovingStoreUpfront = gas + txGasEstimate + numOwners.length * 15000
// const txReceipt = await safe.execTransaction( // const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
// to, // const contractTxHash = await gnosisSafe.getTransactionHash(to, valueInWei, data, operation, 0, 0, 0, 0, 0, nonce)
// valueInWei,
// data, // const approveData = gnosisSafe.contract.methods.approveHash(contractTxHash).encodeABI()
// operation, // const gas = await calculateGasOf(approveData, sender, safeAddress)
// txGasEstimate, // const txReceipt = await gnosisSafe.approveHash(contractTxHash, { from: sender, gas, gasPrice })
// 0, // dataGasEstimate
// 0, // gasPrice
// 0, // txGasToken
// 0, // refundReceiver
// sigs,
// { from: sender, gas: gasIncludingRemovingStoreUpfront, gasPrice },
// )
// const txHash = txReceipt.tx // const txHash = txReceipt.tx
// await checkReceiptStatus(txHash) // await checkReceiptStatus(txHash)
// await saveTxToHistory(safeAddress, to, valueInWei, data, operation, nonce, txHash, sender, 'confirmation')
// return txHash
// }
// // export const executeTransaction = async (
// // safeAddress: string,
// // to: string,
// // valueInWei: number,
// // data: string,
// // operation: Operation,
// // nonce: number,
// // sender: string,
// // ownersWhoHasSigned: List<string>,
// // ) => {
// // const gasPrice = await calculateGasPrice()
// // if (signaturesViaMetamask()) {
// // const safe = await getSafeEthereumInstance(safeAddress)
// // const txGasEstimate = await generateTxGasEstimateFrom(safe, safeAddress, data, to, valueInWei, operation)
// // const signature = await generateMetamaskSignature(
// // safe,
// // safeAddress,
// // sender,
// // to,
// // valueInWei,
// // nonce,
// // data,
// // operation,
// // txGasEstimate,
// // )
// // storeSignature(safeAddress, nonce, signature)
// // const sigs = getSignaturesFrom(safeAddress, nonce)
// // const threshold = await safe.getThreshold()
// // const gas = await estimateDataGas(
// // safe,
// // to,
// // valueInWei,
// // data,
// // operation,
// // txGasEstimate,
// // 0,
// // nonce,
// // Number(threshold),
// // 0,
// // )
// // const numOwners = await safe.getOwners()
// // const gasIncludingRemovingStoreUpfront = gas + txGasEstimate + numOwners.length * 15000
// // const txReceipt = await safe.execTransaction(
// // to,
// // valueInWei,
// // data,
// // operation,
// // txGasEstimate,
// // 0, // dataGasEstimate
// // 0, // gasPrice
// // 0, // txGasToken
// // 0, // refundReceiver
// // sigs,
// // { from: sender, gas: gasIncludingRemovingStoreUpfront, gasPrice },
// // )
// // const txHash = txReceipt.tx
// // await checkReceiptStatus(txHash)
// // // await submitOperation(safeAddress, to, valueInWei, data, operation, nonce, txHash, sender, 'execution')
// // return txHash
// // }
// // const gnosisSafe = await getSafeEthereumInstance(safeAddress)
// // const signatures = buildSignaturesFrom(ownersWhoHasSigned, sender)
// // const txExecutionData = gnosisSafe.contract.methods
// // .execTransaction(to, valueInWei, data, operation, 0, 0, 0, 0, 0, signatures)
// // .encodeABI()
// // const gas = await calculateGasOf(txExecutionData, sender, safeAddress)
// // const numOwners = await gnosisSafe.getOwners()
// // const gasIncludingRemovingStoreUpfront = gas + numOwners.length * 15000
// // const txReceipt = await gnosisSafe.execTransaction(to, valueInWei, data, operation, 0, 0, 0, 0, 0, signatures, {
// // from: sender,
// // gas: gasIncludingRemovingStoreUpfront,
// // gasPrice,
// // })
// // const txHash = txReceipt.tx
// // await checkReceiptStatus(txHash)
// // await submitOperation(safeAddress, to, valueInWei, data, operation, nonce, txHash, sender, 'execution') // // await submitOperation(safeAddress, to, valueInWei, data, operation, nonce, txHash, sender, 'execution')
// return txHash // // return txHash
// } // // }
// const gnosisSafe = await getSafeEthereumInstance(safeAddress)
// const signatures = buildSignaturesFrom(ownersWhoHasSigned, sender)
// const txExecutionData = gnosisSafe.contract.methods
// .execTransaction(to, valueInWei, data, operation, 0, 0, 0, 0, 0, signatures)
// .encodeABI()
// const gas = await calculateGasOf(txExecutionData, sender, safeAddress)
// const numOwners = await gnosisSafe.getOwners()
// const gasIncludingRemovingStoreUpfront = gas + numOwners.length * 15000
// const txReceipt = await gnosisSafe.execTransaction(to, valueInWei, data, operation, 0, 0, 0, 0, 0, signatures, {
// from: sender,
// gas: gasIncludingRemovingStoreUpfront,
// gasPrice,
// })
// const txHash = txReceipt.tx
// await checkReceiptStatus(txHash)
// await submitOperation(safeAddress, to, valueInWei, data, operation, nonce, txHash, sender, 'execution')
// return txHash
// }

View File

@ -5,31 +5,83 @@ import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import { isEther } from '~/logic/tokens/utils/tokenHelpers' import { isEther } from '~/logic/tokens/utils/tokenHelpers'
import { type Token } from '~/logic/tokens/store/model/token' import { type Token } from '~/logic/tokens/store/model/token'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts' import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { type Operation, saveTxToHistory } from '~/logic/safe/transactions'
import { ZERO_ADDRESS } from '~/logic/wallets/ethAddresses'
import { getErrorMessage } from '~/test/utils/ethereumErrors'
export const CALL = 0 export const CALL = 0
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' export const TX_TYPE_EXECUTION = 'execution'
export const TX_TYPE_CONFIRMATION = 'confirmation'
export const approveTransaction = async (
safeInstance: any,
to: string,
valueInWei: number | string,
data: string,
operation: Operation,
nonce: number,
sender: string,
) => {
const contractTxHash = await safeInstance.getTransactionHash(
to,
valueInWei,
data,
operation,
0,
0,
0,
ZERO_ADDRESS,
ZERO_ADDRESS,
nonce,
{
from: sender,
},
)
const receipt = await safeInstance.approveHash(contractTxHash, { from: sender })
if (process.env.NODE_ENV !== 'test') {
await saveTxToHistory(
safeInstance,
to,
valueInWei,
data,
operation,
nonce,
receipt.tx, // tx hash,
sender,
TX_TYPE_CONFIRMATION,
)
}
return receipt
}
export const executeTransaction = async ( export const executeTransaction = async (
safeInstance: any, safeInstance: any,
to: string, to: string,
valueInWei: number | string, valueInWei: number | string,
data: string, data: string,
operation: number | string, operation: Operation,
nonce: string | number, nonce: string | number,
sender: string, sender: string,
signatures?: string,
) => { ) => {
try { let sigs = signatures
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures // https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
const sigs = `0x000000000000000000000000${sender.replace( if (!sigs) {
sigs = `0x000000000000000000000000${sender.replace(
'0x', '0x',
'', '',
)}000000000000000000000000000000000000000000000000000000000000000001` )}000000000000000000000000000000000000000000000000000000000000000001`
}
const tx = await safeInstance.execTransaction( try {
const receipt = await safeInstance.execTransaction(
to, to,
valueInWei, valueInWei,
data, data,
CALL, operation,
0, 0,
0, 0,
0, 0,
@ -39,10 +91,31 @@ export const executeTransaction = async (
{ from: sender }, { from: sender },
) )
return tx if (process.env.NODE_ENV !== 'test') {
await saveTxToHistory(
safeInstance,
to,
valueInWei,
data,
operation,
nonce,
receipt.tx, // tx hash,
sender,
TX_TYPE_EXECUTION,
)
}
return receipt
} catch (error) { } catch (error) {
// eslint-disable-next-line /* eslint-disable */
console.log('Error executing the TX: ' + error) const executeDataUsedSignatures = safeInstance.contract.methods
.execTransaction(to, valueInWei, data, operation, 0, 0, 0, ZERO_ADDRESS, ZERO_ADDRESS, sigs)
.encodeABI()
const errMsg = await getErrorMessage(safeInstance.address, 0, executeDataUsedSignatures, sender)
console.log(`Error executing the TX: ${error}`)
console.log(`Error executing the TX: ${errMsg}`)
/* eslint-enable */
return 0 return 0
} }
} }
@ -52,7 +125,7 @@ export const createTransaction = async (safeAddress: string, to: string, valueIn
const web3 = getWeb3() const web3 = getWeb3()
const from = web3.currentProvider.selectedAddress const from = web3.currentProvider.selectedAddress
const threshold = await safeInstance.getThreshold() const threshold = await safeInstance.getThreshold()
const nonce = await safeInstance.nonce() const nonce = (await safeInstance.nonce()).toString()
const valueInWei = web3.utils.toWei(valueInEth, 'ether') const valueInWei = web3.utils.toWei(valueInEth, 'ether')
const isExecution = threshold.toNumber() === 1 const isExecution = threshold.toNumber() === 1

View File

@ -0,0 +1,79 @@
// @flow
import axios from 'axios'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { getTxServiceUriFrom, getTxServiceHost } from '~/config'
import { ZERO_ADDRESS } from '~/logic/wallets/ethAddresses'
export type TxServiceType = 'confirmation' | 'execution' | 'initialised'
export type Operation = 0 | 1 | 2
const calculateBodyFrom = async (
safeInstance: any,
to: string,
valueInWei: number | string,
data: string,
operation: Operation,
nonce: string | number,
transactionHash: string,
sender: string,
confirmationType: TxServiceType,
) => {
const contractTransactionHash = await safeInstance.getTransactionHash(
to,
valueInWei,
data,
operation,
0,
0,
0,
ZERO_ADDRESS,
ZERO_ADDRESS,
nonce,
)
return {
to: getWeb3().utils.toChecksumAddress(to),
value: valueInWei,
data,
operation,
nonce,
safeTxGas: 0,
baseGas: 0,
gasPrice: 0,
gasToken: ZERO_ADDRESS,
refundReceiver: ZERO_ADDRESS,
contractTransactionHash,
transactionHash,
sender: getWeb3().utils.toChecksumAddress(sender),
confirmationType,
}
}
export const buildTxServiceUrl = (safeAddress: string) => {
const host = getTxServiceHost()
const address = getWeb3().utils.toChecksumAddress(safeAddress)
const base = getTxServiceUriFrom(address)
return `${host}${base}`
}
export const saveTxToHistory = async (
safeInstance: any,
to: string,
valueInWei: number | string,
data: string,
operation: Operation,
nonce: number | string,
txHash: string,
sender: string,
type: TxServiceType,
) => {
const url = buildTxServiceUrl(safeInstance.address)
const body = await calculateBodyFrom(safeInstance, to, valueInWei, data, operation, nonce, txHash, sender, type)
const response = await axios.post(url, body)
if (response.status !== 202) {
return Promise.reject(new Error('Error submitting the transaction'))
}
return Promise.resolve()
}

View File

@ -36,13 +36,13 @@ export const setOwners = async (safeAddress: string, owners: List<Owner>) => {
} }
} }
export const getOwners = async (safeAddress: string): Map<string, string> => { export const getOwners = async (safeAddress: string): Promise<Map<string, string>> => {
const data: Object = await loadFromStorage(`${OWNERS_KEY}-${safeAddress}`) const data: Object = await loadFromStorage(`${OWNERS_KEY}-${safeAddress}`)
return data ? Map(data) : Map() return data ? Map(data) : Map()
} }
export const removeOwners = async (safeAddress: string) => { export const removeOwners = async (safeAddress: string): Promise<void> => {
try { try {
await removeFromStorage(`${OWNERS_KEY}-${safeAddress}`) await removeFromStorage(`${OWNERS_KEY}-${safeAddress}`)
} catch (err) { } catch (err) {

View File

@ -5,7 +5,7 @@ import type { Dispatch as ReduxDispatch } from 'redux'
import StandardToken from '@gnosis.pm/util-contracts/build/contracts/GnosisStandardToken.json' import StandardToken from '@gnosis.pm/util-contracts/build/contracts/GnosisStandardToken.json'
import HumanFriendlyToken from '@gnosis.pm/util-contracts/build/contracts/HumanFriendlyToken.json' import HumanFriendlyToken from '@gnosis.pm/util-contracts/build/contracts/HumanFriendlyToken.json'
import { getWeb3 } from '~/logic/wallets/getWeb3' import { getWeb3 } from '~/logic/wallets/getWeb3'
import { type GlobalState } from '~/store/index' import { type GlobalState } from '~/store'
import { makeToken, type TokenProps } from '~/logic/tokens/store/model/token' import { makeToken, type TokenProps } from '~/logic/tokens/store/model/token'
import { fetchTokenList } from '~/logic/tokens/api' import { fetchTokenList } from '~/logic/tokens/api'
import { ensureOnce } from '~/utils/singleton' import { ensureOnce } from '~/utils/singleton'

View File

@ -1,7 +1,8 @@
// @flow // @flow
import { List } from 'immutable' import { List } from 'immutable'
import logo from '~/assets/icons/icon_etherTokens.svg' import { getWeb3 } from '~/logic/wallets/getWeb3'
import { makeToken, type Token } from '~/logic/tokens/store/model/token' import { makeToken, type Token } from '~/logic/tokens/store/model/token'
import logo from '~/assets/icons/icon_etherTokens.svg'
export const ETH_ADDRESS = '0x000' export const ETH_ADDRESS = '0x000'
export const isEther = (symbol: string) => symbol === 'ETH' export const isEther = (symbol: string) => symbol === 'ETH'
@ -31,3 +32,19 @@ export const calculateActiveErc20TokensFrom = (tokens: List<Token>) => {
return activeTokens return activeTokens
} }
export const isAddressAToken = async (tokenAddress: string) => {
// SECOND APPROACH:
// They both seem to work the same
// const tokenContract = await getStandardTokenContract()
// try {
// await tokenContract.at(tokenAddress)
// } catch {
// return 'Not a token address'
// }
const web3 = getWeb3()
const call = await web3.eth.call({ to: tokenAddress, data: web3.utils.sha3('totalSupply()') })
return call !== '0x'
}

View File

@ -14,8 +14,7 @@ export const sameAddress = (firstAddress: string, secondAddress: string): boolea
} }
export const shortVersionOf = (address: string, cut: number) => { export const shortVersionOf = (address: string, cut: number) => {
const initial = cut
const final = 42 - cut const final = 42 - cut
return `${address.substring(0, initial)}...${address.substring(final)}` return `${address.substring(0, cut)}...${address.substring(final)}`
} }

View File

@ -32,9 +32,7 @@ export const ETHEREUM_NETWORK_IDS = {
42: ETHEREUM_NETWORK.KOVAN, 42: ETHEREUM_NETWORK.KOVAN,
} }
export const openTxInEtherScan = (tx: string, network: string) => `https://${network}.etherscan.io/tx/${tx}` export const getEtherScanLink = (type: 'address' | 'tx', value: string, network: string) => `https://${network === 'mainnet' ? '' : `${network}.`}etherscan.io/${type}/${value}`
export const getEtherScanLink = (address: string, network: string) => `https://${network}.etherscan.io/address/${address}`
let web3 let web3
export const getWeb3 = () => web3 || (window.web3 && new Web3(window.web3.currentProvider)) || (window.ethereum && new Web3(window.ethereum)) export const getWeb3 = () => web3 || (window.web3 && new Web3(window.web3.currentProvider)) || (window.ethereum && new Web3(window.ethereum))

View File

@ -151,7 +151,7 @@ const OwnerListComponent = (props: Props) => {
<Paragraph size="md" color="disabled" noMargin className={classes.address}> <Paragraph size="md" color="disabled" noMargin className={classes.address}>
{address} {address}
</Paragraph> </Paragraph>
<Link className={classes.open} to={getEtherScanLink(address, network)} target="_blank"> <Link className={classes.open} to={getEtherScanLink('address', address, network)} target="_blank">
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
</Link> </Link>
</Row> </Row>

View File

@ -1,9 +1,9 @@
// @flow // @flow
import * as React from 'react' import * as React from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import Block from '~/components/layout/Block'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
import OpenInNew from '@material-ui/icons/OpenInNew' import OpenInNew from '@material-ui/icons/OpenInNew'
import Block from '~/components/layout/Block'
import Identicon from '~/components/Identicon' import Identicon from '~/components/Identicon'
import OpenPaper from '~/components/Stepper/OpenPaper' import OpenPaper from '~/components/Stepper/OpenPaper'
import Col from '~/components/layout/Col' import Col from '~/components/layout/Col'
@ -132,7 +132,7 @@ class ReviewComponent extends React.PureComponent<Props, State> {
<Paragraph size="md" color="disabled" noMargin className={classes.address}> <Paragraph size="md" color="disabled" noMargin className={classes.address}>
{shortVersionOf(safeAddress, 4)} {shortVersionOf(safeAddress, 4)}
</Paragraph> </Paragraph>
<Link className={classes.open} to={getEtherScanLink(safeAddress, network)} target="_blank"> <Link className={classes.open} to={getEtherScanLink('address', safeAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
</Link> </Link>
</Row> </Row>
@ -177,7 +177,7 @@ class ReviewComponent extends React.PureComponent<Props, State> {
<Paragraph size="md" color="disabled" noMargin> <Paragraph size="md" color="disabled" noMargin>
{owners[index]} {owners[index]}
</Paragraph> </Paragraph>
<Link className={classes.open} to={getEtherScanLink(owners[index], network)} target="_blank"> <Link className={classes.open} to={getEtherScanLink('address', owners[index], network)} target="_blank">
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
</Link> </Link>
</Block> </Block>

View File

@ -20,7 +20,7 @@ export const loadSafe = async (
safeName: string, safeName: string,
safeAddress: string, safeAddress: string,
owners: Array, owners: Array,
addSafe: Function addSafe: Function,
) => { ) => {
const safeProps = await buildSafe(safeAddress, safeName) const safeProps = await buildSafe(safeAddress, safeName)
safeProps.owners = owners safeProps.owners = owners

View File

@ -129,7 +129,7 @@ const ReviewComponent = ({ values, classes, network }: Props) => {
<Paragraph size="md" color="disabled" noMargin> <Paragraph size="md" color="disabled" noMargin>
{addresses[index]} {addresses[index]}
</Paragraph> </Paragraph>
<Link className={classes.open} to={getEtherScanLink(addresses[index], network)} target="_blank"> <Link className={classes.open} to={getEtherScanLink('address', addresses[index], network)} target="_blank">
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
</Link> </Link>
</Block> </Block>

View File

@ -1,13 +1,13 @@
// @flow // @flow
import * as React from 'react' import * as React from 'react'
import Block from '~/components/layout/Block'
import OpenInNew from '@material-ui/icons/OpenInNew' import OpenInNew from '@material-ui/icons/OpenInNew'
import Paragraph from '~/components/layout/Paragraph'
import LinearProgress from '@material-ui/core/LinearProgress' import LinearProgress from '@material-ui/core/LinearProgress'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
import Block from '~/components/layout/Block'
import Paragraph from '~/components/layout/Paragraph'
import Img from '~/components/layout/Img' import Img from '~/components/layout/Img'
import Page from '~/components/layout/Page' import Page from '~/components/layout/Page'
import { openTxInEtherScan } from '~/logic/wallets/getWeb3' import { getEtherScanLink } from '~/logic/wallets/getWeb3'
import { mediumFontSize, secondary, xs } from '~/theme/variables' import { mediumFontSize, secondary, xs } from '~/theme/variables'
import { type SelectorProps } from '../container/selector' import { type SelectorProps } from '../container/selector'
@ -74,7 +74,7 @@ const Opening = ({
Follow progress on Follow progress on
{' '} {' '}
<a <a
href={openTxInEtherScan(tx, network)} href={getEtherScanLink('tx', tx, network)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={classes.etherscan} className={classes.etherscan}

View File

@ -1,9 +1,9 @@
// @flow // @flow
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { List } from 'immutable' import { List } from 'immutable'
import { type Token } from '~/logic/tokens/store/model/token'
import cn from 'classnames' import cn from 'classnames'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
import { type Token } from '~/logic/tokens/store/model/token'
import Modal from '~/components/Modal' import Modal from '~/components/Modal'
import ChooseTxType from './screens/ChooseTxType' import ChooseTxType from './screens/ChooseTxType'
import SendFunds from './screens/SendFunds' import SendFunds from './screens/SendFunds'

View File

@ -2,9 +2,9 @@
import React from 'react' import React from 'react'
import OpenInNew from '@material-ui/icons/OpenInNew' import OpenInNew from '@material-ui/icons/OpenInNew'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
import { SharedSnackbarConsumer } from '~/components/SharedSnackBar'
import Close from '@material-ui/icons/Close' import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton' import IconButton from '@material-ui/core/IconButton'
import { SharedSnackbarConsumer } from '~/components/SharedSnackBar'
import Paragraph from '~/components/layout/Paragraph' import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row' import Row from '~/components/layout/Row'
import Link from '~/components/layout/Link' import Link from '~/components/layout/Link'
@ -62,7 +62,6 @@ const ReviewTx = ({
let txData = EMPTY_DATA let txData = EMPTY_DATA
let txAmount = web3.utils.toWei(tx.amount, 'ether') let txAmount = web3.utils.toWei(tx.amount, 'ether')
if (!isSendingETH) { if (!isSendingETH) {
const StandardToken = await getStandardTokenContract() const StandardToken = await getStandardTokenContract()
const tokenInstance = await StandardToken.at(tx.token.address) const tokenInstance = await StandardToken.at(tx.token.address)
@ -139,7 +138,7 @@ const ReviewTx = ({
</Block> </Block>
<Hairline style={{ position: 'absolute', bottom: 85 }} /> <Hairline style={{ position: 'absolute', bottom: 85 }} />
<Row align="center" className={classes.buttonRow}> <Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClickBack}> <Button className={classes.button} minWidth={140} minHeight={42} onClick={onClickBack}>
Back Back
</Button> </Button>
<Button <Button
@ -148,6 +147,7 @@ const ReviewTx = ({
onClick={submitTx} onClick={submitTx}
variant="contained" variant="contained"
minWidth={140} minWidth={140}
minHeight={42}
color="primary" color="primary"
data-testid="submit-tx-btn" data-testid="submit-tx-btn"
> >

View File

@ -158,13 +158,13 @@ const SendFunds = ({
</Row> </Row>
<Hairline /> <Hairline />
<Row align="center" className={classes.buttonRow}> <Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClose}> <Button className={classes.button} minWidth={140} minHeight={42} onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button <Button
type="submit" type="submit"
className={classes.button}
variant="contained" variant="contained"
minHeight={42}
minWidth={140} minWidth={140}
color="primary" color="primary"
data-testid="review-tx-btn" data-testid="review-tx-btn"

View File

@ -1,8 +1,8 @@
// @flow // @flow
import { List } from 'immutable' import { List } from 'immutable'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { type Token } from '~/logic/tokens/store/model/token' import { type Token } from '~/logic/tokens/store/model/token'
import { sameAddress } from '~/logic/wallets/ethAddresses' import { sameAddress } from '~/logic/wallets/ethAddresses'
import { isAddressAToken } from '~/logic/tokens/utils/tokenHelpers'
import { simpleMemoize } from '~/components/forms/validator' import { simpleMemoize } from '~/components/forms/validator'
// import { getStandardTokenContract } from '~/logic/tokens/store/actions/fetchTokens' // import { getStandardTokenContract } from '~/logic/tokens/store/actions/fetchTokens'
@ -17,10 +17,9 @@ export const addressIsTokenContract = simpleMemoize(async (tokenAddress: string)
// return 'Not a token address' // return 'Not a token address'
// } // }
const web3 = getWeb3() const isToken = await isAddressAToken(tokenAddress)
const call = await web3.eth.call({ to: tokenAddress, data: web3.utils.sha3('totalSupply()') })
if (call === '0x') { if (!isToken) {
return 'Not a token address' return 'Not a token address'
} }
}) })

View File

@ -5,19 +5,19 @@ import cn from 'classnames'
import SearchBar from 'material-ui-search-bar' import SearchBar from 'material-ui-search-bar'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
import MuiList from '@material-ui/core/List' import MuiList from '@material-ui/core/List'
import Img from '~/components/layout/Img'
import Block from '~/components/layout/Block'
import ListItem from '@material-ui/core/ListItem' import ListItem from '@material-ui/core/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon' import ListItemIcon from '@material-ui/core/ListItemIcon'
import CircularProgress from '@material-ui/core/CircularProgress'
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction' import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
import ListItemText from '@material-ui/core/ListItemText' import ListItemText from '@material-ui/core/ListItemText'
import Search from '@material-ui/icons/Search'
import Button from '~/components/layout/Button'
import Switch from '@material-ui/core/Switch' import Switch from '@material-ui/core/Switch'
import Search from '@material-ui/icons/Search'
import Img from '~/components/layout/Img'
import Block from '~/components/layout/Block'
import Button from '~/components/layout/Button'
import Divider from '~/components/layout/Divider' import Divider from '~/components/layout/Divider'
import Hairline from '~/components/layout/Hairline' import Hairline from '~/components/layout/Hairline'
import Spacer from '~/components/Spacer' import Spacer from '~/components/Spacer'
import CircularProgress from '@material-ui/core/CircularProgress'
import Row from '~/components/layout/Row' import Row from '~/components/layout/Row'
import { ETH_ADDRESS } from '~/logic/tokens/utils/tokenHelpers' import { ETH_ADDRESS } from '~/logic/tokens/utils/tokenHelpers'
import { type Token } from '~/logic/tokens/store/model/token' import { type Token } from '~/logic/tokens/store/model/token'
@ -141,7 +141,7 @@ class Tokens extends React.Component<Props, State> {
<Button <Button
variant="contained" variant="contained"
size="small" size="small"
color="secondary" color="primary"
className={classes.add} className={classes.add}
onClick={switchToAddCustomTokenScreen} onClick={switchToAddCustomTokenScreen}
testId={ADD_CUSTOM_TOKEN_BUTTON_TEST_ID} testId={ADD_CUSTOM_TOKEN_BUTTON_TEST_ID}

View File

@ -52,6 +52,7 @@ export const generateColumns = () => {
disablePadding: false, disablePadding: false,
label: '', label: '',
custom: true, custom: true,
static: true,
} }
return List([assetColumn, balanceColumn, actions]) return List([assetColumn, balanceColumn, actions])

View File

@ -2,16 +2,16 @@
import * as React from 'react' import * as React from 'react'
import { List } from 'immutable' import { List } from 'immutable'
import classNames from 'classnames/bind' import classNames from 'classnames/bind'
import { type Token } from '~/logic/tokens/store/model/token'
import CallMade from '@material-ui/icons/CallMade' import CallMade from '@material-ui/icons/CallMade'
import CallReceived from '@material-ui/icons/CallReceived' import CallReceived from '@material-ui/icons/CallReceived'
import Button from '@material-ui/core/Button'
import Checkbox from '@material-ui/core/Checkbox' import Checkbox from '@material-ui/core/Checkbox'
import TableRow from '@material-ui/core/TableRow' import TableRow from '@material-ui/core/TableRow'
import TableCell from '@material-ui/core/TableCell' import TableCell from '@material-ui/core/TableCell'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
import { type Token } from '~/logic/tokens/store/model/token'
import Col from '~/components/layout/Col' import Col from '~/components/layout/Col'
import Row from '~/components/layout/Row' import Row from '~/components/layout/Row'
import Button from '~/components/layout/Button'
import ButtonLink from '~/components/layout/ButtonLink' import ButtonLink from '~/components/layout/ButtonLink'
import Paragraph from '~/components/layout/Paragraph' import Paragraph from '~/components/layout/Paragraph'
import Modal from '~/components/Modal' import Modal from '~/components/Modal'
@ -168,10 +168,11 @@ class Balances extends React.Component<Props, State> {
<Button <Button
variant="contained" variant="contained"
size="small" size="small"
color="secondary" color="primary"
className={classes.send} className={classes.send}
onClick={() => this.showSendFunds(row.asset.name)} onClick={() => this.showSendFunds(row.asset.name)}
data-testid="balance-send-btn" rounded
testId="balance-send-btn"
> >
<CallMade className={classNames(classes.leftIcon, classes.iconSmall)} /> <CallMade className={classNames(classes.leftIcon, classes.iconSmall)} />
Send Send
@ -180,9 +181,10 @@ class Balances extends React.Component<Props, State> {
<Button <Button
variant="contained" variant="contained"
size="small" size="small"
color="secondary" color="primary"
className={classes.receive} className={classes.receive}
onClick={this.onShow('Receive')} onClick={this.onShow('Receive')}
rounded
> >
<CallReceived className={classNames(classes.leftIcon, classes.iconSmall)} /> <CallReceived className={classNames(classes.leftIcon, classes.iconSmall)} />
Receive Receive

View File

@ -19,20 +19,23 @@ import {
} from '~/theme/variables' } from '~/theme/variables'
import { copyToClipboard } from '~/utils/clipboard' import { copyToClipboard } from '~/utils/clipboard'
import Balances from './Balances' import Balances from './Balances'
import Transactions from './TransactionsNew'
import Settings from './Settings' import Settings from './Settings'
export const SETTINGS_TAB_BTN_TESTID = 'settings-tab-btn' export const SETTINGS_TAB_BTN_TESTID = 'settings-tab-btn'
export const SAFE_VIEW_NAME_HEADING_TESTID = 'safe-name-heading' export const SAFE_VIEW_NAME_HEADING_TESTID = 'safe-name-heading'
type State = {
tabIndex: number,
}
type Props = SelectorProps & { type Props = SelectorProps & {
classes: Object, classes: Object,
granted: boolean, granted: boolean,
createTransaction: Function,
updateSafe: Function, updateSafe: Function,
} createTransaction: Function,
processTransaction: Function,
type State = { fetchTransactions: Function,
tabIndex: number,
} }
const openIconStyle = { const openIconStyle = {
@ -102,7 +105,10 @@ class Layout extends React.Component<Props, State> {
tokens, tokens,
activeTokens, activeTokens,
createTransaction, createTransaction,
processTransaction,
fetchTransactions,
updateSafe, updateSafe,
transactions,
userAddress, userAddress,
} = this.props } = this.props
const { tabIndex } = this.state const { tabIndex } = this.state
@ -112,7 +118,7 @@ class Layout extends React.Component<Props, State> {
} }
const { address, ethBalance, name } = safe const { address, ethBalance, name } = safe
const etherScanLink = getEtherScanLink(address, network) const etherScanLink = getEtherScanLink('address', address, network)
return ( return (
<React.Fragment> <React.Fragment>
@ -155,6 +161,19 @@ class Layout extends React.Component<Props, State> {
createTransaction={createTransaction} createTransaction={createTransaction}
/> />
)} )}
{tabIndex === 1 && (
<Transactions
threshold={safe.threshold}
owners={safe.owners}
transactions={transactions}
fetchTransactions={fetchTransactions}
safeAddress={address}
userAddress={userAddress}
granted={granted}
createTransaction={createTransaction}
processTransaction={processTransaction}
/>
)}
{tabIndex === 2 && ( {tabIndex === 2 && (
<Settings <Settings
granted={granted} granted={granted}

View File

@ -12,7 +12,7 @@ import Row from '~/components/layout/Row'
import Paragraph from '~/components/layout/Paragraph' import Paragraph from '~/components/layout/Paragraph'
import Hairline from '~/components/layout/Hairline' import Hairline from '~/components/layout/Hairline'
import Button from '~/components/layout/Button' import Button from '~/components/layout/Button'
import { sm, boldFont } from '~/theme/variables' import { sm } from '~/theme/variables'
import { styles } from './style' import { styles } from './style'
const controlsStyle = { const controlsStyle = {
@ -20,11 +20,6 @@ const controlsStyle = {
padding: sm, padding: sm,
} }
const saveButtonStyle = {
marginRight: sm,
fontWeight: boldFont,
}
export const SAFE_NAME_INPUT_TESTID = 'safe-name-input' export const SAFE_NAME_INPUT_TESTID = 'safe-name-input'
export const SAFE_NAME_SUBMIT_BTN_TESTID = 'change-safe-name-btn' export const SAFE_NAME_SUBMIT_BTN_TESTID = 'change-safe-name-btn'
@ -73,7 +68,7 @@ const ChangeSafeName = (props: Props) => {
<Col end="xs"> <Col end="xs">
<Button <Button
type="submit" type="submit"
style={saveButtonStyle} className={classes.saveBtn}
size="small" size="small"
variant="contained" variant="contained"
color="primary" color="primary"

View File

@ -1,5 +1,5 @@
// @flow // @flow
import { lg } from '~/theme/variables' import { lg, sm, boldFont } from '~/theme/variables'
export const styles = () => ({ export const styles = () => ({
formContainer: { formContainer: {
@ -10,4 +10,8 @@ export const styles = () => ({
display: 'flex', display: 'flex',
maxWidth: '460px', maxWidth: '460px',
}, },
saveBtn: {
marginRight: sm,
fontWeight: boldFont,
},
}) })

View File

@ -112,7 +112,7 @@ const ReviewAddOwner = ({
<Paragraph size="md" color="disabled" noMargin> <Paragraph size="md" color="disabled" noMargin>
{owner.address} {owner.address}
</Paragraph> </Paragraph>
<Link className={classes.open} to={getEtherScanLink(owner.address, network)} target="_blank"> <Link className={classes.open} to={getEtherScanLink('address', owner.address, network)} target="_blank">
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
</Link> </Link>
</Block> </Block>
@ -141,7 +141,7 @@ const ReviewAddOwner = ({
<Paragraph size="md" color="disabled" noMargin> <Paragraph size="md" color="disabled" noMargin>
{values.ownerAddress} {values.ownerAddress}
</Paragraph> </Paragraph>
<Link className={classes.open} to={getEtherScanLink(values.ownerAddress, network)} target="_blank"> <Link className={classes.open} to={getEtherScanLink('address', values.ownerAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
</Link> </Link>
</Block> </Block>

View File

@ -94,7 +94,7 @@ const EditOwnerComponent = ({
<Paragraph style={{ marginLeft: 10 }} size="md" color="disabled" noMargin> <Paragraph style={{ marginLeft: 10 }} size="md" color="disabled" noMargin>
{ownerAddress} {ownerAddress}
</Paragraph> </Paragraph>
<Link className={classes.open} to={getEtherScanLink(ownerAddress, network)} target="_blank"> <Link className={classes.open} to={getEtherScanLink('address', ownerAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
</Link> </Link>
</Block> </Block>

View File

@ -76,7 +76,7 @@ const CheckOwner = ({
<Paragraph size="md" color="disabled" noMargin> <Paragraph size="md" color="disabled" noMargin>
{ownerAddress} {ownerAddress}
</Paragraph> </Paragraph>
<Link className={classes.open} to={getEtherScanLink(ownerAddress, network)} target="_blank"> <Link className={classes.open} to={getEtherScanLink('address', ownerAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
</Link> </Link>
</Block> </Block>

View File

@ -127,7 +127,7 @@ const ReviewRemoveOwner = ({
</Paragraph> </Paragraph>
<Link <Link
className={classes.open} className={classes.open}
to={getEtherScanLink(owner.address, network)} to={getEtherScanLink('address', owner.address, network)}
target="_blank" target="_blank"
> >
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
@ -159,7 +159,7 @@ const ReviewRemoveOwner = ({
<Paragraph size="md" color="disabled" noMargin> <Paragraph size="md" color="disabled" noMargin>
{ownerAddress} {ownerAddress}
</Paragraph> </Paragraph>
<Link className={classes.open} to={getEtherScanLink(ownerAddress, network)} target="_blank"> <Link className={classes.open} to={getEtherScanLink('address', ownerAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
</Link> </Link>
</Block> </Block>

View File

@ -94,7 +94,7 @@ const OwnerForm = ({
<Paragraph size="md" color="disabled" noMargin> <Paragraph size="md" color="disabled" noMargin>
{ownerAddress} {ownerAddress}
</Paragraph> </Paragraph>
<Link className={classes.open} to={getEtherScanLink(ownerAddress, network)} target="_blank"> <Link className={classes.open} to={getEtherScanLink('address', ownerAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
</Link> </Link>
</Block> </Block>

View File

@ -129,7 +129,7 @@ const ReviewRemoveOwner = ({
</Paragraph> </Paragraph>
<Link <Link
className={classes.open} className={classes.open}
to={getEtherScanLink(owner.address, network)} to={getEtherScanLink('address', owner.address, network)}
target="_blank" target="_blank"
> >
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
@ -161,7 +161,7 @@ const ReviewRemoveOwner = ({
<Paragraph size="md" color="disabled" noMargin> <Paragraph size="md" color="disabled" noMargin>
{ownerAddress} {ownerAddress}
</Paragraph> </Paragraph>
<Link className={classes.open} to={getEtherScanLink(ownerAddress, network)} target="_blank"> <Link className={classes.open} to={getEtherScanLink('address', ownerAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
</Link> </Link>
</Block> </Block>
@ -187,7 +187,7 @@ const ReviewRemoveOwner = ({
<Paragraph size="md" color="disabled" noMargin> <Paragraph size="md" color="disabled" noMargin>
{values.ownerAddress} {values.ownerAddress}
</Paragraph> </Paragraph>
<Link className={classes.open} to={getEtherScanLink(values.ownerAddress, network)} target="_blank"> <Link className={classes.open} to={getEtherScanLink('address', values.ownerAddress, network)} target="_blank">
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
</Link> </Link>
</Block> </Block>

View File

@ -4,8 +4,8 @@ import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close' import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton' import IconButton from '@material-ui/core/IconButton'
import SelectField from '~/components/forms/SelectField'
import MenuItem from '@material-ui/core/MenuItem' import MenuItem from '@material-ui/core/MenuItem'
import SelectField from '~/components/forms/SelectField'
import { import {
composeValidators, minValue, mustBeInteger, required, differentFrom, composeValidators, minValue, mustBeInteger, required, differentFrom,
} from '~/components/forms/validator' } from '~/components/forms/validator'

View File

@ -0,0 +1,18 @@
// @flow
import * as React from 'react'
import Bold from '~/components/layout/Bold'
import Col from '~/components/layout/Col'
import Row from '~/components/layout/Row'
import Paragraph from '~/components/layout/Paragraph/index'
const NoTransactions = () => (
<Row>
<Col xs={12} center="xs" sm={10} smOffset={2} start="sm" margin="md">
<Paragraph size="lg">
<Bold>No transactions found for this safe</Bold>
</Paragraph>
</Col>
</Row>
)
export default NoTransactions

View File

@ -0,0 +1,127 @@
// @flow
import React, { useState } from 'react'
import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton'
import { withStyles } from '@material-ui/core/styles'
import FormControlLabel from '@material-ui/core/FormControlLabel'
import Checkbox from '@material-ui/core/Checkbox'
import { SharedSnackbarConsumer } from '~/components/SharedSnackBar'
import Modal from '~/components/Modal'
import Hairline from '~/components/layout/Hairline'
import Button from '~/components/layout/Button'
import Row from '~/components/layout/Row'
import Bold from '~/components/layout/Bold'
import Block from '~/components/layout/Block'
import Paragraph from '~/components/layout/Paragraph'
import { type Transaction } from '~/routes/safe/store/models/transaction'
import { styles } from './style'
type Props = {
onClose: () => void,
classes: Object,
isOpen: boolean,
processTransaction: Function,
tx: Transaction,
nonce: string,
safeAddress: string,
threshold: number,
thresholdReached: boolean,
userAddress: string,
}
const getModalTitleAndDescription = (thresholdReached: boolean) => {
const title = thresholdReached ? 'Execute Transaction' : 'Approve Transaction'
const description = `This action will ${
thresholdReached ? 'execute' : 'approve'
} this transaction. A separate transaction will be performed to submit the ${
thresholdReached ? 'execution' : 'approval'
}.`
return {
title,
description,
}
}
const ApproveTxModal = ({
onClose,
isOpen,
classes,
processTransaction,
tx,
safeAddress,
threshold,
thresholdReached,
userAddress,
}: Props) => {
const [approveAndExecute, setApproveAndExecute] = useState<boolean>(false)
const { title, description } = getModalTitleAndDescription(thresholdReached)
const oneConfirmationLeft = tx.confirmations.size + 1 === threshold
const handleExecuteCheckbox = () => setApproveAndExecute(prevApproveAndExecute => !prevApproveAndExecute)
return (
<SharedSnackbarConsumer>
{({ openSnackbar }) => {
const approveTx = () => {
processTransaction(safeAddress, tx, openSnackbar, userAddress, approveAndExecute)
onClose()
}
return (
<Modal title={title} description={description} handleClose={onClose} open={isOpen}>
<Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.headingText} noMargin>
{title}
</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<Block className={classes.container}>
<Row>
<Paragraph>{description}</Paragraph>
<Paragraph size="sm" color="medium">
Transaction nonce:
<br />
<Bold className={classes.nonceNumber}>{tx.nonce}</Bold>
</Paragraph>
{!thresholdReached && oneConfirmationLeft && (
<>
<Paragraph color="error">
Approving transaction does not execute it immediately. If you want to approve and execute the
transaction right away, click on checkbox below.
</Paragraph>
<FormControlLabel
control={<Checkbox onChange={handleExecuteCheckbox} checked={approveAndExecute} color="primary" />}
label="Execute transaction"
/>
</>
)}
</Row>
</Block>
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} minHeight={42} onClick={onClose}>
Exit
</Button>
<Button
type="submit"
className={classes.button}
variant="contained"
minWidth={214}
minHeight={42}
color="primary"
onClick={approveTx}
>
{title}
</Button>
</Row>
</Modal>
)
}}
</SharedSnackbarConsumer>
)
}
export default withStyles(styles)(ApproveTxModal)

View File

@ -0,0 +1,35 @@
// @flow
import {
lg, md, sm, border,
} from '~/theme/variables'
export const styles = () => ({
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'space-between',
boxSizing: 'border-box',
maxHeight: '75px',
},
headingText: {
fontSize: '24px',
},
closeIcon: {
height: '35px',
width: '35px',
},
container: {
padding: `${md} ${lg}`,
},
buttonRow: {
height: '84px',
justifyContent: 'center',
position: 'absolute',
bottom: 0,
width: '100%',
borderTop: `1px solid ${border}`,
},
nonceNumber: {
marginTop: sm,
fontSize: md,
},
})

View File

@ -0,0 +1,89 @@
// @flow
import React from 'react'
import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton'
import { withStyles } from '@material-ui/core/styles'
import { SharedSnackbarConsumer } from '~/components/SharedSnackBar'
import Modal from '~/components/Modal'
import Hairline from '~/components/layout/Hairline'
import Button from '~/components/layout/Button'
import Row from '~/components/layout/Row'
import Bold from '~/components/layout/Bold'
import Block from '~/components/layout/Block'
import Paragraph from '~/components/layout/Paragraph'
import { type Transaction } from '~/routes/safe/store/models/transaction'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import { styles } from './style'
type Props = {
onClose: () => void,
classes: Object,
isOpen: boolean,
createTransaction: Function,
tx: Transaction,
safeAddress: string,
}
const CancelTxModal = ({
onClose, isOpen, classes, createTransaction, tx, safeAddress,
}: Props) => (
<SharedSnackbarConsumer>
{({ openSnackbar }) => {
const sendReplacementTransaction = () => {
createTransaction(safeAddress, safeAddress, 0, EMPTY_DATA, openSnackbar)
onClose()
}
return (
<Modal
title="Cancel Transaction"
description="Cancel Transaction"
handleClose={onClose}
open={isOpen}
// paperClassName={cn(smallerModalSize && classes.smallerModalWindow)}
>
<Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.headingText} noMargin>
Cancel transaction
</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<Block className={classes.container}>
<Row>
<Paragraph>
This action will cancel this transaction. A separate transaction will be performed to submit the
cancellation.
</Paragraph>
<Paragraph size="sm" color="medium">
Transaction nonce:
<br />
<Bold className={classes.nonceNumber}>{tx.nonce}</Bold>
</Paragraph>
</Row>
</Block>
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} minHeight={42} onClick={onClose}>
Exit
</Button>
<Button
type="submit"
className={classes.button}
variant="contained"
minWidth={214}
minHeight={42}
color="secondary"
onClick={sendReplacementTransaction}
>
Cancel Transaction
</Button>
</Row>
</Modal>
)
}}
</SharedSnackbarConsumer>
)
export default withStyles(styles)(CancelTxModal)

View File

@ -0,0 +1,35 @@
// @flow
import {
lg, md, sm, border,
} from '~/theme/variables'
export const styles = () => ({
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'space-between',
boxSizing: 'border-box',
maxHeight: '75px',
},
headingText: {
fontSize: '24px',
},
closeIcon: {
height: '35px',
width: '35px',
},
container: {
padding: `${md} ${lg}`,
},
buttonRow: {
height: '84px',
justifyContent: 'center',
position: 'absolute',
bottom: 0,
width: '100%',
borderTop: `1px solid ${border}`,
},
nonceNumber: {
marginTop: sm,
fontSize: md,
},
})

View File

@ -0,0 +1,73 @@
// @flow
import React from 'react'
import { withStyles } from '@material-ui/core/styles'
import EditIcon from '@material-ui/icons/Edit'
import BlockIcon from '@material-ui/icons/Block'
import Row from '~/components/layout/Row'
import Button from '~/components/layout/Button'
import { sm, lg } from '~/theme/variables'
type Props = {
onTxConfirm: Function,
onTxCancel: Function,
onTxExecute: Function,
classes: Object,
showConfirmBtn: boolean,
showCancelBtn: boolean,
showExecuteBtn: boolean,
}
const styles = () => ({
buttonRow: {
height: '56px',
justifyContent: 'center',
backgroundColor: '#f7f8fb',
},
button: {
height: '32px',
'&:last-child': {
marginLeft: lg,
},
},
icon: {
width: '14px',
height: '14px',
marginRight: sm,
},
})
const ButtonRow = ({
classes,
onTxCancel,
onTxConfirm,
showConfirmBtn,
showCancelBtn,
showExecuteBtn,
onTxExecute,
}: Props) => (
<Row align="center" className={classes.buttonRow}>
{showCancelBtn && (
<Button className={classes.button} variant="contained" minWidth={140} color="secondary" onClick={onTxCancel}>
<BlockIcon className={classes.icon} />
{' '}
Cancel TX
</Button>
)}
{showConfirmBtn && (
<Button className={classes.button} variant="contained" minWidth={140} color="primary" onClick={onTxConfirm}>
<EditIcon className={classes.icon} />
{' '}
Confirm TX
</Button>
)}
{showExecuteBtn && (
<Button className={classes.button} variant="contained" minWidth={140} color="primary" onClick={onTxExecute}>
<EditIcon className={classes.icon} />
{' '}
Execute TX
</Button>
)}
</Row>
)
export default withStyles(styles)(ButtonRow)

View File

@ -0,0 +1,67 @@
// @flow
import React from 'react'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import OpenInNew from '@material-ui/icons/OpenInNew'
import MuiList from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText'
import Chip from '@material-ui/core/Chip'
import Identicon from '~/components/Identicon'
import Hairline from '~/components/layout/Hairline'
import { type Owner } from '~/routes/safe/store/models/owner'
import { getEtherScanLink } from '~/logic/wallets/getWeb3'
import { secondary } from '~/theme/variables'
import { shortVersionOf } from '~/logic/wallets/ethAddresses'
import { styles } from './style'
type ListProps = {
owners: List<Owner>,
classes: Object,
executionConfirmation?: Owner,
}
type OwnerProps = {
owner: Owner,
classes: Object,
isExecutor?: boolean,
}
const openIconStyle = {
height: '13px',
color: secondary,
}
const OwnerComponent = withStyles(styles)(({ owner, classes, isExecutor }: OwnerProps) => (
<ListItem key={owner.address} className={classes.owner}>
<ListItemIcon>
<Identicon address={owner.address} diameter={32} className={classes.icon} />
</ListItemIcon>
<ListItemText
primary={owner.name}
secondary={(
<a href={getEtherScanLink('address', owner.address, 'rinkeby')} target="_blank" rel="noopener noreferrer">
{shortVersionOf(owner.address, 4)}
{' '}
<OpenInNew style={openIconStyle} />
</a>
)}
/>
{isExecutor && <Chip label="EXECUTOR" color="secondary" />}
</ListItem>
))
const OwnersList = ({ owners, classes, executionConfirmation }: ListProps) => (
<>
<MuiList className={classes.ownersList}>
{executionConfirmation && <OwnerComponent owner={executionConfirmation} isExecutor />}
{owners.map(owner => (
<OwnerComponent key={owner.address} owner={owner} />
))}
</MuiList>
<Hairline color="#c8ced4" />
</>
)
export default withStyles(styles)(OwnersList)

View File

@ -0,0 +1,120 @@
// @flow
import React, { useState } from 'react'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import Tabs from '@material-ui/core/Tabs'
import Tab from '@material-ui/core/Tab'
import Col from '~/components/layout/Col'
import Row from '~/components/layout/Row'
import Hairline from '~/components/layout/Hairline'
import { type Owner } from '~/routes/safe/store/models/owner'
import { type Transaction } from '~/routes/safe/store/models/transaction'
import { TX_TYPE_CONFIRMATION } from '~/logic/safe/transactions/send'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import OwnersList from './List'
import ButtonRow from './ButtonRow'
import { styles } from './style'
type Props = {
tx: Transaction,
owners: List<Owner>,
classes: Object,
granted: boolean,
threshold: number,
userAddress: string,
thresholdReached: boolean,
safeAddress: string,
onTxConfirm: Function,
onTxCancel: Function,
onTxExecute: Function,
}
const isCancellationTransaction = (tx: Transaction, safeAddress: string) =>
!tx.value && tx.data === EMPTY_DATA && tx.recipient === safeAddress
const OwnersColumn = ({
tx,
owners,
classes,
granted,
threshold,
userAddress,
thresholdReached,
safeAddress,
onTxConfirm,
onTxCancel,
onTxExecute,
}: Props) => {
const [tabIndex, setTabIndex] = useState(0)
const handleTabChange = (event, tabClicked) => {
setTabIndex(tabClicked)
}
const cancellationTx = isCancellationTransaction(tx, safeAddress)
const ownersWhoConfirmed = []
let currentUserAlreadyConfirmed = false
let executionConfirmation
tx.confirmations.forEach((conf) => {
if (conf.owner.address === userAddress) {
currentUserAlreadyConfirmed = true
}
if (conf.type === TX_TYPE_CONFIRMATION) {
ownersWhoConfirmed.push(conf.owner)
} else {
executionConfirmation = conf.owner
}
})
const ownersUnconfirmed = owners.filter(
owner => tx.confirmations.findIndex(conf => conf.owner.address === owner.address) === -1,
)
let displayButtonRow = true
if (tx.executionTxHash) {
// One of owners already executed the tx
displayButtonRow = false
} else if (tx.status === 'cancelled') {
// tx is cancelled (replaced) by another one
displayButtonRow = false
} else if (cancellationTx && currentUserAlreadyConfirmed && !thresholdReached) {
// the TX is the cancellation (replacement) transaction for previous TX,
// current user has already confirmed it and threshold is not reached (so he can't execute/cancel it)
displayButtonRow = false
}
let confirmedLabel = `Confirmed [${tx.confirmations.size}/${threshold}]`
if (tx.executionTxHash) {
confirmedLabel = `Confirmed [${tx.confirmations.size}]`
}
const unconfirmedLabel = `Unconfirmed [${ownersUnconfirmed.size}]`
return (
<Col xs={6} className={classes.rightCol} layout="block">
<Row>
<Tabs value={tabIndex} onChange={handleTabChange} indicatorColor="secondary" textColor="secondary">
<Tab label={confirmedLabel} />
<Tab label={unconfirmedLabel} />
</Tabs>
<Hairline color="#c8ced4" />
</Row>
<Row>
{tabIndex === 0 && <OwnersList owners={ownersWhoConfirmed} executionConfirmation={executionConfirmation} />}
</Row>
<Row>{tabIndex === 1 && <OwnersList owners={ownersUnconfirmed} />}</Row>
{granted && displayButtonRow && (
<ButtonRow
onTxConfirm={onTxConfirm}
onTxCancel={onTxCancel}
showConfirmBtn={!currentUserAlreadyConfirmed && !thresholdReached}
showCancelBtn={!cancellationTx}
showExecuteBtn={thresholdReached}
onTxExecute={onTxExecute}
/>
)}
</Col>
)
}
export default withStyles(styles)(OwnersColumn)

View File

@ -0,0 +1,21 @@
// @flow
import { border, sm } from '~/theme/variables'
export const styles = () => ({
ownersList: {
width: '100%',
padding: 0,
height: '192px',
overflowY: 'scroll',
},
rightCol: {
boxSizing: 'border-box',
borderLeft: 'solid 1px #c8ced4',
},
icon: {
marginRight: sm,
},
owner: {
borderBottom: `1px solid ${border}`,
},
})

View File

@ -0,0 +1,94 @@
// @flow
import React from 'react'
import { withStyles } from '@material-ui/core/styles'
import { type Transaction } from '~/routes/safe/store/models/transaction'
import Bold from '~/components/layout/Bold'
import EtherscanLink from '~/components/EtherscanLink'
import Paragraph from '~/components/layout/Paragraph'
import Block from '~/components/layout/Block'
import { md, lg, secondary } from '~/theme/variables'
import { getTxData } from './utils'
export const styles = () => ({
txDataContainer: {
padding: `${lg} ${md}`,
},
})
type Props = {
classes: Object,
tx: Transaction,
}
type TransferDescProps = {
value: string,
symbol: string,
recipient: string,
}
type DescriptionDescProps = {
removedOwner?: string,
addedOwner?: string,
newThreshold?: string,
}
const TransferDescription = ({ value = '', symbol, recipient }: TransferDescProps) => (
<Paragraph noMargin>
<Bold>
Send
{' '}
{value}
{' '}
{symbol}
{' '}
to:
</Bold>
<br />
<EtherscanLink type="address" value={recipient} />
</Paragraph>
)
const SettingsDescription = ({ removedOwner, addedOwner, newThreshold }: DescriptionDescProps) => (
<>
{removedOwner && (
<Paragraph>
<Bold>Remove owner:</Bold>
<br />
<EtherscanLink type="address" value={removedOwner} />
</Paragraph>
)}
{addedOwner && (
<Paragraph>
<Bold>Add owner:</Bold>
<br />
<EtherscanLink type="address" value={addedOwner} />
</Paragraph>
)}
{newThreshold && (
<Paragraph>
<Bold>Change required confirmations:</Bold>
<br />
{newThreshold}
</Paragraph>
)}
</>
)
const TxDescription = ({ tx, classes }: Props) => {
const {
recipient, value, modifySettingsTx, removedOwner, addedOwner, newThreshold, cancellationTx,
} = getTxData(tx)
return (
<Block className={classes.txDataContainer}>
{modifySettingsTx && (
<SettingsDescription removedOwner={removedOwner} newThreshold={newThreshold} addedOwner={addedOwner} />
)}
{!cancellationTx && !modifySettingsTx && (
<TransferDescription value={value} symbol={tx.symbol} recipient={recipient} />
)}
</Block>
)
}
export default withStyles(styles)(TxDescription)

View File

@ -0,0 +1,53 @@
// @flow
import { type Transaction } from '~/routes/safe/store/models/transaction'
import { getWeb3 } from '~/logic/wallets/getWeb3'
const web3 = getWeb3()
const { toBN, fromWei } = web3.utils
type DecodedTxData = {
recipient: string,
value?: string,
modifySettingsTx?: boolean,
removedOwner?: string,
newThreshold?: string,
addedOwner?: string,
cancellationTx?: boolean,
}
export const getTxData = (tx: Transaction): DecodedTxData => {
const txData = {}
if (tx.isTokenTransfer && tx.decodedParams) {
txData.recipient = tx.decodedParams.recipient
txData.value = fromWei(toBN(tx.decodedParams.value), 'ether')
} else if (Number(tx.value) > 0) {
txData.recipient = tx.recipient
txData.value = fromWei(toBN(tx.value), 'ether')
} else if (tx.modifySettingsTx) {
txData.recipient = tx.recipient
txData.modifySettingsTx = true
if (tx.decodedParams) {
/* eslint-disable */
if (tx.decodedParams.methodName === 'removeOwner') {
txData.removedOwner = tx.decodedParams.args[1]
txData.newThreshold = tx.decodedParams.args[2]
} else if (tx.decodedParams.methodName === 'changeThreshold') {
txData.newThreshold = tx.decodedParams.args[0]
} else if (tx.decodedParams.methodName === 'addOwnerWithThreshold') {
txData.addedOwner = tx.decodedParams.args[0]
txData.newThreshold = tx.decodedParams.args[1]
} else if (tx.decodedParams.methodName === 'swapOwner') {
txData.addedOwner = tx.decodedParams.args[0]
txData.removedOwner = tx.decodedParams.args[1]
txData.newThreshold = tx.decodedParams.args[2]
}
/* eslint-enable */
}
} else if (tx.cancellationTx) {
txData.cancellationTx = true
}
return txData
}

View File

@ -0,0 +1,140 @@
// @flow
import React, { useState } from 'react'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import OpenInNew from '@material-ui/icons/OpenInNew'
import Row from '~/components/layout/Row'
import Block from '~/components/layout/Block'
import Col from '~/components/layout/Col'
import Bold from '~/components/layout/Bold'
import Span from '~/components/layout/Span'
import Paragraph from '~/components/layout/Paragraph'
import Hairline from '~/components/layout/Hairline'
import { type Transaction } from '~/routes/safe/store/models/transaction'
import { type Owner } from '~/routes/safe/store/models/owner'
import { getEtherScanLink } from '~/logic/wallets/getWeb3'
import { shortVersionOf } from '~/logic/wallets/ethAddresses'
import { secondary } from '~/theme/variables'
import TxDescription from './TxDescription'
import OwnersColumn from './OwnersColumn'
import CancelTxModal from './CancelTxModal'
import ApproveTxModal from './ApproveTxModal'
import { styles } from './style'
import { formatDate } from '../columns'
type Props = {
classes: Object,
tx: Transaction,
threshold: number,
owners: List<Owner>,
granted: boolean,
userAddress: string,
safeAddress: string,
createTransaction: Function,
processTransaction: Function,
}
type OpenModal = 'cancelTx' | 'approveTx' | null
const openIconStyle = {
height: '13px',
color: secondary,
}
const txStatusToLabel = {
success: 'Success',
awaiting_confirmations: 'Awaiting confirmations',
cancelled: 'Cancelled',
awaiting_execution: 'Awaiting execution',
}
const ExpandedTx = ({
classes,
tx,
threshold,
owners,
granted,
userAddress,
safeAddress,
createTransaction,
processTransaction,
}: Props) => {
const [openModal, setOpenModal] = useState<OpenModal>(null)
const openApproveModal = () => setOpenModal('approveTx')
const openCancelModal = () => setOpenModal('cancelTx')
const closeModal = () => setOpenModal(null)
const thresholdReached = threshold <= tx.confirmations.size
return (
<>
<Block>
<Row>
<Col xs={6} layout="column">
<Block className={classes.txDataContainer}>
<Paragraph noMargin>
<Bold>TX hash: </Bold>
{tx.executionTxHash ? (
<a href={getEtherScanLink('tx', tx.executionTxHash, 'rinkeby')} target="_blank" rel="noopener noreferrer">
{shortVersionOf(tx.executionTxHash, 4)}
<OpenInNew style={openIconStyle} />
</a>
) : (
'n/a'
)}
</Paragraph>
<Paragraph noMargin>
<Bold>TX status: </Bold>
<Span className={classes[tx.status]} style={{ fontWeight: 'bold' }}>
{txStatusToLabel[tx.status]}
</Span>
</Paragraph>
<Paragraph noMargin>
<Bold>TX created: </Bold>
{formatDate(tx.submissionDate)}
</Paragraph>
{tx.executionDate && (
<Paragraph noMargin>
<Bold>TX executed: </Bold>
{formatDate(tx.executionDate)}
</Paragraph>
)}
</Block>
<Hairline />
<TxDescription tx={tx} />
</Col>
<OwnersColumn
tx={tx}
owners={owners}
granted={granted}
threshold={threshold}
userAddress={userAddress}
thresholdReached={thresholdReached}
safeAddress={safeAddress}
onTxConfirm={openApproveModal}
onTxCancel={openCancelModal}
onTxExecute={openApproveModal}
/>
</Row>
</Block>
<CancelTxModal
isOpen={openModal === 'cancelTx'}
createTransaction={createTransaction}
onClose={closeModal}
tx={tx}
safeAddress={safeAddress}
/>
<ApproveTxModal
isOpen={openModal === 'approveTx'}
processTransaction={processTransaction}
onClose={closeModal}
tx={tx}
userAddress={userAddress}
safeAddress={safeAddress}
threshold={threshold}
thresholdReached={thresholdReached}
/>
</>
)
}
export default withStyles(styles)(ExpandedTx)

View File

@ -0,0 +1,22 @@
// @flow
import {
md, lg, connected, error,
} from '~/theme/variables'
export const styles = () => ({
txDataContainer: {
padding: `${lg} ${md}`,
},
awaiting_confirmations: {
color: '#2e73d9',
},
awaiting_execution: {
color: '#2e73d9',
},
success: {
color: connected,
},
cancelled: {
color: error,
},
})

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="13" viewBox="0 0 14 13">
<path fill="#2E73D9" fill-rule="nonzero" d="M2.332 0C1.412 0 .666.746.666 1.666v.333A.666.666 0 0 0 0 2.665V5.33c0 .368.298.666.666.666h3.332a.666.666 0 0 0 .666-.666V2.665A.666.666 0 0 0 3.998 2v-.333C3.998.746 3.252 0 2.332 0zm0 .666a1 1 0 0 1 1 1v.333h-2v-.333a1 1 0 0 1 1-1zm9.434 0a.634.634 0 0 0-.46.187l-1.225 1.232 2.498 2.499 1.226-1.233a.655.655 0 0 0 0-.932L12.246.853a.703.703 0 0 0-.48-.187zM9.368 2.792l-7.37 7.369v2.498h2.5l7.368-7.369-2.498-2.498z"/>
</svg>

After

Width:  |  Height:  |  Size: 562 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">
<path fill="#FD7890" fill-rule="nonzero" d="M7.7 7.7H6.3V3.5h1.4v4.2zm0 2.8H6.3V9.1h1.4v1.4zM7 0a7 7 0 1 0 0 14A7 7 0 0 0 7 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">
<path fill="#346D6D" fill-rule="nonzero" d="M7 0a7 7 0 1 1 0 14A7 7 0 0 1 7 0zm-.913 9.8L11.2 5.139 10.17 4.2 6.087 7.916 3.83 5.865l-1.03.939L6.087 9.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

View File

@ -0,0 +1,44 @@
// @flow
import * as React from 'react'
import { withStyles } from '@material-ui/core/styles'
import Block from '~/components/layout/Block'
import Paragraph from '~/components/layout/Paragraph/'
import Img from '~/components/layout/Img'
import { type TransactionStatus } from '~/routes/safe/store/models/transaction'
import ErrorIcon from './assets/error.svg'
import OkIcon from './assets/ok.svg'
import AwaitingIcon from './assets/awaiting.svg'
import { styles } from './style'
type Props = {
classes: Object,
status: TransactionStatus,
}
const statusToIcon = {
success: OkIcon,
cancelled: ErrorIcon,
awaiting_confirmations: AwaitingIcon,
awaiting_execution: AwaitingIcon,
}
const statusToLabel = {
success: 'Success',
cancelled: 'Cancelled',
awaiting_confirmations: 'Awaiting',
awaiting_execution: 'Awaiting',
}
const statusIconStyle = {
height: '14px',
width: '14px',
}
const Status = ({ classes, status }: Props) => (
<Block className={`${classes.container} ${classes[status]}`}>
<Img src={statusToIcon[status]} alt="OK Icon" style={statusIconStyle} />
<Paragraph noMargin className={classes.statusText}>{statusToLabel[status]}</Paragraph>
</Block>
)
export default withStyles(styles)(Status)

View File

@ -0,0 +1,34 @@
// @flow
import { smallFontSize, boldFont, sm } from '~/theme/variables'
export const styles = () => ({
container: {
display: 'flex',
fontSize: smallFontSize,
fontWeight: boldFont,
width: '100px',
padding: sm,
alignItems: 'center',
boxSizing: 'border-box',
},
success: {
backgroundColor: '#d7f3f3',
color: '#346d6d',
},
cancelled: {
backgroundColor: 'transparent',
color: '#fd7890',
},
awaiting_confirmations: {
backgroundColor: '#dfebff',
color: '#2e73d9',
},
awaiting_execution: {
backgroundColor: '#dfebff',
color: '#2e73d9',
},
statusText: {
marginLeft: 'auto',
textTransform: 'uppercase',
},
})

View File

@ -0,0 +1,124 @@
// @flow
import { format, getTime } from 'date-fns'
import { List } from 'immutable'
import { type Transaction } from '~/routes/safe/store/models/transaction'
import { type SortRow, buildOrderFieldFrom } from '~/components/Table/sorting'
import { type Column } from '~/components/Table/TableHead'
import { getWeb3 } from '~/logic/wallets/getWeb3'
export const TX_TABLE_NONCE_ID = 'nonce'
export const TX_TABLE_TYPE_ID = 'type'
export const TX_TABLE_DATE_ID = 'date'
export const TX_TABLE_AMOUNT_ID = 'amount'
export const TX_TABLE_STATUS_ID = 'status'
export const TX_TABLE_RAW_TX_ID = 'tx'
export const TX_TABLE_EXPAND_ICON = 'expand'
const web3 = getWeb3()
const { toBN, fromWei } = web3.utils
type TxData = {
nonce: number,
type: string,
date: string,
amount: number | string,
tx: Transaction,
status?: string,
}
export const formatDate = (date: Date): string => format(date, 'MMM D, YYYY - HH:mm:ss')
export const getTxAmount = (tx: Transaction) => {
let txAmount = 'n/a'
if (tx.isTokenTransfer && tx.decodedParams) {
txAmount = `${fromWei(toBN(tx.decodedParams.value), 'ether')} ${tx.symbol}`
} else if (Number(tx.value) > 0) {
txAmount = `${fromWei(toBN(tx.value), 'ether')} ${tx.symbol}`
}
return txAmount
}
export type TransactionRow = SortRow<TxData>
export const getTxTableData = (transactions: List<Transaction>): List<TransactionRow> => {
const rows = transactions.map((tx: Transaction) => {
const txDate = tx.isExecuted ? tx.executionDate : tx.submissionDate
let txType = 'Outgoing transfer'
if (tx.modifySettingsTx) {
txType = 'Modify Safe Settings'
} else if (tx.cancellationTx) {
txType = 'Cancellation transaction'
}
return {
[TX_TABLE_NONCE_ID]: tx.nonce,
[TX_TABLE_TYPE_ID]: txType,
[TX_TABLE_DATE_ID]: formatDate(tx.isExecuted ? tx.executionDate : tx.submissionDate),
[buildOrderFieldFrom(TX_TABLE_DATE_ID)]: getTime(txDate),
[TX_TABLE_AMOUNT_ID]: getTxAmount(tx),
[TX_TABLE_STATUS_ID]: tx.status,
[TX_TABLE_RAW_TX_ID]: tx,
}
})
return rows
}
export const generateColumns = () => {
const nonceColumn: Column = {
id: TX_TABLE_NONCE_ID,
disablePadding: false,
label: 'Nonce',
custom: false,
order: false,
width: 50,
}
const typeColumn: Column = {
id: TX_TABLE_TYPE_ID,
order: false,
disablePadding: false,
label: 'Type',
custom: false,
width: 200,
}
const valueColumn: Column = {
id: TX_TABLE_AMOUNT_ID,
order: false,
disablePadding: false,
label: 'Amount',
custom: false,
width: 100,
}
const dateColumn: Column = {
id: TX_TABLE_DATE_ID,
disablePadding: false,
order: true,
label: 'Date',
custom: false,
}
const statusColumn: Column = {
id: TX_TABLE_STATUS_ID,
order: false,
disablePadding: false,
label: 'Status',
custom: true,
}
const expandIconColumn: Column = {
id: TX_TABLE_EXPAND_ICON,
order: false,
disablePadding: true,
label: '',
custom: true,
width: 50,
static: true,
}
return List([nonceColumn, typeColumn, valueColumn, dateColumn, statusColumn, expandIconColumn])
}

View File

@ -0,0 +1,131 @@
// @flow
import React, { useState } from 'react'
import cn from 'classnames'
import { List } from 'immutable'
import Collapse from '@material-ui/core/Collapse'
import IconButton from '@material-ui/core/IconButton'
import ExpandLess from '@material-ui/icons/ExpandLess'
import ExpandMore from '@material-ui/icons/ExpandMore'
import TableRow from '@material-ui/core/TableRow'
import TableCell from '@material-ui/core/TableCell'
import { withStyles } from '@material-ui/core/styles'
import Block from '~/components/layout/Block'
import Row from '~/components/layout/Row'
import { type Column, cellWidth } from '~/components/Table/TableHead'
import Table from '~/components/Table'
import { type Transaction } from '~/routes/safe/store/models/transaction'
import { type Owner } from '~/routes/safe/store/models/owner'
import ExpandedTxComponent from './ExpandedTx'
import {
getTxTableData, generateColumns, TX_TABLE_DATE_ID, type TransactionRow, TX_TABLE_RAW_TX_ID,
} from './columns'
import { styles } from './style'
import Status from './Status'
const expandCellStyle = {
paddingLeft: 0,
paddingRight: 0,
}
type Props = {
classes: Object,
transactions: List<Transaction>,
threshold: number,
owners: List<Owner>,
userAddress: string,
granted: boolean,
safeAddress: string,
createTransaction: Function,
processTransaction: Function,
}
const TxsTable = ({
classes,
transactions,
threshold,
owners,
granted,
userAddress,
safeAddress,
createTransaction,
processTransaction,
}: Props) => {
const [expandedTx, setExpandedTx] = useState<string | null>(null)
const handleTxExpand = (creationTxHash) => {
setExpandedTx(prevTx => (prevTx === creationTxHash ? null : creationTxHash))
}
const columns = generateColumns()
const autoColumns = columns.filter(c => !c.custom)
const filteredData = getTxTableData(transactions)
return (
<Block className={classes.container}>
<Table
label="Transactions"
defaultOrderBy={TX_TABLE_DATE_ID}
defaultOrder="desc"
columns={columns}
data={filteredData}
size={filteredData.size}
defaultFixed
>
{(sortedData: Array<TransactionRow>) => sortedData.map((row: any, index: number) => (
<React.Fragment key={index}>
<TableRow
tabIndex={-1}
className={cn(classes.row, expandedTx === row.tx.creationTxHash && classes.expandedRow)}
onClick={() => handleTxExpand(row.tx.creationTxHash)}
>
{autoColumns.map((column: Column) => (
<TableCell
key={column.id}
className={classes.cell}
style={cellWidth(column.width)}
align={column.align}
component="td"
>
{row[column.id]}
</TableCell>
))}
<TableCell component="td">
<Row align="end" className={classes.actions}>
<Status status={row.status} />
</Row>
</TableCell>
<TableCell style={expandCellStyle}>
<IconButton disableRipple>{expandedTx === row.nonce ? <ExpandLess /> : <ExpandMore />}</IconButton>
</TableCell>
</TableRow>
<TableRow>
<TableCell
style={{ paddingBottom: 0, paddingTop: 0 }}
colSpan={6}
className={classes.extendedTxContainer}
>
<Collapse
in={expandedTx === row.tx.creationTxHash}
timeout="auto"
component={ExpandedTxComponent}
unmountOnExit
tx={row[TX_TABLE_RAW_TX_ID]}
threshold={threshold}
owners={owners}
granted={granted}
userAddress={userAddress}
createTransaction={createTransaction}
processTransaction={processTransaction}
safeAddress={safeAddress}
/>
</TableCell>
</TableRow>
</React.Fragment>
))
}
</Table>
</Block>
)
}
export default withStyles(styles)(TxsTable)

View File

@ -0,0 +1,24 @@
// @flow
import { lg } from '~/theme/variables'
export const styles = () => ({
container: {
marginTop: lg,
},
row: {
cursor: 'pointer',
'&:hover': {
backgroundColor: '#fff3e2',
},
},
expandedRow: {
backgroundColor: '#fff3e2',
},
extendedTxContainer: {
padding: 0,
'&:last-child': {
padding: 0,
},
backgroundColor: '#fffaf4',
},
})

View File

@ -0,0 +1,58 @@
// @flow
import React, { useEffect } from 'react'
import { List } from 'immutable'
import NoTransactions from '~/routes/safe/components/TransactionsNew/NoTransactions'
import TxsTable from '~/routes/safe/components/TransactionsNew/TxsTable'
import { type Transaction } from '~/routes/safe/store/models/transaction'
import { type Owner } from '~/routes/safe/store/models/owner'
type Props = {
safeAddress: string,
threshold: number,
fetchTransactions: Function,
transactions: List<Transaction>,
owners: List<Owner>,
userAddress: string,
granted: boolean,
createTransaction: Function,
processTransaction: Function,
}
const Transactions = ({
transactions = List(),
owners,
threshold,
userAddress,
granted,
safeAddress,
createTransaction,
processTransaction,
fetchTransactions,
}: Props) => {
useEffect(() => {
fetchTransactions(safeAddress)
}, [safeAddress])
const hasTransactions = transactions.size > 0
return (
<React.Fragment>
{hasTransactions ? (
<TxsTable
transactions={transactions}
threshold={threshold}
owners={owners}
userAddress={userAddress}
granted={granted}
safeAddress={safeAddress}
createTransaction={createTransaction}
processTransaction={processTransaction}
/>
) : (
<NoTransactions />
)}
</React.Fragment>
)
}
export default Transactions

View File

@ -2,18 +2,27 @@
import fetchSafe from '~/routes/safe/store/actions/fetchSafe' import fetchSafe from '~/routes/safe/store/actions/fetchSafe'
import fetchTokenBalances from '~/routes/safe/store/actions/fetchTokenBalances' import fetchTokenBalances from '~/routes/safe/store/actions/fetchTokenBalances'
import createTransaction from '~/routes/safe/store/actions/createTransaction' import createTransaction from '~/routes/safe/store/actions/createTransaction'
import processTransaction from '~/routes/safe/store/actions/processTransaction'
import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
import updateSafe from '~/routes/safe/store/actions/updateSafe' import updateSafe from '~/routes/safe/store/actions/updateSafe'
import fetchTokens from '~/logic/tokens/store/actions/fetchTokens'
export type Actions = { export type Actions = {
fetchSafe: typeof fetchSafe, fetchSafe: typeof fetchSafe,
fetchTokenBalances: typeof fetchTokenBalances, fetchTokenBalances: typeof fetchTokenBalances,
createTransaction: typeof createTransaction, createTransaction: typeof createTransaction,
fetchTransactions: typeof fetchTransactions,
updateSafe: typeof updateSafe, updateSafe: typeof updateSafe,
fetchTokens: typeof fetchTokens,
processTransaction: typeof processTransaction,
} }
export default { export default {
fetchSafe, fetchSafe,
fetchTokenBalances, fetchTokenBalances,
createTransaction, createTransaction,
processTransaction,
fetchTokens,
fetchTransactions,
updateSafe, updateSafe,
} }

View File

@ -16,11 +16,13 @@ const TIMEOUT = process.env.NODE_ENV === 'test' ? 1500 : 5000
class SafeView extends React.Component<Props> { class SafeView extends React.Component<Props> {
componentDidMount() { componentDidMount() {
const { const {
fetchSafe, activeTokens, safeUrl, fetchTokenBalances, fetchSafe, activeTokens, safeUrl, fetchTokenBalances, fetchTokens,
} = this.props } = this.props
fetchSafe(safeUrl) fetchSafe(safeUrl)
fetchTokenBalances(safeUrl, activeTokens) fetchTokenBalances(safeUrl, activeTokens)
// fetch tokens there to get symbols for tokens in TXs list
fetchTokens()
this.intervalId = setInterval(() => { this.intervalId = setInterval(() => {
this.checkForUpdates() this.checkForUpdates()
@ -61,7 +63,10 @@ class SafeView extends React.Component<Props> {
network, network,
tokens, tokens,
createTransaction, createTransaction,
processTransaction,
fetchTransactions,
updateSafe, updateSafe,
transactions,
} = this.props } = this.props
return ( return (
@ -75,7 +80,10 @@ class SafeView extends React.Component<Props> {
network={network} network={network}
granted={granted} granted={granted}
createTransaction={createTransaction} createTransaction={createTransaction}
processTransaction={processTransaction}
fetchTransactions={fetchTransactions}
updateSafe={updateSafe} updateSafe={updateSafe}
transactions={transactions}
/> />
</Page> </Page>
) )

View File

@ -1,6 +1,7 @@
// @flow // @flow
import { List, Map } from 'immutable' import { List, Map } from 'immutable'
import { createSelector, createStructuredSelector, type Selector } from 'reselect' import { createSelector, createStructuredSelector, type Selector } from 'reselect'
import { isAfter } from 'date-fns'
import { import {
safeSelector, safeSelector,
safeActiveTokensSelector, safeActiveTokensSelector,
@ -13,8 +14,10 @@ import { type Safe } from '~/routes/safe/store/models/safe'
import { type Owner } from '~/routes/safe/store/models/owner' import { type Owner } from '~/routes/safe/store/models/owner'
import { type GlobalState } from '~/store' import { type GlobalState } from '~/store'
import { sameAddress } from '~/logic/wallets/ethAddresses' import { sameAddress } from '~/logic/wallets/ethAddresses'
import { safeTransactionsSelector } from '~/routes/safe/store/selectors/index'
import { orderedTokenListSelector, tokensSelector } from '~/logic/tokens/store/selectors' import { orderedTokenListSelector, tokensSelector } from '~/logic/tokens/store/selectors'
import { type Token } from '~/logic/tokens/store/model/token' import { type Token } from '~/logic/tokens/store/model/token'
import { type Transaction, type TransactionStatus } from '~/routes/safe/store/models/transaction'
import { type TokenBalance } from '~/routes/safe/store/models/tokenBalance' import { type TokenBalance } from '~/routes/safe/store/models/tokenBalance'
import { safeParamAddressSelector } from '../store/selectors' import { safeParamAddressSelector } from '../store/selectors'
import { getEthAsToken } from '~/logic/tokens/utils/tokenHelpers' import { getEthAsToken } from '~/logic/tokens/utils/tokenHelpers'
@ -27,6 +30,21 @@ export type SelectorProps = {
userAddress: string, userAddress: string,
network: string, network: string,
safeUrl: string, safeUrl: string,
transactions: List<Transaction>,
}
const getTxStatus = (tx: Transaction, safe: Safe): TransactionStatus => {
let txStatus = 'awaiting_confirmations'
if (tx.executionTxHash) {
txStatus = 'success'
} else if (tx.cancelled) {
txStatus = 'cancelled'
} else if (tx.confirmations.size === safe.threshold) {
txStatus = 'awaiting_execution'
}
return txStatus
} }
export const grantedSelector: Selector<GlobalState, RouterProps, boolean> = createSelector( export const grantedSelector: Selector<GlobalState, RouterProps, boolean> = createSelector(
@ -86,6 +104,32 @@ const extendedSafeTokensSelector: Selector<GlobalState, RouterProps, List<Token>
}, },
) )
const extendedTransactionsSelector: Selector<GlobalState, RouterProps, List<Transaction>> = createSelector(
safeSelector,
safeTransactionsSelector,
(safe, transactions) => {
const extendedTransactions = transactions.map((tx: Transaction) => {
let extendedTx = tx
// If transactions is not executed, but there's a transaction with the same nonce submitted later
// it means that the transaction was cancelled (Replaced) and shouldn't get executed
let replacementTransaction
if (!tx.isExecuted) {
replacementTransaction = transactions.findLast(
transaction => transaction.nonce === tx.nonce && isAfter(transaction.submissionDate, tx.submissionDate),
)
if (replacementTransaction) {
extendedTx = tx.set('cancelled', true)
}
}
return extendedTx.set('status', getTxStatus(extendedTx, safe))
})
return extendedTransactions
},
)
export default createStructuredSelector<Object, *>({ export default createStructuredSelector<Object, *>({
safe: safeSelector, safe: safeSelector,
provider: providerNameSelector, provider: providerNameSelector,
@ -95,4 +139,5 @@ export default createStructuredSelector<Object, *>({
userAddress: userAccountSelector, userAddress: userAccountSelector,
network: networkSelector, network: networkSelector,
safeUrl: safeParamAddressSelector, safeUrl: safeParamAddressSelector,
transactions: extendedTransactionsSelector,
}) })

View File

@ -0,0 +1,5 @@
// @flow
import { createAction } from 'redux-actions'
export const ADD_TRANSACTIONS = 'ADD_TRANSACTIONS'
export const addTransactions = createAction<string, *>(ADD_TRANSACTIONS)

View File

@ -1,14 +1,11 @@
// @flow // @flow
import type { Dispatch as ReduxDispatch, GetState } from 'redux' import type { Dispatch as ReduxDispatch, GetState } from 'redux'
import { createAction } from 'redux-actions'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions' import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import { userAccountSelector } from '~/logic/wallets/store/selectors' import { userAccountSelector } from '~/logic/wallets/store/selectors'
import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
import { type GlobalState } from '~/store' import { type GlobalState } from '~/store'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts' import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { executeTransaction, CALL } from '~/logic/safe/transactions' import { approveTransaction, executeTransaction, CALL } from '~/logic/safe/transactions'
export const ADD_TRANSACTIONS = 'ADD_TRANSACTIONS'
export const addTransactions = createAction<string, *>(ADD_TRANSACTIONS)
const createTransaction = ( const createTransaction = (
safeAddress: string, safeAddress: string,
@ -16,14 +13,15 @@ const createTransaction = (
valueInWei: string, valueInWei: string,
txData: string = EMPTY_DATA, txData: string = EMPTY_DATA,
openSnackbar: Function, openSnackbar: Function,
shouldExecute?: boolean,
) => async (dispatch: ReduxDispatch<GlobalState>, getState: GetState<GlobalState>) => { ) => async (dispatch: ReduxDispatch<GlobalState>, getState: GetState<GlobalState>) => {
const state: GlobalState = getState() const state: GlobalState = getState()
const safeInstance = await getGnosisSafeInstanceAt(safeAddress) const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
const from = userAccountSelector(state) const from = userAccountSelector(state)
const threshold = await safeInstance.getThreshold() const threshold = await safeInstance.getThreshold()
const nonce = await safeInstance.nonce() const nonce = (await safeInstance.nonce()).toString()
const isExecution = threshold.toNumber() === 1 const isExecution = threshold.toNumber() === 1 || shouldExecute
let txHash let txHash
if (isExecution) { if (isExecution) {
@ -31,10 +29,14 @@ const createTransaction = (
txHash = await executeTransaction(safeInstance, to, valueInWei, txData, CALL, nonce, from) txHash = await executeTransaction(safeInstance, to, valueInWei, txData, CALL, nonce, from)
openSnackbar('Transaction has been confirmed', 'success') openSnackbar('Transaction has been confirmed', 'success')
} else { } else {
console.log('Temporal error: threshold != 1') openSnackbar('Approval transaction has been submitted', 'success')
// txHash = await approveTransaction(safeAddress, to, valueInWei, txData, CALL, nonce) txHash = await approveTransaction(safeInstance, to, valueInWei, txData, CALL, nonce, from)
openSnackbar('Approval transaction has been confirmed', 'success')
}
if (!process.env.NODE_ENV === 'test') {
dispatch(fetchTransactions(safeAddress))
} }
// dispatch(addTransactions(txHash))
return txHash return txHash
} }

View File

@ -1,74 +1,123 @@
// // @flow // @flow
// import { List, Map } from 'immutable' import { List, Map } from 'immutable'
// import axios from 'axios' import axios from 'axios'
// import type { Dispatch as ReduxDispatch } from 'redux' import type { Dispatch as ReduxDispatch } from 'redux'
// import { type GlobalState } from '~/store/index' import { type GlobalState } from '~/store/index'
// import { makeOwner } from '~/routes/safe/store/models/owner' import { makeOwner } from '~/routes/safe/store/models/owner'
// import { makeTransaction, type Transaction } from '~/routes/safe/store/models/transaction' import { makeTransaction, type Transaction } from '~/routes/safe/store/models/transaction'
// import { makeConfirmation } from '~/routes/safe/store/models/confirmation' import { makeConfirmation } from '~/routes/safe/store/models/confirmation'
// import { loadSafeSubjects } from '~/utils/storage/transactions' import { loadSafeSubjects } from '~/utils/storage/transactions'
// import { buildTxServiceUrlFrom, type TxServiceType } from '~/logic/safe/safeTxHistory' import { buildTxServiceUrl, type TxServiceType } from '~/logic/safe/transactions/txHistory'
// import { getOwners } from '~/logic/safe/utils' import { getOwners } from '~/logic/safe/utils'
// import { EMPTY_DATA } from '~/logic/wallets/ethTransactions' import { getWeb3 } from '~/logic/wallets/getWeb3'
// import addTransactions from './addTransactions' import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import { addTransactions } from './addTransactions'
import { getHumanFriendlyToken } from '~/logic/tokens/store/actions/fetchTokens'
import { isAddressAToken } from '~/logic/tokens/utils/tokenHelpers'
import { TX_TYPE_EXECUTION } from '~/logic/safe/transactions/send'
import { decodeParamsFromSafeMethod } from '~/logic/contracts/methodIds'
// type ConfirmationServiceModel = { let web3
// owner: string,
// submissionDate: Date,
// type: string,
// transactionHash: string,
// }
// type TxServiceModel = { type ConfirmationServiceModel = {
// to: string, owner: string,
// value: number, submissionDate: Date,
// data: string, confirmationType: string,
// operation: number, transactionHash: string,
// nonce: number, }
// submissionDate: Date,
// executionDate: Date,
// confirmations: ConfirmationServiceModel[],
// isExecuted: boolean,
// }
// const buildTransactionFrom = (safeAddress: string, tx: TxServiceModel, safeSubjects: Map<string, string>) => { type TxServiceModel = {
// const name = safeSubjects.get(String(tx.nonce)) || 'Unknown' to: string,
// const storedOwners = getOwners(safeAddress) value: number,
// const confirmations = List( data: string,
// tx.confirmations.map((conf: ConfirmationServiceModel) => { operation: number,
// const ownerName = storedOwners.get(conf.owner.toLowerCase()) || 'UNKNOWN' nonce: number,
submissionDate: Date,
executionDate: Date,
confirmations: ConfirmationServiceModel[],
isExecuted: boolean,
}
// return makeConfirmation({ const buildTransactionFrom = async (safeAddress: string, tx: TxServiceModel, safeSubjects: Map<string, string>) => {
// owner: makeOwner({ address: conf.owner, name: ownerName }), const name = safeSubjects.get(String(tx.nonce)) || 'Unknown'
// type: ((conf.type.toLowerCase(): any): TxServiceType), const storedOwners = await getOwners(safeAddress)
// hash: conf.transactionHash, const confirmations = List(
// }) tx.confirmations.map((conf: ConfirmationServiceModel) => {
// }), const ownerName = storedOwners.get(conf.owner.toLowerCase()) || 'UNKNOWN'
// )
// return makeTransaction({ return makeConfirmation({
// name, owner: makeOwner({ address: conf.owner, name: ownerName }),
// nonce: tx.nonce, type: ((conf.confirmationType.toLowerCase(): any): TxServiceType),
// value: Number(tx.value), hash: conf.transactionHash,
// confirmations, })
// destination: tx.to, }),
// data: tx.data ? tx.data : EMPTY_DATA, )
// isExecuted: tx.isExecuted, const modifySettingsTx = tx.to === safeAddress && Number(tx.value) === 0 && !!tx.data
// }) const cancellationTx = tx.to === safeAddress && Number(tx.value) === 0 && !tx.data
// } const isTokenTransfer = await isAddressAToken(tx.to)
const creationTxHash = confirmations.last().hash
// export const loadSafeTransactions = async (safeAddress: string) => { let executionTxHash
// const url = buildTxServiceUrlFrom(safeAddress) const executionTx = confirmations.find(conf => conf.type === TX_TYPE_EXECUTION)
// const response = await axios.get(url)
// const transactions: TxServiceModel[] = response.data.results
// const safeSubjects = loadSafeSubjects(safeAddress)
// const txsRecord = transactions.map((tx: TxServiceModel) => buildTransactionFrom(safeAddress, tx, safeSubjects))
// return Map().set(safeAddress, List(txsRecord)) if (executionTx) {
// } executionTxHash = executionTx.hash
}
// export default (safeAddress: string) => async (dispatch: ReduxDispatch<GlobalState>) => { let symbol = 'ETH'
// const transactions: Map<string, List<Transaction>> = await loadSafeTransactions(safeAddress) let decodedParams
if (isTokenTransfer) {
const tokenContract = await getHumanFriendlyToken()
const tokenInstance = await tokenContract.at(tx.to)
symbol = await tokenInstance.symbol()
// return dispatch(addTransactions(transactions)) const params = web3.eth.abi.decodeParameters(['address', 'uint256'], tx.data.slice(10))
// } decodedParams = {
recipient: params[0],
value: params[1],
}
} else if (
modifySettingsTx && tx.data
) {
decodedParams = await decodeParamsFromSafeMethod(tx.data)
}
return makeTransaction({
name,
symbol,
nonce: tx.nonce,
value: tx.value.toString(),
confirmations,
recipient: tx.to,
data: tx.data ? tx.data : EMPTY_DATA,
isExecuted: tx.isExecuted,
submissionDate: tx.submissionDate,
executionDate: tx.executionDate,
executionTxHash,
creationTxHash,
isTokenTransfer,
decodedParams,
modifySettingsTx,
cancellationTx,
})
}
export const loadSafeTransactions = async (safeAddress: string) => {
web3 = await getWeb3()
const url = buildTxServiceUrl(safeAddress)
const response = await axios.get(url)
const transactions: TxServiceModel[] = response.data.results
const safeSubjects = loadSafeSubjects(safeAddress)
const txsRecord = await Promise.all(
transactions.map((tx: TxServiceModel) => buildTransactionFrom(safeAddress, tx, safeSubjects)),
)
return Map().set(safeAddress, List(txsRecord))
}
export default (safeAddress: string) => async (dispatch: ReduxDispatch<GlobalState>) => {
const transactions: Map<string, List<Transaction>> = await loadSafeTransactions(safeAddress)
return dispatch(addTransactions(transactions))
}

View File

@ -0,0 +1,66 @@
// @flow
import type { Dispatch as ReduxDispatch, GetState } from 'redux'
import { type Transaction } from '~/routes/safe/store/models/transaction'
import { userAccountSelector } from '~/logic/wallets/store/selectors'
import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
import { type GlobalState } from '~/store'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { approveTransaction, executeTransaction, CALL } from '~/logic/safe/transactions'
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
// https://github.com/gnosis/safe-contracts/blob/master/test/gnosisSafeTeamEdition.js#L26
const generateSignaturesFromTxConfirmations = (tx: Transaction, preApprovingOwner?: string) => {
// The constant parts need to be sorted so that the recovered signers are sorted ascending
// (natural order) by address (not checksummed).
let confirmedAdresses = tx.confirmations.map(conf => conf.owner.address)
if (preApprovingOwner) {
confirmedAdresses = confirmedAdresses.push(preApprovingOwner)
}
let sigs = '0x'
confirmedAdresses.sort().forEach((addr) => {
sigs += `000000000000000000000000${addr.replace(
'0x',
'',
)}000000000000000000000000000000000000000000000000000000000000000001`
})
return sigs
}
const processTransaction = (
safeAddress: string,
tx: Transaction,
openSnackbar: Function,
userAddress: string,
approveAndExecute?: boolean,
) => async (dispatch: ReduxDispatch<GlobalState>, getState: GetState<GlobalState>) => {
const state: GlobalState = getState()
const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
const from = userAccountSelector(state)
const nonce = (await safeInstance.nonce()).toString()
const threshold = (await safeInstance.getThreshold()).toNumber()
const shouldExecute = threshold === tx.confirmations.size || approveAndExecute
const sigs = generateSignaturesFromTxConfirmations(tx, approveAndExecute && userAddress)
let txHash
if (shouldExecute) {
openSnackbar('Transaction has been submitted', 'success')
txHash = await executeTransaction(safeInstance, tx.recipient, tx.value, tx.data, CALL, nonce, from, sigs)
openSnackbar('Transaction has been confirmed', 'success')
} else {
openSnackbar('Approval transaction has been submitted', 'success')
txHash = await approveTransaction(safeInstance, tx.recipient, tx.value, tx.data, CALL, nonce, from)
openSnackbar('Approval transaction has been confirmed', 'success')
}
if (!process.env.NODE_ENV === 'test') {
dispatch(fetchTransactions(safeAddress))
}
return txHash
}
export default processTransaction

View File

@ -0,0 +1,10 @@
// @flow
import type { Dispatch as ReduxDispatch } from 'redux'
import { type GlobalState } from '~/store'
import updateSafe from './updateSafe'
const updateSafeName = (safeAddress: string, safeName: string) => async (dispatch: ReduxDispatch<GlobalState>) => {
dispatch(updateSafe({ address: safeAddress, name: safeName }))
}
export default updateSafeName

View File

@ -1,6 +1,5 @@
// @flow // @flow
import type { Store, AnyAction } from 'redux' import type { Store, AnyAction } from 'redux'
import { List } from 'immutable'
import { ADD_SAFE } from '~/routes/safe/store/actions/addSafe' import { ADD_SAFE } from '~/routes/safe/store/actions/addSafe'
import { UPDATE_SAFE } from '~/routes/safe/store/actions/updateSafe' import { UPDATE_SAFE } from '~/routes/safe/store/actions/updateSafe'
import { REMOVE_SAFE } from '~/routes/safe/store/actions/removeSafe' import { REMOVE_SAFE } from '~/routes/safe/store/actions/removeSafe'
@ -75,13 +74,13 @@ const safeStorageMware = (store: Store<GlobalState>) => (next: Function) => asyn
} }
case ADD_SAFE_OWNER: { case ADD_SAFE_OWNER: {
const { safeAddress, ownerAddress, ownerName } = action.payload const { safeAddress, ownerAddress, ownerName } = action.payload
const owners = List(safes.get(safeAddress).owners) const { owners } = safes.get(safeAddress)
setOwners(safeAddress, owners.push(makeOwner({ address: ownerAddress, name: ownerName }))) setOwners(safeAddress, owners.push(makeOwner({ address: ownerAddress, name: ownerName })))
break break
} }
case REMOVE_SAFE_OWNER: { case REMOVE_SAFE_OWNER: {
const { safeAddress, ownerAddress } = action.payload const { safeAddress, ownerAddress } = action.payload
const owners = List(safes.get(safeAddress).owners) const { owners } = safes.get(safeAddress)
setOwners(safeAddress, owners.filter(o => o.address.toLowerCase() !== ownerAddress.toLowerCase())) setOwners(safeAddress, owners.filter(o => o.address.toLowerCase() !== ownerAddress.toLowerCase()))
break break
} }
@ -89,7 +88,7 @@ const safeStorageMware = (store: Store<GlobalState>) => (next: Function) => asyn
const { const {
safeAddress, ownerAddress, ownerName, oldOwnerAddress, safeAddress, ownerAddress, ownerName, oldOwnerAddress,
} = action.payload } = action.payload
const owners = List(safes.get(safeAddress).owners) const { owners } = safes.get(safeAddress)
setOwners( setOwners(
safeAddress, safeAddress,
owners owners
@ -100,7 +99,7 @@ const safeStorageMware = (store: Store<GlobalState>) => (next: Function) => asyn
} }
case EDIT_SAFE_OWNER: { case EDIT_SAFE_OWNER: {
const { safeAddress, ownerAddress, ownerName } = action.payload const { safeAddress, ownerAddress, ownerName } = action.payload
const owners = List(safes.get(safeAddress).owners) const { owners } = safes.get(safeAddress)
const ownerToUpdateIndex = owners.findIndex(o => o.address.toLowerCase() === ownerAddress.toLowerCase()) const ownerToUpdateIndex = owners.findIndex(o => o.address.toLowerCase() === ownerAddress.toLowerCase())
setOwners(safeAddress, owners.update(ownerToUpdateIndex, owner => owner.set('name', ownerName))) setOwners(safeAddress, owners.update(ownerToUpdateIndex, owner => owner.set('name', ownerName)))
break break

View File

@ -2,7 +2,7 @@
import { Record } from 'immutable' import { Record } from 'immutable'
import type { RecordFactory, RecordOf } from 'immutable' import type { RecordFactory, RecordOf } from 'immutable'
import { makeOwner, type Owner } from '~/routes/safe/store/models/owner' import { makeOwner, type Owner } from '~/routes/safe/store/models/owner'
import { type TxServiceType } from '~/logic/safe/safeTxHistory' import { type TxServiceType } from '~/logic/safe/transactions/txHistory'
export type ConfirmationProps = { export type ConfirmationProps = {
owner: Owner, owner: Owner,

View File

@ -3,14 +3,27 @@ import { List, Record } from 'immutable'
import type { RecordFactory, RecordOf } from 'immutable' import type { RecordFactory, RecordOf } from 'immutable'
import { type Confirmation } from '~/routes/safe/store/models/confirmation' import { type Confirmation } from '~/routes/safe/store/models/confirmation'
export type TransactionStatus = 'awaiting_confirmations' | 'success' | 'cancelled' | 'awaiting_execution'
export type TransactionProps = { export type TransactionProps = {
name: string, name: string,
nonce: number, nonce: number,
value: number, value: string,
confirmations: List<Confirmation>, confirmations: List<Confirmation>,
destination: string, recipient: string,
data: string, data: string,
isExecuted: boolean, isExecuted: boolean,
submissionDate: Date,
executionDate: Date,
symbol: string,
modifySettingsTx: boolean,
cancellationTx: boolean,
creationTxHash: string,
executionTxHash?: string,
cancelled?: boolean,
status?: TransactionStatus,
isTokenTransfer: boolean,
decodedParams?: Object,
} }
export const makeTransaction: RecordFactory<TransactionProps> = Record({ export const makeTransaction: RecordFactory<TransactionProps> = Record({
@ -18,9 +31,20 @@ export const makeTransaction: RecordFactory<TransactionProps> = Record({
nonce: 0, nonce: 0,
value: 0, value: 0,
confirmations: List([]), confirmations: List([]),
destination: '', recipient: '',
data: '', data: '',
isExecuted: false, isExecuted: false,
submissionDate: '',
executionDate: '',
symbol: '',
executionTxHash: undefined,
creationTxHash: '',
cancelled: false,
modifySettingsTx: false,
cancellationTx: false,
status: 'awaiting',
isTokenTransfer: false,
decodedParams: {},
}) })
export type Transaction = RecordOf<TransactionProps> export type Transaction = RecordOf<TransactionProps>

View File

@ -1,7 +1,7 @@
// @flow // @flow
import { List, Map } from 'immutable' import { List, Map } from 'immutable'
import { handleActions, type ActionType } from 'redux-actions' import { handleActions, type ActionType } from 'redux-actions'
import { ADD_TRANSACTIONS } from '~/routes/safe/store/actions/createTransaction' import { ADD_TRANSACTIONS } from '~/routes/safe/store/actions/addTransactions'
import { type Transaction } from '~/routes/safe/store/models/transaction' import { type Transaction } from '~/routes/safe/store/models/transaction'
export const TRANSACTIONS_REDUCER_ID = 'transactions' export const TRANSACTIONS_REDUCER_ID = 'transactions'

View File

@ -23,17 +23,15 @@ type TransactionProps = {
transaction: Transaction, transaction: Transaction,
} }
const safePropAddressSelector = (state: GlobalState, props: SafeProps) => props.safeAddress
const transactionsSelector = (state: GlobalState): TransactionsState => state[TRANSACTIONS_REDUCER_ID] const transactionsSelector = (state: GlobalState): TransactionsState => state[TRANSACTIONS_REDUCER_ID]
const oneTransactionSelector = (state: GlobalState, props: TransactionProps) => props.transaction const oneTransactionSelector = (state: GlobalState, props: TransactionProps) => props.transaction
export const safeParamAddressSelector = (state: GlobalState, props: RouterProps) => props.match.params[SAFE_PARAM_ADDRESS] || '' export const safeParamAddressSelector = (state: GlobalState, props: RouterProps) => props.match.params[SAFE_PARAM_ADDRESS] || ''
export const safeTransactionsSelector: Selector<GlobalState, SafeProps, List<Transaction>> = createSelector( export const safeTransactionsSelector: Selector<GlobalState, RouterProps, List<Transaction>> = createSelector(
transactionsSelector, transactionsSelector,
safePropAddressSelector, safeParamAddressSelector,
(transactions: TransactionsState, address: string): List<Transaction> => { (transactions: TransactionsState, address: string): List<Transaction> => {
if (!transactions) { if (!transactions) {
return List([]) return List([])

View File

@ -34,6 +34,7 @@ export const CreateSafe = ({ size, provider }: SafeProps) => (
color="primary" color="primary"
disabled={!provider} disabled={!provider}
minWidth={240} minWidth={240}
minHeight={42}
> >
<Img src={plus} height={14} alt="Safe" /> <Img src={plus} height={14} alt="Safe" />
<div style={buttonStyle}>Create new Safe</div> <div style={buttonStyle}>Create new Safe</div>

View File

@ -9,7 +9,7 @@ import PageFrame from '~/components/layout/PageFrame'
import ListItemText from '~/components/List/ListItemText/index' import ListItemText from '~/components/List/ListItemText/index'
import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions' import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
import { sleep } from '~/utils/timer' import { sleep } from '~/utils/timer'
import { history } from '~/store' import { history, type GlobalState } from '~/store'
import AppRoutes from '~/routes' import AppRoutes from '~/routes'
import { SAFELIST_ADDRESS } from '~/routes/routes' import { SAFELIST_ADDRESS } from '~/routes/routes'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions' import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'

View File

@ -11,7 +11,7 @@ import { sleep } from '~/utils/timer'
import TokenBalanceRecord from '~/routes/safe/store/models/tokenBalance' import TokenBalanceRecord from '~/routes/safe/store/models/tokenBalance'
import { calculateBalanceOf } from '~/routes/safe/store/actions/fetchTokenBalances' import { calculateBalanceOf } from '~/routes/safe/store/actions/fetchTokenBalances'
import updateActiveTokens from '~/routes/safe/store/actions/updateActiveTokens' import updateActiveTokens from '~/routes/safe/store/actions/updateActiveTokens'
import 'jest-dom/extend-expect' import '@testing-library/jest-dom/extend-expect'
import updateSafe from '~/routes/safe/store/actions/updateSafe' import updateSafe from '~/routes/safe/store/actions/updateSafe'
import { BALANCE_ROW_TEST_ID } from '~/routes/safe/components/Balances' import { BALANCE_ROW_TEST_ID } from '~/routes/safe/components/Balances'

View File

@ -4,7 +4,7 @@ import { aNewStore } from '~/store'
import { aMinedSafe } from '~/test/builder/safe.redux.builder' import { aMinedSafe } from '~/test/builder/safe.redux.builder'
import { renderSafeView } from '~/test/builder/safe.dom.utils' import { renderSafeView } from '~/test/builder/safe.dom.utils'
import { sleep } from '~/utils/timer' import { sleep } from '~/utils/timer'
import 'jest-dom/extend-expect' import '@testing-library/jest-dom/extend-expect'
import { SETTINGS_TAB_BTN_TESTID, SAFE_VIEW_NAME_HEADING_TESTID } from '~/routes/safe/components/Layout' import { SETTINGS_TAB_BTN_TESTID, SAFE_VIEW_NAME_HEADING_TESTID } from '~/routes/safe/components/Layout'
import { SAFE_NAME_INPUT_TESTID, SAFE_NAME_SUBMIT_BTN_TESTID } from '~/routes/safe/components/Settings/ChangeSafeName' import { SAFE_NAME_INPUT_TESTID, SAFE_NAME_SUBMIT_BTN_TESTID } from '~/routes/safe/components/Settings/ChangeSafeName'

View File

@ -4,7 +4,7 @@ import { aNewStore } from '~/store'
import { aMinedSafe } from '~/test/builder/safe.redux.builder' import { aMinedSafe } from '~/test/builder/safe.redux.builder'
import { renderSafeView } from '~/test/builder/safe.dom.utils' import { renderSafeView } from '~/test/builder/safe.dom.utils'
import { sleep } from '~/utils/timer' import { sleep } from '~/utils/timer'
import 'jest-dom/extend-expect' import '@testing-library/jest-dom/extend-expect'
import { SETTINGS_TAB_BTN_TESTID } from '~/routes/safe/components/Layout' import { SETTINGS_TAB_BTN_TESTID } from '~/routes/safe/components/Layout'
import { OWNERS_SETTINGS_TAB_TESTID } from '~/routes/safe/components/Settings' import { OWNERS_SETTINGS_TAB_TESTID } from '~/routes/safe/components/Settings'
import { import {
@ -162,7 +162,7 @@ describe('DOM > Feature > Settings - Manage owners', () => {
fireEvent.click(SafeDom.getByTestId(ADD_OWNER_THRESHOLD_NEXT_BTN_TESTID)) fireEvent.click(SafeDom.getByTestId(ADD_OWNER_THRESHOLD_NEXT_BTN_TESTID))
await sleep(200) await sleep(200)
fireEvent.click(SafeDom.getByTestId(ADD_OWNER_SUBMIT_BTN_TESTID)) fireEvent.click(SafeDom.getByTestId(ADD_OWNER_SUBMIT_BTN_TESTID))
await sleep(1000) await sleep(1500)
// check if owner was added // check if owner was added
ownerRows = SafeDom.getAllByTestId(OWNERS_ROW_TESTID) ownerRows = SafeDom.getAllByTestId(OWNERS_ROW_TESTID)

View File

@ -15,7 +15,7 @@ import {
ADD_CUSTOM_TOKEN_FORM, ADD_CUSTOM_TOKEN_FORM,
} from '~/routes/safe/components/Balances/Tokens/screens/AddCustomToken' } from '~/routes/safe/components/Balances/Tokens/screens/AddCustomToken'
import { BALANCE_ROW_TEST_ID } from '~/routes/safe/components/Balances/' import { BALANCE_ROW_TEST_ID } from '~/routes/safe/components/Balances/'
import 'jest-dom/extend-expect' import '@testing-library/jest-dom/extend-expect'
// https://github.com/testing-library/@testing-library/react/issues/281 // https://github.com/testing-library/@testing-library/react/issues/281
const originalError = console.error const originalError = console.error

View File

@ -10,7 +10,7 @@ import saveTokens from '~/logic/tokens/store/actions/saveTokens'
import { clickOnManageTokens, toggleToken, closeManageTokensModal } from './utils/DOMNavigation' import { clickOnManageTokens, toggleToken, closeManageTokensModal } from './utils/DOMNavigation'
import { BALANCE_ROW_TEST_ID } from '~/routes/safe/components/Balances' import { BALANCE_ROW_TEST_ID } from '~/routes/safe/components/Balances'
import { makeToken } from '~/logic/tokens/store/model/token' import { makeToken } from '~/logic/tokens/store/model/token'
import 'jest-dom/extend-expect' import '@testing-library/jest-dom/extend-expect'
describe('DOM > Feature > Enable and disable default tokens', () => { describe('DOM > Feature > Enable and disable default tokens', () => {
let web3 let web3

View File

@ -43,6 +43,9 @@ export default createMuiTheme({
}, },
overrides: { overrides: {
MuiButton: { MuiButton: {
label: {
lineHeight: 1,
},
root: { root: {
fontFamily: 'Roboto Mono, monospace', fontFamily: 'Roboto Mono, monospace',
letterSpacing: '0.9px', letterSpacing: '0.9px',
@ -57,6 +60,9 @@ export default createMuiTheme({
containedPrimary: { containedPrimary: {
backgroundColor: secondary, backgroundColor: secondary,
}, },
containedSecondary: {
backgroundColor: error,
},
sizeLarge: { sizeLarge: {
padding: `${md} ${lg}`, padding: `${md} ${lg}`,
minHeight: '52px', minHeight: '52px',
@ -78,6 +84,16 @@ export default createMuiTheme({
padding: '24px 0 0 15px', padding: '24px 0 0 15px',
}, },
}, },
MuiIconButton: {
root: {
padding: 0,
},
},
MuiChip: {
root: {
fontFamily: 'Roboto Mono, monospace',
},
},
MuiStepIcon: { MuiStepIcon: {
root: { root: {
fontSize: '22px', fontSize: '22px',

2624
yarn.lock

File diff suppressed because it is too large Load Diff