Merge pull request #1038 from gnosis/feature/supportCustomTX

[Contract Interaction] Support Hex Encoded Payload
This commit is contained in:
Mati Dastugue 2020-06-22 12:34:26 -03:00 committed by GitHub
commit 6240afc07a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 1172 additions and 439 deletions

View File

@ -18,7 +18,6 @@ module.exports = {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react/prop-types': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/camelcase': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-empty-function': 'off',

View File

@ -19,6 +19,10 @@ const ContractInteraction = React.lazy(() => import('./screens/ContractInteracti
const ContractInteractionReview: any = React.lazy(() => import('./screens/ContractInteraction/Review'))
const SendCustomTx = React.lazy(() => import('./screens/ContractInteraction/SendCustomTx'))
const ReviewCustomTx = React.lazy(() => import('./screens/ContractInteraction/ReviewCustomTx'))
const useStyles = makeStyles({
scalableModalWindow: {
height: 'auto',
@ -40,9 +44,11 @@ const SendModal = ({ activeScreenType, isOpen, onClose, recipientAddress, select
const classes = useStyles()
const [activeScreen, setActiveScreen] = useState(activeScreenType || 'chooseTxType')
const [tx, setTx] = useState({})
const [isABI, setIsABI] = useState(true)
useEffect(() => {
setActiveScreen(activeScreenType || 'chooseTxType')
setIsABI(true)
setTx({})
}, [activeScreenType, isOpen])
@ -53,9 +59,14 @@ const SendModal = ({ activeScreenType, isOpen, onClose, recipientAddress, select
setTx(txInfo)
}
const handleContractInteractionCreation = (contractInteractionInfo) => {
const handleContractInteractionCreation = (contractInteractionInfo: any, submit: boolean): void => {
setTx(contractInteractionInfo)
setActiveScreen('contractInteractionReview')
if (submit) setActiveScreen('contractInteractionReview')
}
const handleCustomTxCreation = (customTxInfo: any, submit: boolean): void => {
setTx(customTxInfo)
if (submit) setActiveScreen('reviewCustomTx')
}
const handleSendCollectible = (txInfo) => {
@ -63,6 +74,10 @@ const SendModal = ({ activeScreenType, isOpen, onClose, recipientAddress, select
setTx(txInfo)
}
const handleSwitchMethod = (): void => {
setIsABI(!isABI)
}
return (
<Modal
description="Send Tokens Form"
@ -93,17 +108,32 @@ const SendModal = ({ activeScreenType, isOpen, onClose, recipientAddress, select
{activeScreen === 'reviewTx' && (
<ReviewTx onClose={onClose} onPrev={() => setActiveScreen('sendFunds')} tx={tx} />
)}
{activeScreen === 'contractInteraction' && (
{activeScreen === 'contractInteraction' && isABI && (
<ContractInteraction
isABI={isABI}
switchMethod={handleSwitchMethod}
contractAddress={recipientAddress}
initialValues={tx}
onClose={onClose}
onNext={handleContractInteractionCreation}
/>
)}
{activeScreen === 'contractInteractionReview' && tx && (
{activeScreen === 'contractInteractionReview' && isABI && tx && (
<ContractInteractionReview onClose={onClose} onPrev={() => setActiveScreen('contractInteraction')} tx={tx} />
)}
{activeScreen === 'contractInteraction' && !isABI && (
<SendCustomTx
initialValues={tx}
isABI={isABI}
switchMethod={handleSwitchMethod}
onClose={onClose}
onNext={handleCustomTxCreation}
contractAddress={recipientAddress}
/>
)}
{activeScreen === 'reviewCustomTx' && (
<ReviewCustomTx onClose={onClose} onPrev={() => setActiveScreen('contractInteraction')} tx={tx} />
)}
{activeScreen === 'sendCollectible' && (
<SendCollectible
initialValues={tx}

View File

@ -50,7 +50,7 @@ const ContractInteractionReview = ({ onClose, onPrev, tx }: Props) => {
useEffect(() => {
let isCurrent = true
const estimateGas = async () => {
const estimateGas = async (): Promise<void> => {
const { fromWei, toBN } = getWeb3().utils
const txData = tx.data ? tx.data.trim() : ''

View File

@ -0,0 +1,184 @@
import IconButton from '@material-ui/core/IconButton'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import { useSnackbar } from 'notistack'
import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import ArrowDown from '../../assets/arrow-down.svg'
import { styles } from './style'
import CopyBtn from 'src/components/CopyBtn'
import EtherscanBtn from 'src/components/EtherscanBtn'
import Identicon from 'src/components/Identicon'
import Block from 'src/components/layout/Block'
import Button from 'src/components/layout/Button'
import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import { TX_NOTIFICATION_TYPES } from 'src/logic/safe/transactions'
import { estimateTxGasCosts } from 'src/logic/safe/transactions/gasNew'
import { formatAmount } from 'src/logic/tokens/utils/formatAmount'
import { getEthAsToken } from 'src/logic/tokens/utils/tokenHelpers'
import { getWeb3 } from 'src/logic/wallets/getWeb3'
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
import { setImageToPlaceholder } from 'src/routes/safe/components/Balances/utils'
import createTransaction from 'src/routes/safe/store/actions/createTransaction'
import { safeSelector } from 'src/routes/safe/store/selectors'
import { sm } from 'src/theme/variables'
type Props = {
onClose: () => void
onPrev: () => void
tx: { contractAddress?: string; data?: string; value?: string }
}
const useStyles = makeStyles(styles)
const ReviewCustomTx = ({ onClose, onPrev, tx }: Props) => {
const { enqueueSnackbar, closeSnackbar } = useSnackbar()
const classes = useStyles()
const dispatch = useDispatch()
const { address: safeAddress } = useSelector(safeSelector)
const [gasCosts, setGasCosts] = useState<string>('< 0.001')
useEffect(() => {
let isCurrent = true
const estimateGas = async () => {
const { fromWei, toBN } = getWeb3().utils
const txData = tx.data ? tx.data.trim() : ''
const estimatedGasCosts = await estimateTxGasCosts(safeAddress, tx.contractAddress, txData)
const gasCostsAsEth = fromWei(toBN(estimatedGasCosts), 'ether')
const formattedGasCosts = formatAmount(gasCostsAsEth)
if (isCurrent) {
setGasCosts(formattedGasCosts)
}
}
estimateGas()
return () => {
isCurrent = false
}
}, [safeAddress, tx.data, tx.contractAddress])
const submitTx = async (): Promise<void> => {
const web3 = getWeb3()
const txRecipient = tx.contractAddress
const txData = tx.data ? tx.data.trim() : ''
const txValue = tx.value ? web3.utils.toWei(tx.value, 'ether') : '0'
dispatch(
createTransaction({
safeAddress,
to: txRecipient,
valueInWei: txValue,
txData,
notifiedTransaction: TX_NOTIFICATION_TYPES.STANDARD_TX,
enqueueSnackbar,
closeSnackbar,
} as any),
)
onClose()
}
return (
<>
<Row align="center" className={classes.heading} grow>
<Paragraph className={classes.headingText} noMargin weight="bolder">
Send Custom Tx
</Paragraph>
<Paragraph className={classes.annotation}>2 of 2</Paragraph>
<IconButton disableRipple onClick={onClose}>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<Block className={classes.container}>
<SafeInfo />
<Row margin="md">
<Col xs={1}>
<img alt="Arrow Down" src={ArrowDown} style={{ marginLeft: sm }} />
</Col>
<Col center="xs" layout="column" xs={11}>
<Hairline />
</Col>
</Row>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Recipient
</Paragraph>
</Row>
<Row align="center" margin="md">
<Col xs={1}>
<Identicon address={tx.contractAddress} diameter={32} />
</Col>
<Col layout="column" xs={11}>
<Block justify="left">
<Paragraph noMargin weight="bolder">
{tx.contractAddress}
</Paragraph>
<CopyBtn content={tx.contractAddress} />
<EtherscanBtn type="address" value={tx.contractAddress} />
</Block>
</Col>
</Row>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Value
</Paragraph>
</Row>
<Row align="center" margin="md">
<Img alt="Ether" height={28} onError={setImageToPlaceholder} src={getEthAsToken('0').logoUri} />
<Paragraph className={classes.value} noMargin size="md">
{tx.value || 0}
{' ETH'}
</Paragraph>
</Row>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Data (hex encoded)
</Paragraph>
</Row>
<Row align="center" margin="md">
<Col className={classes.outerData}>
<Row className={classes.data} size="md">
{tx.data}
</Row>
</Col>
</Row>
<Row>
<Paragraph>
{`You're about to create a transaction and will have to confirm it with your currently connected wallet. Make sure you have ${gasCosts} (fee price) ETH in this wallet to fund this confirmation.`}
</Paragraph>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button minWidth={140} onClick={onPrev}>
Back
</Button>
<Button
className={classes.submitButton}
color="primary"
data-testid="submit-tx-btn"
minWidth={140}
onClick={submitTx}
type="submit"
variant="contained"
>
Submit
</Button>
</Row>
</>
)
}
export default ReviewCustomTx

View File

@ -0,0 +1,58 @@
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

@ -0,0 +1,278 @@
import IconButton from '@material-ui/core/IconButton'
import InputAdornment from '@material-ui/core/InputAdornment'
import Switch from '@material-ui/core/Switch'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import React, { useState } from 'react'
import { useSelector } from 'react-redux'
import ArrowDown from '../../assets/arrow-down.svg'
import { styles } from './style'
import QRIcon from 'src/assets/icons/qrcode.svg'
import CopyBtn from 'src/components/CopyBtn'
import EtherscanBtn from 'src/components/EtherscanBtn'
import Identicon from 'src/components/Identicon'
import ScanQRModal from 'src/components/ScanQRModal'
import Field from 'src/components/forms/Field'
import GnoForm from 'src/components/forms/GnoForm'
import TextField from 'src/components/forms/TextField'
import TextareaField from 'src/components/forms/TextareaField'
import { composeValidators, maxValue, mustBeFloat, greaterThan } from 'src/components/forms/validator'
import Block from 'src/components/layout/Block'
import Button from 'src/components/layout/Button'
import ButtonLink from 'src/components/layout/ButtonLink'
import Col from 'src/components/layout/Col'
import Hairline from 'src/components/layout/Hairline'
import Img from 'src/components/layout/Img'
import Paragraph from 'src/components/layout/Paragraph'
import Row from 'src/components/layout/Row'
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
import AddressBookInput from 'src/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
import { safeSelector } from 'src/routes/safe/store/selectors'
import { sm } from 'src/theme/variables'
export interface CreatedTx {
contractAddress: string
data: string
value: string | number
}
type Props = {
initialValues: { contractAddress?: string }
onClose: () => void
onNext: (tx: CreatedTx, submit: boolean) => void
isABI: boolean
switchMethod: () => void
contractAddress: string
}
const useStyles = makeStyles(styles)
const SendCustomTx: React.FC<Props> = ({ initialValues, onClose, onNext, contractAddress, switchMethod, isABI }) => {
const classes = useStyles()
const { ethBalance } = useSelector(safeSelector)
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false)
const [selectedEntry, setSelectedEntry] = useState<{ address?: string; name?: string } | null>({
address: contractAddress || initialValues.contractAddress,
name: '',
})
const [isValidAddress, setIsValidAddress] = useState<boolean>(true)
const saveForm = async (values) => {
await handleSubmit(values, false)
switchMethod()
}
const handleSubmit = (values: any, submit = true) => {
if (values.data || values.value) {
onNext(values, submit)
}
}
const openQrModal = () => {
setQrModalOpen(true)
}
const closeQrModal = () => {
setQrModalOpen(false)
}
const formMutators = {
setMax: (args, state, utils) => {
utils.changeValue(state, 'value', () => ethBalance)
},
setRecipient: (args, state, utils) => {
utils.changeValue(state, 'contractAddress', () => args[0])
},
}
return (
<>
<Row align="center" className={classes.heading} grow>
<Paragraph className={classes.manage} noMargin weight="bolder">
Send custom transactions
</Paragraph>
<Paragraph className={classes.annotation}>1 of 2</Paragraph>
<IconButton disableRipple onClick={onClose}>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<GnoForm
formMutators={formMutators}
initialValues={initialValues}
subscription={{ submitting: true, pristine: true, values: true }}
onSubmit={handleSubmit}
>
{(...args) => {
const mutators = args[3]
const pristine = args[2].pristine
let shouldDisableSubmitButton = !isValidAddress
if (selectedEntry) {
shouldDisableSubmitButton = !selectedEntry.address
}
const handleScan = (value) => {
let scannedAddress = value
if (scannedAddress.startsWith('ethereum:')) {
scannedAddress = scannedAddress.replace('ethereum:', '')
}
mutators.setRecipient(scannedAddress)
closeQrModal()
}
return (
<>
<Block className={classes.formContainer}>
<SafeInfo />
<Row margin="md">
<Col xs={1}>
<img alt="Arrow Down" src={ArrowDown} style={{ marginLeft: sm }} />
</Col>
<Col center="xs" layout="column" xs={11}>
<Hairline />
</Col>
</Row>
{selectedEntry && selectedEntry.address ? (
<div
onKeyDown={(e) => {
if (e.keyCode !== 9) {
setSelectedEntry(null)
}
}}
role="listbox"
tabIndex={0}
>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Recipient
</Paragraph>
</Row>
<Row align="center" margin="md">
<Col xs={1}>
<Identicon address={selectedEntry.address} diameter={32} />
</Col>
<Col layout="column" xs={11}>
<Block justify="left">
<Block>
<Paragraph
className={classes.selectAddress}
noMargin
onClick={() => setSelectedEntry(null)}
weight="bolder"
>
{selectedEntry.name}
</Paragraph>
<Paragraph
className={classes.selectAddress}
noMargin
onClick={() => setSelectedEntry(null)}
weight="bolder"
>
{selectedEntry.address}
</Paragraph>
</Block>
<CopyBtn content={selectedEntry.address} />
<EtherscanBtn type="address" value={selectedEntry.address} />
</Block>
</Col>
</Row>
</div>
) : (
<>
<Row margin="md">
<Col xs={11}>
<AddressBookInput
fieldMutator={mutators.setRecipient}
isCustomTx
pristine={pristine}
setIsValidAddress={setIsValidAddress}
setSelectedEntry={setSelectedEntry}
/>
</Col>
<Col center="xs" className={classes} middle="xs" xs={1}>
<Img
alt="Scan QR"
className={classes.qrCodeBtn}
height={20}
onClick={() => {
openQrModal()
}}
role="button"
src={QRIcon}
/>
</Col>
</Row>
</>
)}
<Row margin="xs">
<Col between="lg">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Value
</Paragraph>
<ButtonLink onClick={mutators.setMax} weight="bold">
Send max
</ButtonLink>
</Col>
</Row>
<Row margin="md">
<Col>
<Field
component={TextField}
inputAdornment={{
endAdornment: <InputAdornment position="end">ETH</InputAdornment>,
}}
name="value"
placeholder="Value*"
text="Value*"
type="text"
validate={composeValidators(mustBeFloat, maxValue(ethBalance), greaterThan(0))}
/>
</Col>
</Row>
<Row margin="sm">
<Col>
<TextareaField
name="data"
placeholder="Data (hex encoded)*"
text="Data (hex encoded)*"
type="text"
/>
</Col>
</Row>
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Use custom data (hex encoded)
<Switch onChange={() => saveForm(args[2].values)} checked={!isABI} />
</Paragraph>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button minWidth={140} onClick={onClose}>
Cancel
</Button>
<Button
className={classes.submitButton}
color="primary"
data-testid="review-tx-btn"
disabled={shouldDisableSubmitButton}
minWidth={140}
type="submit"
variant="contained"
>
Review
</Button>
</Row>
{qrModalOpen && <ScanQRModal isOpen={qrModalOpen} onClose={closeQrModal} onScan={handleScan} />}
</>
)
}}
</GnoForm>
</>
)
}
export default SendCustomTx

View File

@ -0,0 +1,51 @@
import { lg, md } 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: '#a2a8ba',
marginRight: 'auto',
marginLeft: '20px',
},
manage: {
fontSize: lg,
},
closeIcon: {
height: '35px',
width: '35px',
},
qrCodeBtn: {
cursor: 'pointer',
},
formContainer: {
padding: `${md} ${lg}`,
},
buttonRow: {
height: '84px',
justifyContent: 'center',
'& > button': {
fontFamily: 'Averta',
fontSize: md,
},
},
submitButton: {
boxShadow: '1px 2px 10px 0 rgba(212, 212, 211, 0.59)',
marginLeft: '15px',
},
dataInput: {
'& TextField-root-294': {
lineHeight: 'auto',
border: 'green',
},
},
selectAddress: {
cursor: 'pointer',
},
})

View File

@ -1,13 +1,14 @@
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import { useSelector } from 'react-redux'
import Switch from '@material-ui/core/Switch'
import { styles } from './style'
import GnoForm from 'src/components/forms/GnoForm'
import Block from 'src/components/layout/Block'
import Hairline from 'src/components/layout/Hairline'
import SafeInfo from 'src/routes/safe/components/Balances/SendModal/SafeInfo'
import { safeSelector } from 'src/routes/safe/store/selectors'
import Paragraph from 'src/components/layout/Paragraph'
import Buttons from './Buttons'
import ContractABI from './ContractABI'
import EthAddressInput from './EthAddressInput'
@ -33,11 +34,20 @@ export interface CreatedTx {
export interface ContractInteractionProps {
contractAddress: string
initialValues: { contractAddress?: string }
isABI: boolean
onClose: () => void
onNext: (tx: CreatedTx) => void
switchMethod: () => void
onNext: (tx: CreatedTx, submit: boolean) => void
}
const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }: ContractInteractionProps) => {
const ContractInteraction: React.FC<ContractInteractionProps> = ({
contractAddress,
initialValues,
onClose,
onNext,
switchMethod,
isABI,
}) => {
const classes = useStyles()
const { address: safeAddress = '' } = useSelector(safeSelector)
let setCallResults
@ -48,13 +58,21 @@ const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }
}
}, [contractAddress, initialValues.contractAddress])
const handleSubmit = async ({ contractAddress, selectedMethod, value, ...values }) => {
const saveForm = async (values: CreatedTx): Promise<void> => {
await handleSubmit(values, false)
switchMethod()
}
const handleSubmit = async (
{ contractAddress, selectedMethod, value, ...values },
submit = true,
): Promise<void | any> => {
if (value || (contractAddress && selectedMethod)) {
try {
const txObject = createTxObject(selectedMethod, contractAddress, values)
const data = txObject.encodeABI()
if (isReadMethod(selectedMethod)) {
if (isReadMethod(selectedMethod) && submit) {
const result = await txObject.call({ from: safeAddress })
setCallResults(result)
@ -62,7 +80,7 @@ const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }
return
}
onNext({ ...values, contractAddress, data, selectedMethod, value })
onNext({ ...values, contractAddress, data, selectedMethod, value }, submit)
} catch (error) {
return handleSubmitError(error, values)
}
@ -78,7 +96,7 @@ const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }
formMutators={formMutators}
initialValues={initialValues}
onSubmit={handleSubmit}
subscription={{ submitting: true, pristine: true }}
subscription={{ submitting: true, pristine: true, values: true }}
>
{(submitting, validating, rest, mutators) => {
setCallResults = mutators.setCallResults
@ -99,6 +117,10 @@ const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }
<RenderInputParams />
<RenderOutputParams />
<FormErrorMessage />
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Use custom data (hex encoded)
<Switch checked={!isABI} onChange={() => saveForm(rest.values)} />
</Paragraph>
</Block>
<Hairline />
<Buttons onClose={onClose} />

963
yarn.lock

File diff suppressed because it is too large Load Diff