Merge pull request #108 from gnosis/80-send-funds

#80 Send funds for threshold = 1
This commit is contained in:
Mikhail Mikheev 2019-05-29 13:11:05 +04:00 committed by GitHub
commit 04db6a31c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
64 changed files with 3246 additions and 1284 deletions

View File

@ -65,14 +65,15 @@
"verbose": true "verbose": true
}, },
"dependencies": { "dependencies": {
"@gnosis.pm/util-contracts": "2.0.1",
"@gnosis.pm/safe-contracts": "^1.0.0", "@gnosis.pm/safe-contracts": "^1.0.0",
"@gnosis.pm/util-contracts": "^2.0.0", "@material-ui/core": "4.0.0",
"@material-ui/core": "^3.9.3", "@material-ui/icons": "4.0.0",
"@material-ui/icons": "^3.0.1", "@welldone-software/why-did-you-render": "^3.0.9",
"axios": "^0.18.0", "axios": "^0.18.0",
"bignumber.js": "^8.1.1", "bignumber.js": "^8.1.1",
"connected-react-router": "^6.3.1", "connected-react-router": "^6.3.1",
"final-form": "^4.2.1", "final-form": "4.13.0",
"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",
@ -81,10 +82,11 @@
"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": "^4.1.0", "react-final-form": "6.0.0",
"react-hot-loader": "4.8.4", "react-final-form-listeners": "^1.0.2",
"react-hot-loader": "4.8.8",
"react-infinite-scroll-component": "^4.5.2", "react-infinite-scroll-component": "^4.5.2",
"react-redux": "7.0.2", "react-redux": "7.0.3",
"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.1",
@ -94,10 +96,10 @@
"web3": "1.0.0-beta.37" "web3": "1.0.0-beta.37"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.0.0-beta.40", "@babel/cli": "7.4.4",
"@babel/core": "^7.4.0", "@babel/core": "7.4.5",
"@babel/plugin-proposal-class-properties": "^7.4.0", "@babel/plugin-proposal-class-properties": "7.4.4",
"@babel/plugin-proposal-decorators": "^7.4.0", "@babel/plugin-proposal-decorators": "7.4.4",
"@babel/plugin-proposal-do-expressions": "^7.0.0", "@babel/plugin-proposal-do-expressions": "^7.0.0",
"@babel/plugin-proposal-export-default-from": "^7.0.0", "@babel/plugin-proposal-export-default-from": "^7.0.0",
"@babel/plugin-proposal-export-namespace-from": "^7.0.0", "@babel/plugin-proposal-export-namespace-from": "^7.0.0",
@ -105,7 +107,7 @@
"@babel/plugin-proposal-function-sent": "^7.0.0", "@babel/plugin-proposal-function-sent": "^7.0.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.0.0", "@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.0.0",
@ -114,20 +116,20 @@
"@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.0", "@babel/polyfill": "7.4.4",
"@babel/preset-env": "^7.4.2", "@babel/preset-env": "7.4.5",
"@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",
"@storybook/addon-actions": "5.0.10", "@storybook/addon-actions": "5.0.11",
"@storybook/addon-knobs": "5.0.10", "@storybook/addon-knobs": "5.0.11",
"@storybook/addon-links": "5.0.10", "@storybook/addon-links": "5.0.11",
"@storybook/react": "5.0.10", "@storybook/react": "5.0.11",
"autoprefixer": "9.5.1", "autoprefixer": "9.5.1",
"babel-core": "^7.0.0-bridge.0", "babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.0.1", "babel-eslint": "^10.0.1",
"babel-jest": "^24.1.0", "babel-jest": "24.8.0",
"babel-loader": "^8.0.0-beta.0", "babel-loader": "8.0.6",
"babel-plugin-dynamic-import-node": "^2.2.0", "babel-plugin-dynamic-import-node": "^2.2.0",
"babel-plugin-transform-es3-member-expression-literals": "^6.22.0", "babel-plugin-transform-es3-member-expression-literals": "^6.22.0",
"babel-plugin-transform-es3-property-literals": "^6.22.0", "babel-plugin-transform-es3-property-literals": "^6.22.0",
@ -136,19 +138,19 @@
"detect-port": "^1.2.2", "detect-port": "^1.2.2",
"eslint": "^5.16.0", "eslint": "^5.16.0",
"eslint-config-airbnb": "^17.1.0", "eslint-config-airbnb": "^17.1.0",
"eslint-plugin-flowtype": "3.6.1", "eslint-plugin-flowtype": "3.9.1",
"eslint-plugin-import": "2.17.2", "eslint-plugin-import": "2.17.3",
"eslint-plugin-jest": "22.5.1", "eslint-plugin-jest": "22.6.4",
"eslint-plugin-jsx-a11y": "^6.0.3", "eslint-plugin-jsx-a11y": "^6.0.3",
"eslint-plugin-react": "^7.7.0", "eslint-plugin-react": "7.13.0",
"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",
"file-loader": "^3.0.1", "file-loader": "^3.0.1",
"flow-bin": "0.97.0", "flow-bin": "0.98.1",
"fs-extra": "^7.0.1", "fs-extra": "8.0.1",
"html-loader": "^0.5.5", "html-loader": "^0.5.5",
"html-webpack-plugin": "^3.0.4", "html-webpack-plugin": "^3.0.4",
"jest": "^24.1.0", "jest": "24.8.0",
"json-loader": "^0.5.7", "json-loader": "^0.5.7",
"mini-css-extract-plugin": "0.6.0", "mini-css-extract-plugin": "0.6.0",
"postcss-loader": "^3.0.0", "postcss-loader": "^3.0.0",
@ -160,14 +162,14 @@
"storybook-host": "^5.0.3", "storybook-host": "^5.0.3",
"storybook-router": "^0.3.3", "storybook-router": "^0.3.3",
"style-loader": "^0.23.1", "style-loader": "^0.23.1",
"truffle": "5.0.14", "truffle": "5.0.19",
"truffle-contract": "4.0.13", "truffle-contract": "4.0.17",
"truffle-solidity-loader": "0.1.14", "truffle-solidity-loader": "0.1.18",
"uglifyjs-webpack-plugin": "^2.1.2", "uglifyjs-webpack-plugin": "2.1.3",
"webpack": "^4.1.1", "webpack": "4.32.2",
"webpack-bundle-analyzer": "3.3.2", "webpack-bundle-analyzer": "3.3.2",
"webpack-cli": "3.3.1", "webpack-cli": "3.3.2",
"webpack-dev-server": "3.3.1", "webpack-dev-server": "3.4.1",
"webpack-manifest-plugin": "^2.0.0-rc.2" "webpack-manifest-plugin": "^2.0.0-rc.2"
} }
} }

View File

