(Fix) [Contract Interaction] Parameter validation (#974)

This commit is contained in:
Fernando 2020-06-06 10:32:25 -03:00 committed by GitHub
parent 5a7ced92ad
commit cefb33b593
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 313 additions and 1079 deletions

View File

@ -170,7 +170,7 @@
"electron-updater": "4.3.1", "electron-updater": "4.3.1",
"eth-sig-util": "^2.5.3", "eth-sig-util": "^2.5.3",
"express": "^4.17.1", "express": "^4.17.1",
"final-form": "4.20.0", "final-form": "^4.20.0",
"final-form-calculate": "^1.3.1", "final-form-calculate": "^1.3.1",
"history": "4.10.1", "history": "4.10.1",
"immortal-db": "^1.0.2", "immortal-db": "^1.0.2",
@ -185,7 +185,7 @@
"query-string": "6.12.1", "query-string": "6.12.1",
"react": "16.13.1", "react": "16.13.1",
"react-dom": "16.13.1", "react-dom": "16.13.1",
"react-final-form": "6.5.0", "react-final-form": "^6.5.0",
"react-final-form-listeners": "^1.0.2", "react-final-form-listeners": "^1.0.2",
"react-ga": "^2.7.0", "react-ga": "^2.7.0",
"react-hot-loader": "4.12.21", "react-hot-loader": "4.12.21",
@ -234,8 +234,9 @@
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"prettier": "2.0.5", "prettier": "2.0.5",
"react-app-rewired": "^2.1.6", "react-app-rewired": "^2.1.6",
"truffle": "5.1.28", "truffle": "5.1.23",
"typescript": "3.9.3", "typescript": "~3.7.2",
"wait-on": "5.0.0" "wait-on": "5.0.0",
"web3-utils": "^1.2.8"
} }
} }

View File

@ -33,13 +33,15 @@ class TextField extends React.PureComponent<any> {
} = this.props } = this.props
const helperText = value ? text : undefined const helperText = value ? text : undefined
const showError = (meta.touched || !meta.pristine) && !meta.valid const showError = (meta.touched || !meta.pristine) && !meta.valid
const hasError = !!meta.error || (!meta.modifiedSinceLastSubmit && !!meta.submitError)
const errorMessage = meta.error || meta.submitError
const isInactiveAndPristineOrUntouched = !meta.active && (meta.pristine || !meta.touched) const isInactiveAndPristineOrUntouched = !meta.active && (meta.pristine || !meta.touched)
const isInvalidAndUntouched = typeof meta.error === 'undefined' ? true : !meta.touched const isInvalidAndUntouched = typeof meta.error === 'undefined' ? true : !meta.touched
const disableUnderline = isInactiveAndPristineOrUntouched && isInvalidAndUntouched const disableUnderline = isInactiveAndPristineOrUntouched && isInvalidAndUntouched
const inputRoot = helperText ? classes.root : '' const inputRoot = helperText ? classes.root : ''
const statusClasses = meta.valid ? 'isValid' : meta.error && (meta.dirty || meta.touched) ? 'isInvalid' : '' const statusClasses = meta.valid ? 'isValid' : hasError && showError ? 'isInvalid' : ''
const inputProps = { const inputProps = {
...restInput, ...restInput,
autoComplete: 'off', autoComplete: 'off',
@ -53,8 +55,8 @@ class TextField extends React.PureComponent<any> {
return ( return (
<MuiTextField <MuiTextField
error={meta.error && (meta.touched || !meta.pristine)} error={hasError && showError}
helperText={showError ? meta.error : helperText || ' '} helperText={hasError && showError ? errorMessage : helperText || ' '}
inputProps={inputProps} // blank in order to force to have helper text inputProps={inputProps} // blank in order to force to have helper text
InputProps={inputRootProps} InputProps={inputRootProps}
multiline={multiline} multiline={multiline}

View File

@ -1,41 +0,0 @@
import { getWeb3 } from 'src/logic/wallets/getWeb3'
class ABIService {
static extractUsefulMethods(abi) {
return abi
.filter(({ constant, name, type }) => type === 'function' && !!name && typeof constant === 'boolean')
.map((method) => ({
action: method.constant ? 'read' : 'write',
...ABIService.getMethodSignatureAndSignatureHash(method),
...method,
}))
.sort(({ name: a }, { name: b }) => (a.toLowerCase() > b.toLowerCase() ? 1 : -1))
}
static getMethodHash(method) {
const signature = ABIService.getMethodSignature(method)
return ABIService.getSignatureHash(signature)
}
static getMethodSignatureAndSignatureHash(method) {
const signature = ABIService.getMethodSignature(method)
const signatureHash = ABIService.getSignatureHash(signature)
return { signature, signatureHash }
}
static getMethodSignature({ inputs, name }) {
const params = inputs.map((x) => x.type).join(',')
return `${name}(${params})`
}
static getSignatureHash(signature) {
const web3 = getWeb3()
return web3.utils.keccak256(signature).toString()
}
static isPayable(method) {
return method.payable
}
}
export default ABIService

View File

@ -0,0 +1,48 @@
import { AbiItem } from 'web3-utils'
import { web3ReadOnly as web3 } from 'src/logic/wallets/getWeb3'
export interface AbiItemExtended extends AbiItem {
action: string
methodSignature: string
signatureHash: string
}
export const getMethodSignature = ({ inputs, name }: AbiItem) => {
const params = inputs.map((x) => x.type).join(',')
return `${name}(${params})`
}
export const getSignatureHash = (signature: string): string => {
return web3.utils.keccak256(signature).toString()
}
export const getMethodHash = (method: AbiItem): string => {
const signature = getMethodSignature(method)
return getSignatureHash(signature)
}
export const getMethodSignatureAndSignatureHash = (
method: AbiItem,
): { methodSignature: string; signatureHash: string } => {
const methodSignature = getMethodSignature(method)
const signatureHash = getSignatureHash(methodSignature)
return { methodSignature, signatureHash }
}
export const extractUsefulMethods = (abi: AbiItem[]): AbiItemExtended[] => {
return abi
.filter(({ constant, name, type }) => type === 'function' && !!name && typeof constant === 'boolean')
.map(
(method): AbiItemExtended => ({
action: method.constant ? 'read' : 'write',
...getMethodSignatureAndSignatureHash(method),
...method,
}),
)
.sort(({ name: a }, { name: b }) => (a.toLowerCase() > b.toLowerCase() ? 1 : -1))
}
export const isPayable = (method: AbiItem | AbiItemExtended): boolean => {
return method.payable
}

View File

@ -1,11 +1,10 @@
import { RateLimit } from 'async-sema' import { RateLimit } from 'async-sema'
import memoize from 'lodash.memoize' import memoize from 'lodash.memoize'
import ABIService from 'src/logic/contractInteraction/sources/ABIService'
import { ETHEREUM_NETWORK } from 'src/logic/wallets/getWeb3' import { ETHEREUM_NETWORK } from 'src/logic/wallets/getWeb3'
import { ETHERSCAN_API_KEY } from 'src/utils/constants' import { ETHERSCAN_API_KEY } from 'src/utils/constants'
class EtherscanService extends ABIService { class EtherscanService {
_rateLimit = async () => {} _rateLimit = async () => {}
_endpointsUrls = { _endpointsUrls = {
@ -38,7 +37,6 @@ class EtherscanService extends ABIService {
) )
constructor(options) { constructor(options) {
super()
this._rateLimit = RateLimit(options.rps) this._rateLimit = RateLimit(options.rps)
} }

View File

@ -5,57 +5,45 @@ import { useField, useFormState } from 'react-final-form'
import Button from 'src/components/layout/Button' import Button from 'src/components/layout/Button'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { styles } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style' import { styles } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style'
import { createTxObject } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils' import { isReadMethod } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils'
const useStyles = makeStyles(styles as any) const useStyles = makeStyles(styles)
const Buttons = ({ onCallSubmit, onClose }) => { export interface ButtonProps {
onClose: () => void
}
const Buttons = ({ onClose }: ButtonProps) => {
const classes = useStyles() const classes = useStyles()
const { const {
input: { value: method }, input: { value: method },
} = useField('selectedMethod', { value: true }) } = useField('selectedMethod', { subscription: { value: true } })
const { const { modifiedSinceLastSubmit, submitError, submitting, valid, validating } = useFormState({
input: { value: contractAddress }, subscription: {
} = useField('contractAddress', { valid: true } as any) modifiedSinceLastSubmit: true,
const { submitting, valid, validating, values } = useFormState({ submitError: true,
subscription: { submitting: true, valid: true, values: true, validating: true }, submitting: true,
valid: true,
validating: true,
},
}) })
const handleCallSubmit = async () => {
const results = await createTxObject(method, contractAddress, values).call()
onCallSubmit(results)
}
return ( return (
<Row align="center" className={classes.buttonRow}> <Row align="center" className={classes.buttonRow}>
<Button minWidth={140} onClick={onClose}> <Button minWidth={140} onClick={onClose}>
Cancel Cancel
</Button> </Button>
{method && (method as any).action === 'read' ? ( <Button
<Button className={classes.submitButton}
className={classes.submitButton} color="primary"
color="primary" data-testid={`${isReadMethod(method) ? 'call' : 'review'}-tx-btn`}
data-testid="review-tx-btn" disabled={submitting || validating || ((!valid || !!submitError) && !modifiedSinceLastSubmit) || !method}
disabled={validating || !valid} minWidth={140}
minWidth={140} type="submit"
onClick={handleCallSubmit} variant="contained"
variant="contained" >
> {isReadMethod(method) ? 'Call' : 'Review'}
Call </Button>
</Button>
) : (
<Button
className={classes.submitButton}
color="primary"
data-testid="review-tx-btn"
disabled={submitting || validating || !valid || !method || (method as any).action === 'read'}
minWidth={140}
type="submit"
variant="contained"
>
Review
</Button>
)}
</Row> </Row>
) )
} }

View File

@ -3,19 +3,19 @@ import React from 'react'
import TextareaField from 'src/components/forms/TextareaField' import TextareaField from 'src/components/forms/TextareaField'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import EtherscanService from 'src/logic/contractInteraction/sources/EtherscanService' import { extractUsefulMethods } from 'src/logic/contractInteraction/sources/ABIService'
export const NO_DATA = 'no data' export const NO_DATA = 'no data'
const mustBeValidABI = (abi) => { const mustBeValidABI = (abi: string): undefined | string => {
try { try {
const parsedABI = EtherscanService.extractUsefulMethods(JSON.parse(abi)) const parsedABI = extractUsefulMethods(JSON.parse(abi))
if (parsedABI.length === 0) { if (parsedABI.length === 0) {
return NO_DATA return NO_DATA
} }
} catch (e) { } catch (e) {
return [] return NO_DATA
} }
} }

View File

@ -14,9 +14,17 @@ import Col from 'src/components/layout/Col'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { styles } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style' import { styles } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style'
const useStyles = makeStyles(styles as any) const useStyles = makeStyles(styles)
const EthAddressInput = ({ isContract = true, isRequired = true, name, onScannedValue, text }) => { export interface EthAddressProps {
isContract?: boolean
isRequired?: boolean
name: string
onScannedValue: (scannedValue: string) => void
text: string
}
const EthAddressInput = ({ isContract = true, isRequired = true, name, onScannedValue, text }: EthAddressProps) => {
const classes = useStyles() const classes = useStyles()
const validatorsList = [isRequired && required, mustBeEthereumAddress, isContract && mustBeEthereumContractAddress] const validatorsList = [isRequired && required, mustBeEthereumAddress, isContract && mustBeEthereumContractAddress]
const validate = composeValidators(...validatorsList.filter((_) => _)) const validate = composeValidators(...validatorsList.filter((_) => _))

View File

@ -11,21 +11,24 @@ import ButtonLink from 'src/components/layout/ButtonLink'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
import Paragraph from 'src/components/layout/Paragraph' import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import ABIService from 'src/logic/contractInteraction/sources/ABIService' import { isPayable } from 'src/logic/contractInteraction/sources/ABIService'
import { styles } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style' import { styles } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style'
import { safeSelector } from 'src/routes/safe/store/selectors' import { safeSelector } from 'src/routes/safe/store/selectors'
const useStyles = makeStyles(styles as any) const useStyles = makeStyles(styles)
const EthValue = ({ onSetMax }) => { interface EthValueProps {
onSetMax: (ethBalance: string) => void
}
const EthValue = ({ onSetMax }: EthValueProps) => {
const classes = useStyles() const classes = useStyles()
const { ethBalance } = useSelector(safeSelector) const { ethBalance } = useSelector(safeSelector)
const { const {
input: { value: method }, input: { value: method },
} = useField('selectedMethod', { value: true }) } = useField('selectedMethod', { subscription: { value: true } })
const disabled = !ABIService.isPayable(method) const disabled = !isPayable(method)
return ( return disabled ? null : (
<> <>
<Row className={classes.fullWidth} margin="xs"> <Row className={classes.fullWidth} margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}> <Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
@ -42,7 +45,6 @@ const EthValue = ({ onSetMax }) => {
<Row margin="md"> <Row margin="md">
<Col> <Col>
<Field <Field
className={classes.addressInput}
component={TextField} component={TextField}
disabled={disabled} disabled={disabled}
inputAdornment={{ inputAdornment={{

View File

@ -0,0 +1,27 @@
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import { useFormState } from 'react-final-form'
import Row from 'src/components/layout/Row'
import Paragraph from 'src/components/layout/Paragraph'
import { styles } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style'
const useStyles = makeStyles(styles)
const FormErrorMessage = () => {
const classes = useStyles()
const { modifiedSinceLastSubmit, submitError } = useFormState({
subscription: { modifiedSinceLastSubmit: true, submitError: true },
})
const hasNewSubmitError = !!submitError && !modifiedSinceLastSubmit
return hasNewSubmitError ? (
<Row align="center" className={classes.fullWidth} margin="xs">
<Paragraph color="error" noMargin size="md" style={{ letterSpacing: '-0.5px', overflowWrap: 'anywhere' }}>
{submitError}
</Paragraph>
</Row>
) : null
}
export default FormErrorMessage

View File

@ -7,14 +7,20 @@ import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { styles } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style' import { styles } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style'
const useStyles = makeStyles(styles as any) const useStyles = makeStyles(styles)
const Header = ({ onClose, subTitle, title }) => { interface HeaderProps {
onClose: () => void
subTitle: string
title: string
}
const Header = ({ onClose, subTitle, title }: HeaderProps) => {
const classes = useStyles() const classes = useStyles()
return ( return (
<Row align="center" className={classes.heading} grow> <Row align="center" className={classes.heading} grow>
<Paragraph className={classes.manage} noMargin weight="bolder"> <Paragraph className={classes.headingText} noMargin weight="bolder">
{title} {title}
</Paragraph> </Paragraph>
<Paragraph className={classes.annotation}>{subTitle}</Paragraph> <Paragraph className={classes.annotation}>{subTitle}</Paragraph>

View File

@ -8,23 +8,28 @@ import SearchIcon from '@material-ui/icons/Search'
import classNames from 'classnames' import classNames from 'classnames'
import React from 'react' import React from 'react'
import { useField, useFormState } from 'react-final-form' import { useField, useFormState } from 'react-final-form'
import { AbiItem } from 'web3-utils'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import EtherscanService from 'src/logic/contractInteraction/sources/EtherscanService'
import { NO_CONTRACT } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils' import { NO_CONTRACT } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils'
import CheckIcon from 'src/routes/safe/components/CurrencyDropdown/img/check.svg' import CheckIcon from 'src/routes/safe/components/CurrencyDropdown/img/check.svg'
import { useDropdownStyles } from 'src/routes/safe/components/CurrencyDropdown/style' import { useDropdownStyles } from 'src/routes/safe/components/CurrencyDropdown/style'
import { DropdownListTheme } from 'src/theme/mui' import { DropdownListTheme } from 'src/theme/mui'
import { extractUsefulMethods } from 'src/logic/contractInteraction/sources/ABIService'
const MENU_WIDTH = '452px' const MENU_WIDTH = '452px'
const MethodsDropdown = ({ onChange }) => { interface MethodsDropdownProps {
onChange: (method: AbiItem) => void
}
const MethodsDropdown = ({ onChange }: MethodsDropdownProps) => {
const classes = useDropdownStyles({ buttonWidth: MENU_WIDTH }) const classes = useDropdownStyles({ buttonWidth: MENU_WIDTH })
const { const {
input: { value: abi }, input: { value: abi },
meta: { valid }, meta: { valid },
} = useField('abi', { value: true, valid: true } as any) } = useField('abi', { subscription: { value: true, valid: true } })
const { const {
initialValues: { selectedMethod: selectedMethodByDefault }, initialValues: { selectedMethod: selectedMethodByDefault },
} = useFormState({ subscription: { initialValues: true } }) } = useFormState({ subscription: { initialValues: true } })
@ -37,14 +42,14 @@ const MethodsDropdown = ({ onChange }) => {
React.useEffect(() => { React.useEffect(() => {
if (abi) { if (abi) {
try { try {
setMethodsList(EtherscanService.extractUsefulMethods(JSON.parse(abi))) setMethodsList(extractUsefulMethods(JSON.parse(abi)))
} catch (e) { } catch (e) {
setMethodsList([]) setMethodsList([])
} }
} }
}, [abi]) }, [abi])
React.useMemo(() => { React.useEffect(() => {
setMethodsListFiltered(methodsList.filter(({ name }) => name.toLowerCase().includes(searchParams.toLowerCase()))) setMethodsListFiltered(methodsList.filter(({ name }) => name.toLowerCase().includes(searchParams.toLowerCase())))
}, [methodsList, searchParams]) }, [methodsList, searchParams])
@ -56,7 +61,7 @@ const MethodsDropdown = ({ onChange }) => {
setAnchorEl(null) setAnchorEl(null)
} }
const onMethodSelectedChanged = (chosenMethod) => { const onMethodSelectedChanged = (chosenMethod: AbiItem) => {
setSelectedMethod(chosenMethod) setSelectedMethod(chosenMethod)
onChange(chosenMethod) onChange(chosenMethod)
handleClose() handleClose()

View File

@ -8,10 +8,10 @@ import InputComponent from './InputComponent'
const RenderInputParams = () => { const RenderInputParams = () => {
const { const {
meta: { valid: validABI }, meta: { valid: validABI },
} = useField('abi', { value: true }) } = useField('abi', { subscription: { valid: true, value: true } })
const { const {
input: { value: method }, input: { value: method },
}: any = useField('selectedMethod', { value: true }) }: any = useField('selectedMethod', { subscription: { value: true } })
const renderInputs = validABI && !!method && method.inputs.length const renderInputs = validABI && !!method && method.inputs.length
return !renderInputs return !renderInputs

View File

@ -3,19 +3,26 @@ import { useField } from 'react-final-form'
import TextField from 'src/components/forms/TextField' import TextField from 'src/components/forms/TextField'
import Col from 'src/components/layout/Col' import Col from 'src/components/layout/Col'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
const RenderOutputParams = () => { const RenderOutputParams = () => {
const { const {
input: { value: method }, input: { value: method },
}: any = useField('selectedMethod', { value: true }) }: any = useField('selectedMethod', { subscription: { value: true } })
const { const {
input: { value: results }, input: { value: results },
}: any = useField('callResults', { value: true }) }: any = useField('callResults', { subscription: { value: true } })
const multipleResults = !!method && method.outputs.length > 1 const multipleResults = !!method && method.outputs.length > 1
return results return results ? (
? method.outputs.map(({ name, type }, index) => { <>
<Row align="left" margin="xs">
<Paragraph color="primary" size="lg" style={{ letterSpacing: '-0.5px' }}>
Call result:
</Paragraph>
</Row>
{method.outputs.map(({ name, type }, index) => {
const placeholder = name ? `${name} (${type})` : type const placeholder = name ? `${name} (${type})` : type
const key = `methodCallResult-${method.name}_${index}_${type}` const key = `methodCallResult-${method.name}_${index}_${type}`
const value = multipleResults ? results[index] : results const value = multipleResults ? results[index] : results
@ -33,8 +40,9 @@ const RenderOutputParams = () => {
</Col> </Col>
</Row> </Row>
) )
}) })}
: null </>
) : null
} }
export default RenderOutputParams export default RenderOutputParams

View File

@ -3,8 +3,6 @@ import { useSnackbar } from 'notistack'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { styles } from './style'
import AddressInfo from 'src/components/AddressInfo' import AddressInfo from 'src/components/AddressInfo'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Button from 'src/components/layout/Button' import Button from 'src/components/layout/Button'
@ -13,11 +11,13 @@ import Hairline from 'src/components/layout/Hairline'
import Img from 'src/components/layout/Img' import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph' import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row' import Row from 'src/components/layout/Row'
import { AbiItemExtended } from 'src/logic/contractInteraction/sources/ABIService'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions' import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { estimateTxGasCosts } from 'src/logic/safe/transactions/gasNew' import { estimateTxGasCosts } from 'src/logic/safe/transactions/gasNew'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount' import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { getEthAsToken } from 'src/logic/tokens/utils/tokenHelpers' import { getEthAsToken } from 'src/logic/tokens/utils/tokenHelpers'
import { getWeb3 } from 'src/logic/wallets/getWeb3' import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { styles } from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style'
import Header from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/Header' import Header from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/Header'
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils' import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
import createTransaction from 'src/routes/safe/store/actions/createTransaction' import createTransaction from 'src/routes/safe/store/actions/createTransaction'
@ -31,14 +31,7 @@ export type TransactionReviewType = {
contractAddress?: string contractAddress?: string
data?: string data?: string
value?: string value?: string
selectedMethod?: { selectedMethod?: AbiItemExtended
action: string
signature: string
signatureHash: string
constant: boolean
inputs: []
name: string
}
} }
type Props = { type Props = {
@ -102,7 +95,7 @@ const ContractInteractionReview = ({ onClose, onPrev, tx }: Props) => {
<> <>
<Header onClose={onClose} subTitle="2 of 2" title="Contract Interaction" /> <Header onClose={onClose} subTitle="2 of 2" title="Contract Interaction" />
<Hairline /> <Hairline />
<Block className={classes.container}> <Block className={classes.formContainer}>
<Row margin="xs"> <Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}> <Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Contract Address Contract Address

View File

@ -1,58 +0,0 @@
import { border, lg, md, secondaryText, sm } from 'src/theme/variables'
import { createStyles } from '@material-ui/core'
export const styles = createStyles({
heading: {
padding: `${md} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: secondaryText,
marginRight: 'auto',
marginLeft: '20px',
},
headingText: {
fontSize: lg,
},
closeIcon: {
height: '35px',
width: '35px',
},
container: {
padding: `${md} ${lg}`,
},
value: {
marginLeft: sm,
},
outerData: {
borderRadius: '5px',
border: `1px solid ${border}`,
padding: '11px',
minHeight: '21px',
},
data: {
wordBreak: 'break-all',
overflow: 'auto',
fontSize: '14px',
fontFamily: 'Averta',
maxHeight: '100px',
letterSpacing: 'normal',
fontStretch: 'normal',
lineHeight: '1.43',
},
buttonRow: {
height: '84px',
justifyContent: 'center',
'& > button': {
fontFamily: 'Averta',
fontSize: md,
},
},
submitButton: {
boxShadow: '1px 2px 10px 0 rgba(212, 212, 211, 0.59)',
marginLeft: '15px',
},
})

View File

@ -1,31 +1,45 @@
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import React from 'react' import React from 'react'
import { useSelector } from 'react-redux'
import { styles } from './style' import { styles } from './style'
import GnoForm from 'src/components/forms/GnoForm' import GnoForm from 'src/components/forms/GnoForm'
import Block from 'src/components/layout/Block' import Block from 'src/components/layout/Block'
import Hairline from 'src/components/layout/Hairline' import Hairline from 'src/components/layout/Hairline'
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo' import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
import Buttons from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/Buttons' import { safeSelector } from 'src/routes/safe/store/selectors'
import ContractABI from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/ContractABI' import Buttons from './Buttons'
import EthAddressInput from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/EthAddressInput' import ContractABI from './ContractABI'
import EthValue from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/EthValue' import EthAddressInput from './EthAddressInput'
import FormDivisor from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/FormDivisor' import EthValue from './EthValue'
import Header from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/Header' import FormDivisor from './FormDivisor'
import MethodsDropdown from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/MethodsDropdown' import FormErrorMessage from './FormErrorMessage'
import RenderInputParams from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderInputParams' import Header from './Header'
import RenderOutputParams from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderOutputParams' import MethodsDropdown from './MethodsDropdown'
import { import RenderInputParams from './RenderInputParams'
abiExtractor, import RenderOutputParams from './RenderOutputParams'
createTxObject, import { abiExtractor, createTxObject, formMutators, handleSubmitError, isReadMethod } from './utils'
formMutators,
} from 'src/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils'
const useStyles = makeStyles(styles as any) const useStyles = makeStyles(styles)
const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }) => { export interface CreatedTx {
contractAddress: string
data: string
selectedMethod: {}
value: string | number
}
export interface ContractInteractionProps {
contractAddress: string
initialValues: { contractAddress?: string }
onClose: () => void
onNext: (tx: CreatedTx) => void
}
const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }: ContractInteractionProps) => {
const classes = useStyles() const classes = useStyles()
const { address: safeAddress = '' } = useSelector(safeSelector)
let setCallResults
React.useMemo(() => { React.useMemo(() => {
if (contractAddress) { if (contractAddress) {
@ -35,8 +49,22 @@ const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }
const handleSubmit = async ({ contractAddress, selectedMethod, value, ...values }) => { const handleSubmit = async ({ contractAddress, selectedMethod, value, ...values }) => {
if (value || (contractAddress && selectedMethod)) { if (value || (contractAddress && selectedMethod)) {
const data = await createTxObject(selectedMethod, contractAddress, values).encodeABI() try {
onNext({ ...values, contractAddress, data, selectedMethod, value }) const txObject = createTxObject(selectedMethod, contractAddress, values)
const data = txObject.encodeABI()
if (isReadMethod(selectedMethod)) {
const result = await txObject.call({ from: safeAddress })
setCallResults(result)
// this was a read method, so we won't go to the 'review' screen
return
}
onNext({ ...values, contractAddress, data, selectedMethod, value })
} catch (error) {
return handleSubmitError(error, values)
}
} }
} }
@ -52,6 +80,8 @@ const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }
subscription={{ submitting: true, pristine: true }} subscription={{ submitting: true, pristine: true }}
> >
{(submitting, validating, rest, mutators) => { {(submitting, validating, rest, mutators) => {
setCallResults = mutators.setCallResults
return ( return (
<> <>
<Block className={classes.formContainer}> <Block className={classes.formContainer}>
@ -62,14 +92,15 @@ const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }
onScannedValue={mutators.setContractAddress} onScannedValue={mutators.setContractAddress}
text="Contract Address*" text="Contract Address*"
/> />
<EthValue onSetMax={mutators.setMax} />
<ContractABI /> <ContractABI />
<MethodsDropdown onChange={mutators.setSelectedMethod} /> <MethodsDropdown onChange={mutators.setSelectedMethod} />
<EthValue onSetMax={mutators.setMax} />
<RenderInputParams /> <RenderInputParams />
<RenderOutputParams /> <RenderOutputParams />
<FormErrorMessage />
</Block> </Block>
<Hairline /> <Hairline />
<Buttons onCallSubmit={mutators.setCallResults} onClose={onClose} /> <Buttons onClose={onClose} />
</> </>
) )
}} }}

View File

@ -1,6 +1,7 @@
import { lg, md } from 'src/theme/variables' import { lg, md, secondaryText, sm, border } from 'src/theme/variables'
import { createStyles } from '@material-ui/core'
export const styles = () => ({ export const styles = createStyles({
heading: { heading: {
padding: `${md} ${lg}`, padding: `${md} ${lg}`,
justifyContent: 'flex-start', justifyContent: 'flex-start',
@ -9,11 +10,11 @@ export const styles = () => ({
}, },
annotation: { annotation: {
letterSpacing: '-1px', letterSpacing: '-1px',
color: '#a2a8ba', color: secondaryText,
marginRight: 'auto', marginRight: 'auto',
marginLeft: '20px', marginLeft: '20px',
}, },
manage: { headingText: {
fontSize: lg, fontSize: lg,
}, },
closeIcon: { closeIcon: {
@ -26,6 +27,25 @@ export const styles = () => ({
formContainer: { formContainer: {
padding: `${md} ${lg}`, padding: `${md} ${lg}`,
}, },
value: {
marginLeft: sm,
},
outerData: {
borderRadius: '5px',
border: `1px solid ${border}`,
padding: '11px',
minHeight: '21px',
},
data: {
wordBreak: 'break-all',
overflow: 'auto',
fontSize: '14px',
fontFamily: 'Averta',
maxHeight: '100px',
letterSpacing: 'normal',
fontStretch: 'normal',
lineHeight: '1.43',
},
buttonRow: { buttonRow: {
height: '84px', height: '84px',
justifyContent: 'center', justifyContent: 'center',

View File

@ -1,8 +1,11 @@
import { FORM_ERROR } from 'final-form'
import createDecorator from 'final-form-calculate' import createDecorator from 'final-form-calculate'
import { AbiItem } from 'web3-utils'
import { mustBeEthereumAddress, mustBeEthereumContractAddress } from 'src/components/forms/validator' import { mustBeEthereumAddress, mustBeEthereumContractAddress } from 'src/components/forms/validator'
import { getNetwork } from 'src/config' import { getNetwork } from 'src/config'
import { getConfiguredSource } from 'src/logic/contractInteraction/sources' import { getConfiguredSource } from 'src/logic/contractInteraction/sources'
import { AbiItemExtended } from 'src/logic/contractInteraction/sources/ABIService'
import { getWeb3 } from 'src/logic/wallets/getWeb3' import { getWeb3 } from 'src/logic/wallets/getWeb3'
import { TransactionReviewType } from '../Review' import { TransactionReviewType } from '../Review'
@ -49,7 +52,18 @@ export const formMutators = {
}, },
} }
export const createTxObject = (method, contractAddress, values) => { export const handleSubmitError = (error, values) => {
for (const key in values) {
if (values.hasOwnProperty(key) && values[key] === error.value) {
return { [key]: error.reason }
}
}
// .call() failed and we're logging a generic error
return { [FORM_ERROR]: error.message }
}
export const createTxObject = (method: AbiItem, contractAddress: string, values) => {
const web3 = getWeb3() const web3 = getWeb3()
const contract: any = new web3.eth.Contract([method], contractAddress) const contract: any = new web3.eth.Contract([method], contractAddress)
const { inputs, name } = method const { inputs, name } = method
@ -58,6 +72,8 @@ export const createTxObject = (method, contractAddress, values) => {
return contract.methods[name](...args) return contract.methods[name](...args)
} }
export const isReadMethod = (method: AbiItemExtended): boolean => method && method.action === 'read'
export const getValueFromTxInputs = (key: string, type: string, tx: TransactionReviewType): string => { export const getValueFromTxInputs = (key: string, type: string, tx: TransactionReviewType): string => {
let value = tx[key] let value = tx[key]
if (type === 'bool') { if (type === 'bool') {

906
yarn.lock

File diff suppressed because it is too large Load Diff