@ -61,7 +61,15 @@ const buildDotStyleFrom = (size: number, top: number, right: number, mode: Mode)
}) })
const KeyRing = ({ const KeyRing = ({
classes, circleSize, keySize, dotSize, dotTop, dotRight, mode, center = false, hideDot = false, classes,
circleSize,
keySize,
dotSize,
dotTop,
dotRight,
mode,
center = false,
hideDot = false,
}: Props) => { }: Props) => {
const keyStyle = buildKeyStyleFrom(circleSize, center, dotSize) const keyStyle = buildKeyStyleFrom(circleSize, center, dotSize)
const dotStyle = buildDotStyleFrom(dotSize, dotTop, dotRight, mode) const dotStyle = buildDotStyleFrom(dotSize, dotTop, dotRight, mode)

View File

@ -56,10 +56,7 @@ class Provider extends React.Component<Props> {
<div ref={this.myRef} className={classes.root}> <div ref={this.myRef} className={classes.root}>
<Col end="sm" middle="xs" className={classes.provider} onClick={toggle}> <Col end="sm" middle="xs" className={classes.provider} onClick={toggle}>
{info} {info}
<IconButton <IconButton disableRipple className={classes.expand}>
disableRipple
className={classes.expand}
>
{open ? <ExpandLess /> : <ExpandMore />} {open ? <ExpandLess /> : <ExpandMore />}
</IconButton> </IconButton>
</Col> </Col>

View File

@ -54,18 +54,20 @@ const ProviderInfo = ({
return ( return (
<React.Fragment> <React.Fragment>
{ connected && {connected && (
<React.Fragment> <React.Fragment>
<Identicon address={identiconAddress} diameter={30} /> <Identicon address={identiconAddress} diameter={30} />
<Dot className={classes.logo} /> <Dot className={classes.logo} />
</React.Fragment> </React.Fragment>
} )}
{ !connected && {!connected && <CircleDot keySize={14} circleSize={35} dotSize={16} dotTop={24} dotRight={11} mode="warning" />}
<CircleDot keySize={14} circleSize={35} dotSize={16} dotTop={24} dotRight={11} mode="warning" />
}
<Col start="sm" layout="column" className={classes.account}> <Col start="sm" layout="column" className={classes.account}>
<Paragraph size="sm" transform="capitalize" className={classes.network} noMargin weight="bolder">{providerText}</Paragraph> <Paragraph size="sm" transform="capitalize" className={classes.network} noMargin weight="bolder">
<Paragraph size="sm" className={classes.address} noMargin color={color}>{cutAddress}</Paragraph> {providerText}
</Paragraph>
<Paragraph size="sm" className={classes.address} noMargin color={color}>
{cutAddress}
</Paragraph>
</Col> </Col>
</React.Fragment> </React.Fragment>
) )

View File

@ -33,8 +33,12 @@ const ProviderDesconnected = ({ classes }: Props) => (
<React.Fragment> <React.Fragment>
<CircleDot keySize={17} circleSize={35} dotSize={16} dotTop={24} dotRight={11} mode="error" /> <CircleDot keySize={17} circleSize={35} dotSize={16} dotTop={24} dotRight={11} mode="error" />
<Col end="sm" middle="xs" layout="column" className={classes.account}> <Col end="sm" middle="xs" layout="column" className={classes.account}>
<Paragraph size="sm" transform="capitalize" className={classes.network} noMargin weight="bold">Not Connected</Paragraph> <Paragraph size="sm" transform="capitalize" className={classes.network} noMargin weight="bold">
<Paragraph size="sm" color="fancy" className={classes.connect} noMargin>Connect Wallet</Paragraph> Not Connected
</Paragraph>
<Paragraph size="sm" color="fancy" className={classes.connect} noMargin>
Connect Wallet
</Paragraph>
</Col> </Col>
</React.Fragment> </React.Fragment>
) )

View File

@ -12,7 +12,8 @@ import Layout from './component/Layout'
import actions, { type Actions } from './actions' import actions, { type Actions } from './actions'
import selector, { type SelectorProps } from './selector' import selector, { type SelectorProps } from './selector'
type Props = Actions & SelectorProps & { type Props = Actions &
SelectorProps & {
openSnackbar: (message: string, variant: Variant) => void, openSnackbar: (message: string, variant: Variant) => void,
} }
@ -26,22 +27,19 @@ class HeaderComponent extends React.PureComponent<Props, State> {
} }
componentDidMount() { componentDidMount() {
this.props.fetchProvider(this.props.openSnackbar) this.onConnect()
}
componentDidCatch(error: Error, info: Info) {
this.setState({ hasError: true })
this.props.openSnackbar(WALLET_ERROR_MSG, 'error')
logComponentStack(error, info)
} }
onDisconnect = () => { onDisconnect = () => {
this.props.removeProvider(this.props.openSnackbar) const { removeProvider, openSnackbar } = this.props
removeProvider(openSnackbar)
} }
onConnect = () => { onConnect = () => {
this.props.fetchProvider(this.props.openSnackbar) const { fetchProvider, openSnackbar } = this.props
fetchProvider(openSnackbar)
} }
getProviderInfoBased = () => { getProviderInfoBased = () => {
@ -67,13 +65,23 @@ class HeaderComponent extends React.PureComponent<Props, State> {
return <ConnectDetails onConnect={this.onConnect} /> return <ConnectDetails onConnect={this.onConnect} />
} }
return (<UserDetails return (
<UserDetails
provider={provider} provider={provider}
network={network} network={network}
userAddress={userAddress} userAddress={userAddress}
connected={available} connected={available}
onDisconnect={this.onDisconnect} onDisconnect={this.onDisconnect}
/>) />
)
}
componentDidCatch(error: Error, info: Info) {
const { openSnackbar } = this.props
this.setState({ hasError: true })
openSnackbar(WALLET_ERROR_MSG, 'error')
logComponentStack(error, info)
} }
render() { render() {
@ -84,14 +92,13 @@ class HeaderComponent extends React.PureComponent<Props, State> {
} }
} }
const Header = connect(selector, actions)(HeaderComponent) const Header = connect(
selector,
actions,
)(HeaderComponent)
const HeaderSnack = () => ( const HeaderSnack = () => (
<SharedSnackbarConsumer> <SharedSnackbarConsumer>{({ openSnackbar }) => <Header openSnackbar={openSnackbar} />}</SharedSnackbarConsumer>
{({ openSnackbar }) => (
<Header openSnackbar={openSnackbar} />
)}
</SharedSnackbarConsumer>
) )
export default HeaderSnack export default HeaderSnack

View File

@ -16,7 +16,7 @@ export type SelectorProps = {
available: boolean, available: boolean,
} }
export default createStructuredSelector({ export default createStructuredSelector<Object, *>({
provider: providerNameSelector, provider: providerNameSelector,
userAddress: userAccountSelector, userAddress: userAccountSelector,
network: networkSelector, network: networkSelector,

View File

@ -1,5 +1,6 @@
// @flow // @flow
import * as React from 'react' import * as React from 'react'
import cn from 'classnames'
import Modal from '@material-ui/core/Modal' import Modal from '@material-ui/core/Modal'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
@ -10,6 +11,8 @@ type Props = {
handleClose: Function, handleClose: Function,
children: React$Node, children: React$Node,
classes: Object, classes: Object,
modalClassName: ?string,
paperClassName: ?string,
} }
const styles = () => ({ const styles = () => ({
@ -35,18 +38,16 @@ const styles = () => ({
}) })
const GnoModal = ({ const GnoModal = ({
title, description, open, children, handleClose, classes, title, description, open, children, handleClose, modalClassName, classes, paperClassName,
}: Props) => ( }: Props) => (
<Modal <Modal
aria-labelledby={title} aria-labelledby={title}
aria-describedby={description} aria-describedby={description}
open={open} open={open}
onClose={handleClose} onClose={handleClose}
className={classes.root} className={cn(classes.root, modalClassName)}
> >
<div className={classes.paper}> <div className={cn(classes.paper, paperClassName)}>{children}</div>
{ children }
</div>
</Modal> </Modal>
) )

View File

@ -64,7 +64,7 @@ const styles = theme => ({
}, },
iconVariant: { iconVariant: {
opacity: 0.9, opacity: 0.9,
marginRight: theme.spacing.unit, marginRight: theme.spacing(1),
}, },
message: { message: {
display: 'flex', display: 'flex',

View File

@ -15,6 +15,7 @@ type Props = {
padding?: number, padding?: number,
validation?: (values: Object) => Object | Promise<Object>, validation?: (values: Object) => Object | Promise<Object>,
initialValues?: Object, initialValues?: Object,
formMutators?: Object,
} }
const stylesBasedOn = (padding: number): $Shape<CSSStyleDeclaration> => ({ const stylesBasedOn = (padding: number): $Shape<CSSStyleDeclaration> => ({
@ -24,15 +25,16 @@ const stylesBasedOn = (padding: number): $Shape<CSSStyleDeclaration> => ({
}) })
const GnoForm = ({ const GnoForm = ({
onSubmit, validation, initialValues, children, padding = 0, onSubmit, validation, initialValues, children, padding = 0, formMutators,
}: Props) => ( }: Props) => (
<Form <Form
validate={validation} validate={validation}
onSubmit={onSubmit} onSubmit={onSubmit}
initialValues={initialValues} initialValues={initialValues}
mutators={formMutators}
render={({ handleSubmit, ...rest }) => ( render={({ handleSubmit, ...rest }) => (
<form onSubmit={handleSubmit} style={stylesBasedOn(padding)}> <form onSubmit={handleSubmit} style={stylesBasedOn(padding)}>
{children(rest.submitting, rest.validating, rest)} {children(rest.submitting, rest.validating, rest, rest.form.mutators)}
</form> </form>
)} )}
/> />

View File

@ -16,32 +16,30 @@ const SelectInput = ({
meta, meta,
label, label,
formControlProps, formControlProps,
classes,
renderValue,
...rest ...rest
}: SelectFieldProps) => { }: SelectFieldProps) => {
const showError = ((meta.submitError && !meta.dirtySinceLastSubmit) || meta.error) && meta.touched const showError = ((meta.submitError && !meta.dirtySinceLastSubmit) || meta.error) && meta.touched
const inputProps = { ...restInput, name } const inputProps = {
...restInput,
name,
}
return ( return (
<FormControl <FormControl {...formControlProps} error={showError} style={style}>
{...formControlProps}
error={showError}
style={style}
>
<InputLabel htmlFor={name}>{label}</InputLabel> <InputLabel htmlFor={name}>{label}</InputLabel>
<Select <Select
{...rest} classes={classes}
onChange={onChange} onChange={onChange}
renderValue={renderValue}
inputProps={inputProps} inputProps={inputProps}
value={value} value={value}
{...rest}
/> />
{ showError && {showError && <FormHelperText>{meta.error || meta.submitError}</FormHelperText>}
<FormHelperText>
{meta.error || meta.submitError}
</FormHelperText>
}
</FormControl> </FormControl>
) )
} }
export default SelectInput export default SelectInput

View File

@ -1,6 +1,5 @@
// @flow // @flow
import React from 'react' import React from 'react'
import cn from 'classnames'
import MuiTextField, { TextFieldProps } from '@material-ui/core/TextField' import MuiTextField, { TextFieldProps } from '@material-ui/core/TextField'
import { withStyles } from '@material-ui/core/styles' import { withStyles } from '@material-ui/core/styles'
import { lg } from '~/theme/variables' import { lg } from '~/theme/variables'
@ -17,9 +16,6 @@ const styles = () => ({
paddingBottom: '12px', paddingBottom: '12px',
lineHeight: 0, lineHeight: 0,
}, },
input: {
borderRadius: '5px',
},
}) })
class TextField extends React.PureComponent<TextFieldProps> { class TextField extends React.PureComponent<TextFieldProps> {
@ -41,7 +37,7 @@ class TextField extends React.PureComponent<TextFieldProps> {
const inputRoot = helperText ? classes.root : undefined const inputRoot = helperText ? classes.root : undefined
const inputProps = { ...restInput, autoComplete: 'off' } const inputProps = { ...restInput, autoComplete: 'off' }
const inputRootProps = { ...inputAdornment, disableUnderline: !underline, className: cn(inputRoot, classes.input) } const inputRootProps = { ...inputAdornment, disableUnderline: !underline, className: inputRoot }
return ( return (
<MuiTextField <MuiTextField

View File

@ -11,14 +11,16 @@ const styles = {
type Props = { type Props = {
minWidth?: number, minWidth?: number,
minHeight?: number,
} }
const calculateStyleBased = minWidth => ({ const calculateStyleBased = (minWidth, minHeight) => ({
minWidth: `${minWidth}px`, minWidth: minWidth && `${minWidth}px`,
minHeight: minHeight && `${minHeight}px`,
}) })
const GnoButton = ({ minWidth, ...props }: Props) => { const GnoButton = ({ minWidth, minHeight, ...props }: Props) => {
const style = minWidth ? calculateStyleBased(minWidth) : undefined const style = calculateStyleBased(minWidth, minHeight)
return <Button style={style} {...props} /> return <Button style={style} {...props} />
} }

View File

@ -0,0 +1,31 @@
// @flow
/* eslint-disable react/button-has-type */
/* eslint-disable react/default-props-match-prop-types */
import * as React from 'react'
import cn from 'classnames/bind'
import styles from './index.scss'
const cx = cn.bind(styles)
type Props = {
type: 'button' | 'submit' | 'reset',
size?: 'sm' | 'md' | 'lg' | 'xl' | 'xxl',
weight?: 'light' | 'regular' | 'bolder' | 'bold',
color?: 'soft' | 'medium' | 'dark' | 'white' | 'fancy' | 'primary' | 'secondary' | 'warning' | 'disabled',
}
const GnoButtonLink = ({
type, size, weight, color, ...props
}: Props) => (
<button type={type} className={cx(styles.btnLink, size, color, weight)} {...props} />
)
GnoButtonLink.defaultProps = {
type: 'button',
size: 'md',
weight: 'regular',
color: 'secondary',
}
export default GnoButtonLink

View File

@ -0,0 +1,79 @@
.btnLink {
background: transparent;
border: none;
text-decoration: underline;
font-family: 'Roboto Mono', monospace;
cursor: pointer;
}
.sm {
font-size: $smallFontSize;
}
.md {
font-size: $mediumFontSize;
}
.lg {
font-size: $largeFontSize;
}
.xl {
font-size: $extraLargeFontSize;
}
.xxl {
font-size: $xxlFontSize;
}
.light {
font-weight: $lightFont;
}
.regular {
font-weight: $regularFont;
}
.bolder {
font-weight: $bolderFont;
}
.bold {
font-weight: $boldFont;
}
.soft {
color: #888888;
}
.medium {
color: #686868;
}
.dark {
color: black;
}
.fancy {
color: $fancy;
}
.warning {
color: $warning;
}
.primary {
color: $fontColor;
}
.secondary {
color: $secondary;
}
.disabled {
color: $disabled;
}
.white {
color: white;
}

View File

@ -5,6 +5,7 @@ import { border } from '~/theme/variables'
const calculateStyleFrom = (color?: string, margin?: Size) => ({ const calculateStyleFrom = (color?: string, margin?: Size) => ({
width: '100%', width: '100%',
minHeight: '1px',
height: '1px', height: '1px',
backgroundColor: color || border, backgroundColor: color || border,
margin: `${getSize(margin)} 0px`, margin: `${getSize(margin)} 0px`,
@ -13,12 +14,14 @@ const calculateStyleFrom = (color?: string, margin?: Size) => ({
type Props = { type Props = {
margin?: Size, margin?: Size,
color?: string, color?: string,
style?: Object
} }
const Hairline = ({ margin, color }: Props) => { const Hairline = ({ margin, color, style }: Props) => {
const style = calculateStyleFrom(color, margin) const calculatedStyles = calculateStyleFrom(color, margin)
const mergedStyles = { ...calculatedStyles, ...(style || {}) }
return <div style={style} /> return <div style={mergedStyles} />
} }
export default Hairline export default Hairline

View File

@ -1,6 +1,6 @@
// @flow // @flow
import * as React from 'react'
import classNames from 'classnames/bind' import classNames from 'classnames/bind'
import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { capitalize } from '~/utils/css' import { capitalize } from '~/utils/css'
import styles from './index.scss' import styles from './index.scss'
@ -13,20 +13,35 @@ type Props = {
children: React$Node, children: React$Node,
color?: 'regular' | 'white', color?: 'regular' | 'white',
className?: string, className?: string,
innerRef: React.ElementRef<any>,
} }
const GnosisLink = ({ const GnosisLink = ({
to, children, color, className, padding, ...props to, children, color, className, padding, innerRef, ...props
}: Props) => { }: Props) => {
const internal = /^\/(?!\/)/.test(to) const internal = /^\/(?!\/)/.test(to)
const classes = cx(styles.link, color || 'regular', padding ? capitalize(padding, 'padding') : undefined, className) const classes = cx(styles.link, color || 'regular', padding ? capitalize(padding, 'padding') : undefined, className)
const LinkElement = internal ? Link : 'a' const LinkElement = internal ? Link : 'a'
const refs = {}
if (internal) {
// To avoid warning about React not recognizing the prop innerRef on native element (a) if the link is external
refs.innerRef = innerRef
}
return ( return (
<LinkElement className={classes} href={internal ? null : to} to={internal ? to : null} {...props}> <LinkElement
className={classes}
href={internal ? null : to}
to={internal ? to : null}
{...refs}
{...props}
>
{children} {children}
</LinkElement> </LinkElement>
) )
} }
export default GnosisLink // https://material-ui.com/guides/composition/#caveat-with-refs
const LinkWithRef = React.forwardRef<Props, typeof GnosisLink>((props, ref) => <GnosisLink {...props} innerRef={ref} />)
export default LinkWithRef

View File

@ -9,7 +9,7 @@ const cx = classNames.bind(styles)
type Props = { type Props = {
className?: string, className?: string,
children: React$Node, children: React$Node,
margin?: 'sm' | 'md' | 'lg' | 'xl', margin?: 'xs' | 'sm' | 'md' | 'lg' | 'xl',
align?: 'center' | 'end' | 'start', align?: 'center' | 'end' | 'start',
grow?: boolean, grow?: boolean,
} }

View File

@ -7,6 +7,11 @@
.grow { .grow {
flex: 1 1 auto; flex: 1 1 auto;
} }
.marginXs {
margin-bottom: $xs;
}
.marginSm { .marginSm {
margin-bottom: $sm; margin-bottom: $sm;
} }

View File

@ -10,7 +10,7 @@ const devConfig = {
[TX_SERVICE_HOST]: 'http://localhost:8000/api/v1/', [TX_SERVICE_HOST]: 'http://localhost:8000/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

@ -10,7 +10,7 @@ const prodConfig = {
[TX_SERVICE_HOST]: 'https://safe-transaction-history.dev.gnosisdev.com/api/v1/', [TX_SERVICE_HOST]: 'https://safe-transaction-history.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

@ -10,7 +10,7 @@ const testConfig = {
[TX_SERVICE_HOST]: 'http://localhost:8000/api/v1/', [TX_SERVICE_HOST]: 'http://localhost:8000/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 testConfig export default testConfig

View File

@ -8,6 +8,12 @@ import { store } from '~/store'
import loadSafesFromStorage from '~/routes/safe/store/actions/loadSafesFromStorage' import loadSafesFromStorage from '~/routes/safe/store/actions/loadSafesFromStorage'
import loadActiveTokens from '~/logic/tokens/store/actions/loadActiveTokens' import loadActiveTokens from '~/logic/tokens/store/actions/loadActiveTokens'
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line
const whyDidYouRender = require('@welldone-software/why-did-you-render')
whyDidYouRender(React)
}
store.dispatch(loadActiveTokens()) store.dispatch(loadActiveTokens())
store.dispatch(loadSafesFromStorage()) store.dispatch(loadSafesFromStorage())

View File

@ -0,0 +1,17 @@
// @flow
import axios from 'axios'
import { getRelayUrl } from '~/config/index'
export const estimateTxGas = (safeAddress: string, to: string, value: string, data, operation = 0) => {
const apiUrl = getRelayUrl()
const url = `${apiUrl}/safes/${safeAddress}/transactions/estimate/`
// const estimationValue = isTokenTransfer(tx.data) ? '0' : value.toString(10)
return axios.post(url, {
safe: safeAddress,
to,
data: '0x',
value,
operation,
})
}

View File

@ -0,0 +1,3 @@
// @flow
export * from './estimateTxGas'

View File

@ -1,142 +0,0 @@
// @flow
import { List } from 'immutable'
import { calculateGasOf, checkReceiptStatus, calculateGasPrice } from '~/logic/wallets/ethTransactions'
import { type Operation, submitOperation } from '~/logic/safe/safeTxHistory'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { buildSignaturesFrom } from '~/logic/safe/safeTxSigner'
import { generateMetamaskSignature, generateTxGasEstimateFrom, estimateDataGas } from '~/logic/safe/safeTxSignerEIP712'
import { storeSignature, getSignaturesFrom } from '~/utils/storage/signatures'
import { signaturesViaMetamask } from '~/config'
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,
to: string,
valueInWei: number,
data: string,
operation: Operation,
nonce: number,
sender: string,
ownersWhoHasSigned: List<string>,
) => {
const gasPrice = await calculateGasPrice()
if (signaturesViaMetamask()) {
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)
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 getGnosisSafeInstanceAt(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

@ -1,7 +1,7 @@
// @flow // @flow
import { List } from 'immutable' import { List } from 'immutable'
import { type Transaction } from '~/routes/safe/store/models/transaction' import { type Transaction } from '~/routes/safe/store/models/transaction'
import { executeTransaction, approveTransaction } from '~/logic/safe/safeBlockchainOperations' import { executeTransaction, approveTransaction } from '~/logic/safe/transactions'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions' import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import { getWeb3 } from '~/logic/wallets/getWeb3' import { getWeb3 } from '~/logic/wallets/getWeb3'
import { type Safe } from '~/routes/safe/store/models/safe' import { type Safe } from '~/routes/safe/store/models/safe'

View File

@ -0,0 +1,135 @@
// @flow
import { BigNumber } from 'bignumber.js'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
const estimateDataGasCosts = (data) => {
const reducer = (accumulator, currentValue) => {
if (currentValue === EMPTY_DATA) {
return accumulator + 0
}
if (currentValue === '00') {
return accumulator + 4
}
return accumulator + 68
}
return data.match(/.{2}/g).reduce(reducer, 0)
}
// https://gnosis-safe.readthedocs.io/en/latest/contracts/transactions.html#safe-transaction-data-gas-estimation
// https://github.com/gnosis/safe-contracts/blob/a97c6fd24f79c0b159ddd25a10a2ebd3ea2ef926/test/utils/execution.js
export const estimateDataGas = (
safe: any,
to: string,
valueInWei: number,
from: string,
data: string,
operation: number,
txGasEstimate: number,
gasToken: number,
nonce: number,
signatureCount: number,
refundReceiver: number,
) => {
// numbers < 256 are 192 -> 31 * 4 + 68
// numbers < 65k are 256 -> 30 * 4 + 2 * 68
// For signature array length and dataGasEstimate we already calculated
// the 0 bytes so we just add 64 for each non-zero byte
const gasPrice = 0 // no need to get refund when we submit txs to metamask
const signatureCost = signatureCount * (68 + 2176 + 2176 + 6000) // array count (3 -> r, s, v) * signature count
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
const sigs = `0x000000000000000000000000${from.replace(
'0x',
'',
)}000000000000000000000000000000000000000000000000000000000000000001`
const payload = safe.contract.methods
.execTransaction(to, valueInWei, data, operation, txGasEstimate, 0, gasPrice, gasToken, refundReceiver, sigs)
.encodeABI()
// eslint-disable-next-line
const dataGasEstimate = estimateDataGasCosts(payload) + signatureCost + (nonce > 0 ? 5000 : 20000) + 1500 // 1500 -> hash generation costs
return dataGasEstimate + 32000 // Add aditional gas costs (e.g. base tx costs, transfer costs)
}
export const generateTxGasEstimateFrom = async (
safe: any,
safeAddress: string,
data: string,
to: string,
valueInWei: number,
operation: number,
) => {
try {
let safeInstance = safe
if (!safeInstance) {
safeInstance = await getGnosisSafeInstanceAt(safeAddress)
}
const estimateData = safeInstance.contract.methods.requiredTxGas(to, valueInWei, data, operation).encodeABI()
const estimateResponse = await getWeb3().eth.call({
to: safeAddress,
from: safeAddress,
data: estimateData,
})
const txGasEstimate = new BigNumber(estimateResponse.substring(138), 16)
// Add 10k else we will fail in case of nested calls
return txGasEstimate.toNumber() + 10000
} catch (error) {
// eslint-disable-next-line
console.log('Error calculating tx gas estimation ' + error)
return 0
}
}
export const calculateTxFee = async (
safe: any,
safeAddress: string,
from: string,
data: string,
to: string,
valueInWei: number,
operation: number,
) => {
try {
let safeInstance = safe
if (!safeInstance) {
safeInstance = await getGnosisSafeInstanceAt(safeAddress)
}
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
const sigs = `0x000000000000000000000000${from.replace(
'0x',
'',
)}000000000000000000000000000000000000000000000000000000000000000001`
// we get gas limit from this call, then it needs to be multiplied by the gas price
// https://safe-relay.gnosis.pm/api/v1/gas-station/
// https://safe-relay.rinkeby.gnosis.pm/api/v1/about/
const estimate = await safeInstance.execTransaction.estimateGas(
to,
valueInWei,
data,
operation,
0,
0,
0,
'0x0000000000000000000000000000000000000000',
'0x0000000000000000000000000000000000000000',
sigs,
{ from: '0xbc2BB26a6d821e69A38016f3858561a1D80d4182' },
)
return estimate
} catch (error) {
// eslint-disable-next-line
console.log('Error calculating tx gas estimation ' + error)
return 0
}
}

View File

@ -0,0 +1,5 @@
// @flow
export * from './gas'
export * from './send'
export * from './safeBlockchainOperations'
export * from './safeTxSignerEIP712'

View File

@ -0,0 +1,142 @@
// @flow
import { List } from 'immutable'
import { calculateGasOf, checkReceiptStatus, calculateGasPrice } from '~/logic/wallets/ethTransactions'
import { type Operation, submitOperation } from '~/logic/safe/safeTxHistory'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { buildSignaturesFrom } from '~/logic/safe/safeTxSigner'
import { generateMetamaskSignature, generateTxGasEstimateFrom, estimateDataGas } from '~/logic/safe/transactions'
import { storeSignature, getSignaturesFrom } from '~/utils/storage/signatures'
import { signaturesViaMetamask } from '~/config'
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,
// 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')
// return txHash
// }

View File

@ -1,84 +1,6 @@
// @flow // @flow
import { getWeb3 } from '~/logic/wallets/getWeb3' import { getWeb3 } from '~/logic/wallets/getWeb3'
import { BigNumber } from 'bignumber.js'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions' import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import { getSignaturesFrom } from '~/utils/storage/signatures'
const estimateDataGasCosts = (data) => {
const reducer = (accumulator, currentValue) => {
if (currentValue === EMPTY_DATA) {
return accumulator + 0
}
if (currentValue === '00') {
return accumulator + 4
}
return accumulator + 68
}
return data.match(/.{2}/g).reduce(reducer, 0)
}
export const estimateDataGas = (
safe: any,
to: string,
valueInWei: number,
data: string,
operation: number,
txGasEstimate: number,
gasToken: number,
nonce: number,
signatureCount: number,
refundReceiver: number,
) => {
// numbers < 256 are 192 -> 31 * 4 + 68
// numbers < 65k are 256 -> 30 * 4 + 2 * 68
// For signature array length and dataGasEstimate we already calculated
// the 0 bytes so we just add 64 for each non-zero byte
const gasPrice = 0 // no need to get refund when we submit txs to metamask
const signatureCost = signatureCount * (68 + 2176 + 2176) // array count (3 -> r, s, v) * signature count
const sigs = getSignaturesFrom(safe.address, nonce)
const payload = safe.contract.methods
.execTransaction(to, valueInWei, data, operation, txGasEstimate, 0, gasPrice, gasToken, refundReceiver, sigs)
.encodeABI()
let dataGasEstimate = estimateDataGasCosts(payload) + signatureCost
if (dataGasEstimate > 65536) {
dataGasEstimate += 64
} else {
dataGasEstimate += 128
}
return dataGasEstimate + 34000 // Add aditional gas costs (e.g. base tx costs, transfer costs)
}
// eslint-disable-next-line
export const generateTxGasEstimateFrom = async (
safe: any,
safeAddress: string,
data: string,
to: string,
valueInWei: number,
operation: number,
) => {
try {
const estimateData = safe.contract.methods.requiredTxGas(to, valueInWei, data, operation).encodeABI()
const estimateResponse = await getWeb3().eth.call({
to: safeAddress,
from: safeAddress,
data: estimateData,
})
const txGasEstimate = new BigNumber(estimateResponse.substring(138), 16)
// Add 10k else we will fail in case of nested calls
return Promise.resolve(txGasEstimate.toNumber() + 10000)
} catch (error) {
// eslint-disable-next-line
console.log('Error calculating tx gas estimation ' + error)
return Promise.resolve(0)
}
}
const generateTypedDataFrom = async ( const generateTypedDataFrom = async (
safe: any, safe: any,
@ -167,7 +89,7 @@ export const generateMetamaskSignature = async (
// To change once Metamask fixes their status // To change once Metamask fixes their status
// https://github.com/MetaMask/metamask-extension/pull/5368 // https://github.com/MetaMask/metamask-extension/pull/5368
// https://github.com/MetaMask/metamask-extension/issues/5366 // https://github.com/MetaMask/metamask-extension/issues/5366
params: [jsonTypedData, sender], params: [sender, jsonTypedData],
from: sender, from: sender,
} }
const txSignedResponse = await web3.currentProvider.sendAsync(signedTypedData) const txSignedResponse = await web3.currentProvider.sendAsync(signedTypedData)

View File

@ -0,0 +1,75 @@
// @flow
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { getStandardTokenContract } from '~/logic/tokens/store/actions/fetchTokens'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import { isEther } from '~/logic/tokens/utils/tokenHelpers'
import { type Token } from '~/logic/tokens/store/model/token'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
export const CALL = 0
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
export const executeTransaction = async (
safeInstance: any,
to: string,
valueInWei: number,
data: string,
operation: number | string,
nonce: string | number,
sender: string,
) => {
try {
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
const sigs = `0x000000000000000000000000${sender.replace(
'0x',
'',
)}000000000000000000000000000000000000000000000000000000000000000001`
const tx = await safeInstance.execTransaction(
to,
valueInWei,
data,
CALL,
0,
0,
0,
ZERO_ADDRESS,
ZERO_ADDRESS,
sigs,
{ from: sender },
)
return tx
} catch (error) {
// eslint-disable-next-line
console.log('Error executing the TX: ' + error)
return 0
}
}
export const createTransaction = async (safeAddress: string, to: string, valueInEth: string, token: Token) => {
const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
const web3 = getWeb3()
const from = web3.currentProvider.selectedAddress
const threshold = await safeInstance.getThreshold()
const nonce = await safeInstance.nonce()
const valueInWei = web3.utils.toWei(valueInEth, 'ether')
const isExecution = threshold.toNumber() === 1
let txData = EMPTY_DATA
if (!isEther(token.symbol)) {
const StandardToken = await getStandardTokenContract()
const sendToken = await StandardToken.at(token.address)
txData = sendToken.contract.transfer(to, valueInWei).encodeABI()
}
let txHash
if (isExecution) {
txHash = await executeTransaction(safeInstance, to, valueInWei, txData, CALL, nonce, from)
} else {
// txHash = await approveTransaction(safeAddress, to, valueInWei, txData, CALL, nonce)
}
return txHash
}

View File

@ -43,10 +43,7 @@ const styles = {
} }
const Opening = ({ const Opening = ({
classes, classes, name = 'Safe creation process', tx, network,
name = 'Safe creation process',
tx,
network,
}: Props) => ( }: Props) => (
<Page align="center"> <Page align="center">
<Paragraph className={classes.page} color="secondary" size="xxl" weight="bolder" align="center"> <Paragraph className={classes.page} color="secondary" size="xxl" weight="bolder" align="center">
@ -68,13 +65,25 @@ const Opening = ({
</Block> </Block>
<Block margin="md"> <Block margin="md">
<Paragraph size="md" align="center" weight="light" noMargin> <Paragraph size="md" align="center" weight="light" noMargin>
This process should take a couple of minutes. <br /> This process should take a couple of minutes.
{' '}
<br />
</Paragraph> </Paragraph>
{ tx && {tx && (
<Paragraph className={classes.follow} size="md" align="center" weight="light" noMargin> <Paragraph className={classes.follow} size="md" align="center" weight="light" noMargin>
Follow progress on <a href={openTxInEtherScan(tx, network)} target="_blank" rel="noopener noreferrer" className={classes.etherscan}>Etherscan.io<OpenInNew className={classes.icon} /></a> Follow progress on
{' '}
<a
href={openTxInEtherScan(tx, network)}
target="_blank"
rel="noopener noreferrer"
className={classes.etherscan}
>
Etherscan.io
<OpenInNew className={classes.icon} />
</a>
</Paragraph> </Paragraph>
} )}
</Block> </Block>
</Page> </Page>
) )

View File

@ -1,5 +0,0 @@
// @flow
import AssetTableCell from './AssetTableCell'
export default AssetTableCell

View File

@ -23,6 +23,7 @@ const styles = () => ({
padding: `${sm} ${lg}`, padding: `${sm} ${lg}`,
justifyContent: 'space-between', justifyContent: 'space-between',
maxHeight: '75px', maxHeight: '75px',
boxSizing: 'border-box',
}, },
manage: { manage: {
fontSize: '24px', fontSize: '24px',
@ -93,7 +94,14 @@ const Receive = ({
</Block> </Block>
<Block align="center" className={classes.addressContainer}> <Block align="center" className={classes.addressContainer}>
<Identicon address={safeAddress} diameter={32} /> <Identicon address={safeAddress} diameter={32} />
<Paragraph onClick={copyToClipboard} className={classes.address}>{safeAddress}</Paragraph> <Paragraph
onClick={() => {
copyToClipboard(safeAddress)
}}
className={classes.address}
>
{safeAddress}
</Paragraph>
<Link className={classes.open} to={etherScanLink} target="_blank"> <Link className={classes.open} to={etherScanLink} target="_blank">
<OpenInNew style={openIconStyle} /> <OpenInNew style={openIconStyle} />
</Link> </Link>

View File

@ -1,40 +0,0 @@
// @flow
import * as React from 'react'
import { withStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import { lg, md } from '~/theme/variables'
const styles = () => ({
heading: {
padding: `${md} ${lg}`,
justifyContent: 'space-between',
},
manage: {
fontSize: '24px',
},
close: {
height: '35px',
width: '35px',
},
})
type Props = {
onClose: () => void,
classes: Object,
}
const Send = ({ classes, onClose }: Props) => (
<React.Fragment>
<Row align="center" grow className={classes.heading}>
<Paragraph className={classes.manage} noMargin>Send Funds</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.close} />
</IconButton>
</Row>
</React.Fragment>
)
export default withStyles(styles)(Send)

View File

@ -0,0 +1,77 @@
// @flow
import React from 'react'
import OpenInNew from '@material-ui/icons/OpenInNew'
import { withStyles } from '@material-ui/core/styles'
import Row from '~/components/layout/Row'
import Col from '~/components/layout/Col'
import Paragraph from '~/components/layout/Paragraph'
import Link from '~/components/layout/Link'
import Bold from '~/components/layout/Bold'
import Block from '~/components/layout/Block'
import Identicon from '~/components/Identicon'
import { copyToClipboard } from '~/utils/clipboard'
import { secondary, xs } from '~/theme/variables'
const openIconStyle = {
height: '16px',
color: secondary,
}
const styles = () => ({
balanceContainer: {
fontSize: '12px',
lineHeight: 1.08,
letterSpacing: -0.5,
backgroundColor: '#eae9ef',
width: 'fit-content',
padding: '6px',
marginTop: xs,
borderRadius: '3px',
},
})
type Props = {
classes: Object,
safeAddress: string,
etherScanLink: string,
safeName: string,
ethBalance: string,
}
const SafeInfo = (props: Props) => {
const {
safeAddress, safeName, etherScanLink, ethBalance, classes,
} = props
return (
<Row margin="md">
<Col xs={1}>
<Identicon address={safeAddress} diameter={32} />
</Col>
<Col xs={11} layout="column">
<Paragraph weight="bolder" noMargin style={{ lineHeight: 1 }}>
{safeName}
</Paragraph>
<Paragraph weight="bolder" onClick={copyToClipboard} noMargin>
{safeAddress}
<Link to={etherScanLink} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Paragraph>
<Block className={classes.balanceContainer}>
<Paragraph noMargin>
Balance:
{' '}
<Bold>
{ethBalance}
{' '}
ETH
</Bold>
</Paragraph>
</Block>
</Col>
</Row>
)
}
export default withStyles(styles)(SafeInfo)

View File

@ -0,0 +1,111 @@
// @flow
import React, { useState, useEffect } from 'react'
import { List } from 'immutable'
import { type Token } from '~/logic/tokens/store/model/token'
import cn from 'classnames'
import { withStyles } from '@material-ui/core/styles'
import Modal from '~/components/Modal'
import ChooseTxType from './screens/ChooseTxType'
import SendFunds from './screens/SendFunds'
import ReviewTx from './screens/ReviewTx'
type Props = {
onClose: () => void,
classes: Object,
isOpen: boolean,
safeAddress: string,
etherScanLink: string,
safeName: string,
ethBalance: string,
tokens: List<Token>,
selectedToken: string,
createTransaction: Function,
}
type ActiveScreen = 'chooseTxType' | 'sendFunds' | 'reviewTx'
type TxStateType =
| {
token: Token,
recipientAddress: string,
amount: string,
}
| Object
const styles = () => ({
smallerModalWindow: {
height: 'auto',
position: 'static',
},
})
const Send = ({
onClose,
isOpen,
classes,
safeAddress,
etherScanLink,
safeName,
ethBalance,
tokens,
selectedToken,
createTransaction,
}: Props) => {
const [activeScreen, setActiveScreen] = useState<ActiveScreen>('sendFunds')
const [tx, setTx] = useState<TxStateType>({})
const smallerModalSize = activeScreen === 'chooseTxType'
const handleTxCreation = (txInfo) => {
setActiveScreen('reviewTx')
setTx(txInfo)
}
const onClickBack = () => setActiveScreen('sendFunds')
useEffect(
() => () => {
setActiveScreen('sendFunds')
setTx({})
},
[isOpen],
)
return (
<Modal
title="Send Tokens"
description="Send Tokens Form"
handleClose={onClose}
open={isOpen}
paperClassName={cn(smallerModalSize && classes.smallerModalWindow)}
>
<React.Fragment>
{activeScreen === 'chooseTxType' && <ChooseTxType onClose={onClose} setActiveScreen={setActiveScreen} />}
{activeScreen === 'sendFunds' && (
<SendFunds
onClose={onClose}
setActiveScreen={setActiveScreen}
safeAddress={safeAddress}
etherScanLink={etherScanLink}
safeName={safeName}
ethBalance={ethBalance}
tokens={tokens}
selectedToken={selectedToken}
onSubmit={handleTxCreation}
initialValues={tx}
/>
)}
{activeScreen === 'reviewTx' && (
<ReviewTx
tx={tx}
onClose={onClose}
safeAddress={safeAddress}
etherScanLink={etherScanLink}
safeName={safeName}
ethBalance={ethBalance}
onClickBack={onClickBack}
createTransaction={createTransaction}
/>
)}
</React.Fragment>
</Modal>
)
}
export default withStyles(styles)(Send)

View File

@ -0,0 +1,78 @@
// @flow
import * as React from 'react'
import { withStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton'
import Paragraph from '~/components/layout/Paragraph'
import Button from '~/components/layout/Button'
import Row from '~/components/layout/Row'
import Col from '~/components/layout/Col'
import Hairline from '~/components/layout/Hairline'
import { lg, sm } from '~/theme/variables'
const styles = () => ({
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'space-between',
boxSizing: 'border-box',
maxHeight: '75px',
},
manage: {
fontSize: '24px',
},
closeIcon: {
height: '35px',
width: '35px',
},
buttonColumn: {
padding: '52px 0',
},
secondButton: {
marginTop: 10,
},
})
type Props = {
onClose: () => void,
classes: Object,
setActiveScreen: Function,
}
const ChooseTxType = ({ classes, onClose, setActiveScreen }: Props) => (
<React.Fragment>
<Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.manage} noMargin>
Send
</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<Row align="center">
<Col layout="column" middle="xs" className={classes.buttonColumn}>
<Button
color="primary"
minWidth={260}
minHeight={52}
onClick={() => setActiveScreen('sendFunds')}
variant="contained"
>
SEND FUNDS
</Button>
<Button
color="primary"
className={classes.secondButton}
minWidth={260}
minHeight={52}
onClick={onClose}
variant="outlined"
>
SEND CUSTOM TRANSACTION
</Button>
</Col>
</Row>
</React.Fragment>
)
export default withStyles(styles)(ChooseTxType)

View File

@ -0,0 +1,136 @@
// @flow
import React from 'react'
import OpenInNew from '@material-ui/icons/OpenInNew'
import { withStyles } from '@material-ui/core/styles'
import { SharedSnackbarConsumer } from '~/components/SharedSnackBar/Context'
import Close from '@material-ui/icons/Close'
import IconButton from '@material-ui/core/IconButton'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import Link from '~/components/layout/Link'
import Col from '~/components/layout/Col'
import Button from '~/components/layout/Button'
import Img from '~/components/layout/Img'
import Block from '~/components/layout/Block'
import Identicon from '~/components/Identicon'
import { copyToClipboard } from '~/utils/clipboard'
import Hairline from '~/components/layout/Hairline'
import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo'
import { setImageToPlaceholder } from '~/routes/safe/components/Balances/utils'
import ArrowDown from '../assets/arrow-down.svg'
import { secondary } from '~/theme/variables'
import { styles } from './style'
type Props = {
onClose: () => void,
classes: Object,
safeAddress: string,
etherScanLink: string,
safeName: string,
onClickBack: Function,
ethBalance: string,
tx: Object,
createTransaction: Function,
}
const openIconStyle = {
height: '16px',
color: secondary,
}
const ReviewTx = ({
onClose,
classes,
safeAddress,
etherScanLink,
safeName,
ethBalance,
tx,
onClickBack,
createTransaction,
}: Props) => (
<SharedSnackbarConsumer>
{({ openSnackbar }) => (
<React.Fragment>
<Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.headingText} noMargin>
Send Funds
</Paragraph>
<Paragraph className={classes.annotation}>2 of 2</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<Block className={classes.container}>
<SafeInfo
safeAddress={safeAddress}
etherScanLink={etherScanLink}
safeName={safeName}
ethBalance={ethBalance}
/>
<Row margin="md">
<Col xs={1}>
<img src={ArrowDown} alt="Arrow Down" style={{ marginLeft: '8px' }} />
</Col>
<Col xs={11} center="xs" layout="column">
<Hairline />
</Col>
</Row>
<Row margin="xs">
<Paragraph size="md" color="disabled" style={{ letterSpacing: '-0.5px' }} noMargin>
Recipient
</Paragraph>
</Row>
<Row margin="md" align="center">
<Col xs={1}>
<Identicon address={tx.recipientAddress} diameter={32} />
</Col>
<Col xs={11} layout="column">
<Paragraph weight="bolder" onClick={copyToClipboard} noMargin>
{tx.recipientAddress}
<Link to={etherScanLink} target="_blank">
<OpenInNew style={openIconStyle} />
</Link>
</Paragraph>
</Col>
</Row>
<Row margin="xs">
<Paragraph size="md" color="disabled" style={{ letterSpacing: '-0.5px' }} noMargin>
Amount
</Paragraph>
</Row>
<Row margin="md" align="center">
<Img src={tx.token.logoUri} height={28} alt={tx.token.name} onError={setImageToPlaceholder} />
<Paragraph size="md" noMargin className={classes.amount}>
{tx.amount}
{' '}
{tx.token.symbol}
</Paragraph>
</Row>
</Block>
<Hairline style={{ position: 'absolute', bottom: 85 }} />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClickBack}>
Back
</Button>
<Button
type="submit"
className={classes.button}
onClick={() => {
createTransaction(safeAddress, tx.recipientAddress, tx.amount, tx.token, openSnackbar)
onClose()
}}
variant="contained"
minWidth={140}
color="primary"
>
SUBMIT
</Button>
</Row>
</React.Fragment>
)}
</SharedSnackbarConsumer>
)
export default withStyles(styles)(ReviewTx)

View File

@ -0,0 +1,37 @@
// @flow
import { lg, md, sm } from '~/theme/variables'
export const styles = () => ({
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: '#a2a8ba',
marginRight: 'auto',
marginLeft: '20px',
},
headingText: {
fontSize: '24px',
},
closeIcon: {
height: '35px',
width: '35px',
},
container: {
padding: `${md} ${lg}`,
},
amount: {
marginLeft: sm,
},
buttonRow: {
height: '84px',
justifyContent: 'center',
position: 'absolute',
bottom: 0,
width: '100%',
},
})

View File

@ -0,0 +1,83 @@
// @flow
import React, { useEffect, useState } from 'react'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import MenuItem from '@material-ui/core/MenuItem'
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText'
import Field from '~/components/forms/Field'
import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph'
import SelectField from '~/components/forms/SelectField'
import { setImageToPlaceholder } from '~/routes/safe/components/Balances/utils'
import { required } from '~/components/forms/validator'
import { type Token } from '~/logic/tokens/store/model/token'
import { selectedTokenStyles, selectStyles } from './style'
type SelectFieldProps = {
tokens: List<Token>,
classes: Object,
initialValue: string,
}
type SelectedTokenProps = {
token?: Token,
classes: Object,
}
const SelectedToken = ({ token, classes }: SelectedTokenProps) => (
<MenuItem className={classes.container}>
{token ? (
<>
<ListItemIcon className={classes.tokenImage}>
<Img src={token.logoUri} height={28} alt={token.name} onError={setImageToPlaceholder} />
</ListItemIcon>
<ListItemText
className={classes.tokenData}
primary={token.name}
secondary={`${token.balance} ${token.symbol}`}
/>
</>
) : (
<Paragraph color="disabled" size="lg" weight="light" style={{ opacity: 0.5 }}>
Select an asset*
</Paragraph>
)}
</MenuItem>
)
const SelectedTokenStyled = withStyles(selectedTokenStyles)(SelectedToken)
type InitialTokenType = Token | string
const TokenSelectField = ({ tokens, classes, initialValue }: SelectFieldProps) => {
const [initialToken, setInitialToken] = useState<InitialTokenType>('')
useEffect(() => {
const selectedToken = tokens.find(token => token.name === initialValue)
setInitialToken(selectedToken || '')
}, [initialValue])
return (
<Field
name="token"
component={SelectField}
classes={{ selectMenu: classes.selectMenu }}
validate={required}
renderValue={token => <SelectedTokenStyled token={token} />}
initialValue={initialToken}
displayEmpty
>
{tokens.map(token => (
<MenuItem key={token.address} value={token}>
<ListItemIcon>
<Img src={token.logoUri} height={28} alt={token.name} onError={setImageToPlaceholder} />
</ListItemIcon>
<ListItemText primary={token.name} secondary={`${token.balance} ${token.symbol}`} />
</MenuItem>
))}
</Field>
)
}
export default withStyles(selectStyles)(TokenSelectField)

View File

@ -0,0 +1,24 @@
// @flow
import { sm } from '~/theme/variables'
export const selectedTokenStyles = () => ({
container: {
minHeight: '55px',
padding: 0,
width: '100%',
},
tokenData: {
padding: 0,
margin: 0,
lineHeight: '14px',
},
tokenImage: {
marginRight: sm,
},
})
export const selectStyles = () => ({
selectMenu: {
paddingRight: 0,
},
})

View File

@ -0,0 +1,177 @@
// @flow
import React from 'react'
import { List } from 'immutable'
import { withStyles } from '@material-ui/core/styles'
import { OnChange } from 'react-final-form-listeners'
import Close from '@material-ui/icons/Close'
import InputAdornment from '@material-ui/core/InputAdornment'
import IconButton from '@material-ui/core/IconButton'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import GnoForm from '~/components/forms/GnoForm'
import Col from '~/components/layout/Col'
import Button from '~/components/layout/Button'
import Block from '~/components/layout/Block'
import Hairline from '~/components/layout/Hairline'
import ButtonLink from '~/components/layout/ButtonLink'
import Field from '~/components/forms/Field'
import TextField from '~/components/forms/TextField'
import { type Token } from '~/logic/tokens/store/model/token'
import {
composeValidators,
required,
mustBeEthereumAddress,
mustBeFloat,
maxValue,
greaterThan,
} from '~/components/forms/validator'
import TokenSelectField from '~/routes/safe/components/Balances/SendModal/screens/SendFunds/TokenSelectField'
import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo'
import ArrowDown from '../assets/arrow-down.svg'
import { styles } from './style'
type Props = {
onClose: () => void,
classes: Object,
safeAddress: string,
etherScanLink: string,
safeName: string,
ethBalance: string,
selectedToken: string,
tokens: List<Token>,
onSubmit: Function,
initialValues: Object,
}
const SendFunds = ({
classes,
onClose,
safeAddress,
etherScanLink,
safeName,
ethBalance,
tokens,
selectedToken,
initialValues,
onSubmit,
}: Props) => {
const handleSubmit = (values) => {
onSubmit(values)
}
const formMutators = {
setMax: (args, state, utils) => {
const { token } = state.formState.values
utils.changeValue(state, 'amount', () => token && token.balance)
},
onTokenChange: (args, state, utils) => {
utils.changeValue(state, 'amount', () => '')
},
}
return (
<React.Fragment>
<Row align="center" grow className={classes.heading}>
<Paragraph weight="bolder" className={classes.manage} noMargin>
Send Funds
</Paragraph>
<Paragraph className={classes.annotation}>1 of 2</Paragraph>
<IconButton onClick={onClose} disableRipple>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<Block className={classes.formContainer}>
<SafeInfo safeAddress={safeAddress} etherScanLink={etherScanLink} safeName={safeName} ethBalance={ethBalance} />
<Row margin="md">
<Col xs={1}>
<img src={ArrowDown} alt="Arrow Down" style={{ marginLeft: '8px' }} />
</Col>
<Col xs={11} center="xs" layout="column">
<Hairline />
</Col>
</Row>
<GnoForm onSubmit={handleSubmit} formMutators={formMutators} initialValues={initialValues}>
{(...args) => {
const formState = args[2]
const mutators = args[3]
const { token } = formState.values
return (
<React.Fragment>
<Row margin="md">
<Col xs={12}>
<Field
name="recipientAddress"
component={TextField}
type="text"
validate={composeValidators(required, mustBeEthereumAddress)}
placeholder="Recipient*"
text="Recipient*"
className={classes.addressInput}
/>
</Col>
</Row>
<Row margin="sm">
<Col>
<TokenSelectField tokens={tokens} initialValue={selectedToken} />
</Col>
</Row>
<Row margin="xs">
<Col between="lg">
<Paragraph size="md" color="disabled" style={{ letterSpacing: '-0.5px' }} noMargin>
Amount
</Paragraph>
<ButtonLink weight="bold" onClick={mutators.setMax}>
Send max
</ButtonLink>
</Col>
</Row>
<Row margin="md">
<Col>
<Field
name="amount"
component={TextField}
type="text"
validate={composeValidators(
required,
mustBeFloat,
greaterThan(0),
maxValue(token && token.balance),
)}
placeholder="Amount*"
text="Amount*"
className={classes.addressInput}
inputAdornment={
token && {
endAdornment: <InputAdornment position="end">{token.symbol}</InputAdornment>,
}
}
/>
<OnChange name="token">
{() => {
mutators.onTokenChange()
}}
</OnChange>
</Col>
</Row>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button className={classes.button} minWidth={140} onClick={onClose}>
Cancel
</Button>
<Button type="submit" className={classes.button} variant="contained" minWidth={140} color="primary">
Review
</Button>
</Row>
</React.Fragment>
)
}}
</GnoForm>
</Block>
</React.Fragment>
)
}
export default withStyles(styles)(SendFunds)

View File

@ -0,0 +1,31 @@
// @flow
import { lg, md, sm } from '~/theme/variables'
export const styles = () => ({
heading: {
padding: `${sm} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: '#a2a8ba',
marginRight: 'auto',
marginLeft: '20px',
},
manage: {
fontSize: '24px',
},
closeIcon: {
height: '35px',
width: '35px',
},
formContainer: {
padding: `${md} ${lg}`,
},
buttonRow: {
height: '84px',
justifyContent: 'center',
},
})

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="21" viewBox="0 0 13 21">
<path fill="#A2A8BA" fill-rule="evenodd" d="M8.7 11.266V0H4.27v11.266H0l6.484 9.172 6.493-9.172z"/>
</svg>

After

Width:  |  Height:  |  Size: 195 B

View File

@ -21,9 +21,10 @@ type Props = Actions & {
safeAddress: string, safeAddress: string,
activeTokens: List<Token>, activeTokens: List<Token>,
} }
type ActiveScreen = 'tokenList' | 'addCustomToken'
const Tokens = (props: Props) => { const Tokens = (props: Props) => {
const [activeScreen, setActiveScreen] = useState<string>('tokenList') const [activeScreen, setActiveScreen] = useState<ActiveScreen>('tokenList')
const { const {
onClose, onClose,
classes, classes,

View File

@ -158,7 +158,7 @@ class Tokens extends React.Component<Props, State> {
return ( return (
<ListItem key={token.address} className={classes.token}> <ListItem key={token.address} className={classes.token}>
<ListItemIcon> <ListItemIcon className={classes.tokenIcon}>
<Img src={token.logoUri} height={28} alt={token.name} onError={setImageToPlaceholder} /> <Img src={token.logoUri} height={28} alt={token.name} onError={setImageToPlaceholder} />
</ListItemIcon> </ListItemIcon>
<ListItemText primary={token.symbol} secondary={token.name} /> <ListItemText primary={token.symbol} secondary={token.name} />

View File

@ -47,6 +47,9 @@ export const styles = () => ({
letterSpacing: '-0.5px', letterSpacing: '-0.5px',
}, },
}, },
tokenIcon: {
marginRight: md,
},
progressContainer: { progressContainer: {
width: '100%', width: '100%',
height: '100%', height: '100%',

View File

@ -21,7 +21,7 @@ import {
} from './dataFetcher' } from './dataFetcher'
import AssetTableCell from './AssetTableCell' import AssetTableCell from './AssetTableCell'
import Tokens from './Tokens' import Tokens from './Tokens'
import Send from './Send' import SendModal from './SendModal'
import Receive from './Receive' import Receive from './Receive'
import { styles } from './style' import { styles } from './style'
@ -29,7 +29,7 @@ type State = {
hideZero: boolean, hideZero: boolean,
showToken: boolean, showToken: boolean,
showReceive: boolean, showReceive: boolean,
showSend: boolean, sendFunds: Object,
} }
type Props = { type Props = {
@ -40,6 +40,8 @@ type Props = {
safeAddress: string, safeAddress: string,
safeName: string, safeName: string,
etherScanLink: string, etherScanLink: string,
ethBalance: string,
createTransaction: Function,
} }
type Action = 'Token' | 'Send' | 'Receive' type Action = 'Token' | 'Send' | 'Receive'
@ -48,7 +50,10 @@ class Balances extends React.Component<Props, State> {
state = { state = {
hideZero: false, hideZero: false,
showToken: false, showToken: false,
showSend: false, sendFunds: {
isOpen: false,
selectedToken: undefined,
},
showReceive: false, showReceive: false,
} }
@ -60,6 +65,24 @@ class Balances extends React.Component<Props, State> {
this.setState(() => ({ [`show${action}`]: false })) this.setState(() => ({ [`show${action}`]: false }))
} }
showSendFunds = (token: Token) => {
this.setState({
sendFunds: {
isOpen: true,
selectedToken: token,
},
})
}
hideSendFunds = () => {
this.setState({
sendFunds: {
isOpen: false,
selectedToken: undefined,
},
})
}
handleChange = (e: SyntheticInputEvent<HTMLInputElement>) => { handleChange = (e: SyntheticInputEvent<HTMLInputElement>) => {
const { checked } = e.target const { checked } = e.target
@ -68,10 +91,18 @@ class Balances extends React.Component<Props, State> {
render() { render() {
const { const {
hideZero, showToken, showReceive, showSend, hideZero, showToken, showReceive, sendFunds,
} = this.state } = this.state
const { const {
classes, granted, tokens, safeAddress, activeTokens, safeName, etherScanLink, classes,
granted,
tokens,
safeAddress,
activeTokens,
safeName,
etherScanLink,
ethBalance,
createTransaction,
} = this.props } = this.props
const columns = generateColumns() const columns = generateColumns()
@ -137,7 +168,7 @@ class Balances extends React.Component<Props, State> {
size="small" size="small"
color="secondary" color="secondary"
className={classes.send} className={classes.send}
onClick={this.onShow('Send')} onClick={() => this.showSendFunds(row.asset.name)}
> >
<CallMade className={classNames(classes.leftIcon, classes.iconSmall)} /> <CallMade className={classNames(classes.leftIcon, classes.iconSmall)} />
Send Send
@ -159,9 +190,17 @@ class Balances extends React.Component<Props, State> {
)) ))
} }
</Table> </Table>
<Modal title="Send Tokens" description="Send Tokens Form" handleClose={this.onHide('Send')} open={showSend}> <SendModal
<Send onClose={this.onHide('Send')} /> onClose={this.hideSendFunds}
</Modal> isOpen={sendFunds.isOpen}
etherScanLink={etherScanLink}
safeAddress={safeAddress}
safeName={safeName}
ethBalance={ethBalance}
tokens={activeTokens}
selectedToken={sendFunds.selectedToken}
createTransaction={createTransaction}
/>
<Modal <Modal
title="Receive Tokens" title="Receive Tokens"
description="Receive Tokens Form" description="Receive Tokens Form"

View File

@ -13,7 +13,7 @@ export const styles = (theme: Object) => ({
margin: `${sm} 0`, margin: `${sm} 0`,
}, },
actionIcon: { actionIcon: {
marginRight: theme.spacing.unit, marginRight: theme.spacing(1),
}, },
iconSmall: { iconSmall: {
fontSize: 16, fontSize: 16,

View File

@ -23,6 +23,7 @@ import Balances from './Balances'
type Props = SelectorProps & { type Props = SelectorProps & {
classes: Object, classes: Object,
granted: boolean, granted: boolean,
createTransaction: Function,
} }
type State = { type State = {
@ -88,7 +89,7 @@ class Layout extends React.Component<Props, State> {
render() { render() {
const { const {
safe, provider, network, classes, granted, tokens, activeTokens, safe, provider, network, classes, granted, tokens, activeTokens, createTransaction,
} = this.props } = this.props
const { tabIndex } = this.state const { tabIndex } = this.state
@ -137,6 +138,7 @@ class Layout extends React.Component<Props, State> {
safeAddress={address} safeAddress={address}
safeName={name} safeName={name}
etherScanLink={etherScanLink} etherScanLink={etherScanLink}
createTransaction={createTransaction}
/> />
)} )}
</React.Fragment> </React.Fragment>

View File

@ -14,7 +14,7 @@ type FormProps = {
} }
type Props = { type Props = {
symbol: string symbol: string,
} }
const spinnerStyle = { const spinnerStyle = {
@ -25,14 +25,14 @@ const ReviewTx = ({ symbol }: Props) => (controls: React$Node, { values, submitt
<OpenPaper controls={controls}> <OpenPaper controls={controls}>
<Heading tag="h2">Review the move token funds</Heading> <Heading tag="h2">Review the move token funds</Heading>
<Paragraph align="left"> <Paragraph align="left">
<Bold>Destination: </Bold> {values[TKN_DESTINATION_PARAM]} <Bold>Destination: </Bold>
{' '}
{values[TKN_DESTINATION_PARAM]}
</Paragraph> </Paragraph>
<Paragraph align="left"> <Paragraph align="left">
<Bold>{`Amount to transfer: ${values[TKN_VALUE_PARAM]} ${symbol}`}</Bold> <Bold>{`Amount to transfer: ${values[TKN_VALUE_PARAM]} ${symbol}`}</Bold>
</Paragraph> </Paragraph>
<Block style={spinnerStyle}> <Block style={spinnerStyle}>{submitting && <CircularProgress size={50} />}</Block>
{ submitting && <CircularProgress size={50} /> }
</Block>
</OpenPaper> </OpenPaper>
) )

View File

@ -1,13 +1,16 @@
// @flow // @flow
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'
export type Actions = { export type Actions = {
fetchSafe: typeof fetchSafe, fetchSafe: typeof fetchSafe,
fetchTokenBalances: typeof fetchTokenBalances, fetchTokenBalances: typeof fetchTokenBalances,
createTransaction: typeof createTransaction,
} }
export default { export default {
fetchSafe, fetchSafe,
fetchTokenBalances, fetchTokenBalances,
createTransaction,
} }

View File

@ -52,7 +52,7 @@ class SafeView extends React.Component<Props> {
render() { render() {
const { const {
safe, provider, activeTokens, granted, userAddress, network, tokens, safe, provider, activeTokens, granted, userAddress, network, tokens, createTransaction,
} = this.props } = this.props
return ( return (
@ -65,6 +65,7 @@ class SafeView extends React.Component<Props> {
userAddress={userAddress} userAddress={userAddress}
network={network} network={network}
granted={granted} granted={granted}
createTransaction={createTransaction}
/> />
</Page> </Page>
) )

View File

@ -31,7 +31,7 @@ const saveSafe = (
threshold: number, threshold: number,
ownersName: string[], ownersName: string[],
ownersAddress: string[], ownersAddress: string[],
) => async (dispatch: ReduxDispatch<GlobalState>) => { ) => (dispatch: ReduxDispatch<GlobalState>) => {
const owners: List<Owner> = buildOwnersFrom(ownersName, ownersAddress) const owners: List<Owner> = buildOwnersFrom(ownersName, ownersAddress)
const safe: Safe = SafeRecord({ const safe: Safe = SafeRecord({

View File

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

View File

@ -0,0 +1,62 @@
// @flow
import type { Dispatch as ReduxDispatch, GetState } from 'redux'
import { createAction } from 'redux-actions'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import { EMPTY_DATA } from '~/logic/wallets/ethTransactions'
import { type Token } from '~/logic/tokens/store/model/token'
import { userAccountSelector } from '~/logic/wallets/store/selectors'
import { type GlobalState } from '~/store'
import { isEther } from '~/logic/tokens/utils/tokenHelpers'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { executeTransaction, CALL } from '~/logic/safe/transactions'
import { getStandardTokenContract } from '~/logic/tokens/store/actions/fetchTokens'
export const ADD_TRANSACTIONS = 'ADD_TRANSACTIONS'
export const addTransactions = createAction<string, *>(ADD_TRANSACTIONS)
const createTransaction = (
safeAddress: string,
to: string,
valueInEth: string,
token: Token,
openSnackbar: Function,
) => async (dispatch: ReduxDispatch<GlobalState>, getState: GetState<GlobalState>) => {
const isSendingETH = isEther(token.symbol)
const state: GlobalState = getState()
const safeInstance = await getGnosisSafeInstanceAt(safeAddress)
const web3 = getWeb3()
const from = userAccountSelector(state)
const threshold = await safeInstance.getThreshold()
const nonce = await safeInstance.nonce()
const txRecipient = isSendingETH ? to : token.address
const valueInWei = web3.utils.toWei(valueInEth, 'ether')
let txAmount = valueInWei
const isExecution = threshold.toNumber() === 1
let txData = EMPTY_DATA
if (!isSendingETH) {
const StandardToken = await getStandardTokenContract()
const sendToken = await StandardToken.at(token.address)
txData = sendToken.contract.methods.transfer(to, valueInWei).encodeABI()
// txAmount should be 0 if we send tokens
// the real value is encoded in txData and will be used by the contract
// if txAmount > 0 it would send ETH from the safe
txAmount = 0
}
let txHash
if (isExecution) {
openSnackbar('Transaction has been submitted', 'success')
txHash = await executeTransaction(safeInstance, txRecipient, txAmount, txData, CALL, nonce, from)
openSnackbar('Transaction has been confirmed', 'success')
} else {
// txHash = await approveTransaction(safeAddress, to, valueInWei, txData, CALL, nonce)
}
// dispatch(addTransactions(txHash))
return txHash
}
export default createTransaction

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 addTransactions, { ADD_TRANSACTIONS } from '~/routes/safe/store/actions/addTransactions' import { ADD_TRANSACTIONS } from '~/routes/safe/store/actions/createTransaction'
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'
@ -10,7 +10,7 @@ export type State = Map<string, List<Transaction>>
export default handleActions<State, *>( export default handleActions<State, *>(
{ {
[ADD_TRANSACTIONS]: (state: State, action: ActionType<typeof addTransactions>): State => action.payload, [ADD_TRANSACTIONS]: (state: State, action: ActionType<Function>): State => action.payload,
}, },
Map(), Map(),
) )

View File

@ -12,6 +12,7 @@ import {
bolderFont, bolderFont,
boldFont, boldFont,
buttonLargeFontSize, buttonLargeFontSize,
xs,
} from './variables' } from './variables'
export type WithStyles = { export type WithStyles = {
@ -100,6 +101,9 @@ export default createMuiTheme({
letterSpacing: '-0.5px', letterSpacing: '-0.5px',
fontSize: mediumFontSize, fontSize: mediumFontSize,
}, },
body2: {
fontFamily: 'Roboto Mono, monospace',
},
}, },
MuiFormHelperText: { MuiFormHelperText: {
root: { root: {
@ -111,7 +115,7 @@ export default createMuiTheme({
color: secondary, color: secondary,
order: 0, order: 0,
marginTop: '0px', marginTop: '0px',
backgroundColor: 'EAE9EF', backgroundColor: '#EAE9EF',
}, },
}, },
MuiInput: { MuiInput: {
@ -123,6 +127,7 @@ export default createMuiTheme({
order: 1, order: 1,
padding: `0 ${md}`, padding: `0 ${md}`,
backgroundColor: '#EAE9EF', backgroundColor: '#EAE9EF',
borderRadius: '5px',
'&:$disabled': { '&:$disabled': {
color: '#0000ff', color: '#0000ff',
}, },
@ -131,6 +136,7 @@ export default createMuiTheme({
padding: 0, padding: 0,
letterSpacing: '0.5px', letterSpacing: '0.5px',
color: primary, color: primary,
height: 'auto',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
display: 'flex', display: 'flex',
'&::-webkit-input-placeholder': { '&::-webkit-input-placeholder': {
@ -213,6 +219,8 @@ export default createMuiTheme({
color: primary, color: primary,
letterSpacing: '-0.5px', letterSpacing: '-0.5px',
fontWeight: 'normal', fontWeight: 'normal',
paddingTop: xs,
paddingBottom: xs,
}, },
}, },
MuiBackdrop: { MuiBackdrop: {
@ -221,6 +229,16 @@ export default createMuiTheme({
backgroundColor: 'rgba(228, 232, 241, 0.75)', backgroundColor: 'rgba(228, 232, 241, 0.75)',
}, },
}, },
MuiMenuItem: {
root: {
fontFamily: 'Roboto Mono, monospace',
},
},
MuiListItemIcon: {
root: {
minWidth: 'auto',
},
},
MuiListItemText: { MuiListItemText: {
primary: { primary: {
fontFamily: 'Roboto Mono, monospace', fontFamily: 'Roboto Mono, monospace',

2503
yarn.lock

File diff suppressed because it is too large Load Diff