(Feature) Easier Smart Contract Interaction (#741)
This commit is contained in:
parent
119cc51917
commit
c396b3eaf8
|
@ -18,15 +18,14 @@ REACT_APP_SQUARELINK_ID=
|
||||||
REACT_APP_FORTMATIC_KEY=
|
REACT_APP_FORTMATIC_KEY=
|
||||||
REACT_APP_OPENSEA_API_KEY=
|
REACT_APP_OPENSEA_API_KEY=
|
||||||
REACT_APP_COLLECTIBLES_SOURCE=
|
REACT_APP_COLLECTIBLES_SOURCE=
|
||||||
|
REACT_APP_ETHERSCAN_API_KEY=
|
||||||
|
|
||||||
# Versions
|
# Versions
|
||||||
REACT_APP_LATEST_SAFE_VERSION=
|
REACT_APP_LATEST_SAFE_VERSION=
|
||||||
|
|
||||||
# Leave it untouched, version will set using dotenv-expand
|
# Leave it untouched, version will set using dotenv-expand
|
||||||
REACT_APP_APP_VERSION=$npm_package_version
|
REACT_APP_APP_VERSION=$npm_package_version
|
||||||
|
|
||||||
# all environments
|
# For Apps
|
||||||
REACT_APP_INFURA_TOKEN=
|
|
||||||
|
|
||||||
# For Apps
|
|
||||||
REACT_APP_GNOSIS_APPS_URL=https://safe-apps.staging.gnosisdev.com
|
REACT_APP_GNOSIS_APPS_URL=https://safe-apps.staging.gnosisdev.com
|
||||||
REACT_APP_APPS_DISABLED=false
|
REACT_APP_APPS_DISABLED=false
|
||||||
|
|
|
@ -160,12 +160,14 @@
|
||||||
"ethereum-ens": "0.8.0",
|
"ethereum-ens": "0.8.0",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"final-form": "4.19.1",
|
"final-form": "4.19.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",
|
||||||
"immutable": "^4.0.0-rc.9",
|
"immutable": "^4.0.0-rc.9",
|
||||||
"install": "^0.13.0",
|
"install": "^0.13.0",
|
||||||
"js-cookie": "^2.2.1",
|
"js-cookie": "^2.2.1",
|
||||||
"lint-staged": "10.2.2",
|
"lint-staged": "10.2.2",
|
||||||
|
"lodash.memoize": "^4.1.2",
|
||||||
"material-ui-search-bar": "^1.0.0-beta.13",
|
"material-ui-search-bar": "^1.0.0-beta.13",
|
||||||
"notistack": "https://github.com/gnosis/notistack.git#v0.9.4",
|
"notistack": "https://github.com/gnosis/notistack.git#v0.9.4",
|
||||||
"npm": "6.14.5",
|
"npm": "6.14.5",
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { type FormApi } from 'final-form'
|
import { type Decorator, type FormApi } from 'final-form'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { Form } from 'react-final-form'
|
import { Form } from 'react-final-form'
|
||||||
|
|
||||||
|
@ -10,13 +10,15 @@ export type OnSubmit = (
|
||||||
) => ?Object | Promise<?Object> | void
|
) => ?Object | Promise<?Object> | void
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: OnSubmit,
|
|
||||||
children: Function,
|
children: Function,
|
||||||
padding?: number,
|
decorators?: Decorator<{ [string]: any }>[],
|
||||||
validation?: (values: Object) => Object | Promise<Object>,
|
|
||||||
initialValues?: Object,
|
|
||||||
formMutators?: Object,
|
formMutators?: Object,
|
||||||
|
initialValues?: Object,
|
||||||
|
onSubmit: OnSubmit,
|
||||||
|
subscription?: Object,
|
||||||
|
padding?: number,
|
||||||
testId?: string,
|
testId?: string,
|
||||||
|
validation?: (values: Object) => Object | Promise<Object>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const stylesBasedOn = (padding: number): $Shape<CSSStyleDeclaration> => ({
|
const stylesBasedOn = (padding: number): $Shape<CSSStyleDeclaration> => ({
|
||||||
|
@ -25,8 +27,19 @@ const stylesBasedOn = (padding: number): $Shape<CSSStyleDeclaration> => ({
|
||||||
flex: '1 0 auto',
|
flex: '1 0 auto',
|
||||||
})
|
})
|
||||||
|
|
||||||
const GnoForm = ({ children, formMutators, initialValues, onSubmit, padding = 0, testId = '', validation }: Props) => (
|
const GnoForm = ({
|
||||||
|
children,
|
||||||
|
decorators,
|
||||||
|
formMutators,
|
||||||
|
initialValues,
|
||||||
|
onSubmit,
|
||||||
|
padding = 0,
|
||||||
|
subscription,
|
||||||
|
testId = '',
|
||||||
|
validation,
|
||||||
|
}: Props) => (
|
||||||
<Form
|
<Form
|
||||||
|
decorators={decorators}
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
mutators={formMutators}
|
mutators={formMutators}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
@ -35,6 +48,7 @@ const GnoForm = ({ children, formMutators, initialValues, onSubmit, padding = 0,
|
||||||
{children(rest.submitting, rest.validating, rest, rest.form.mutators)}
|
{children(rest.submitting, rest.validating, rest, rest.form.mutators)}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
subscription={subscription}
|
||||||
validate={validation}
|
validate={validation}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
// @flow
|
||||||
|
import type Web3 from 'web3'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ABI,
|
||||||
|
ContractInterface,
|
||||||
|
ExtendedABI,
|
||||||
|
ExtendedContractInterface,
|
||||||
|
} from '~/logic/contractInteraction/sources/types'
|
||||||
|
import { getWeb3 } from '~/logic/wallets/getWeb3'
|
||||||
|
|
||||||
|
class ABIService {
|
||||||
|
static extractUsefulMethods(abi: ABI): ExtendedABI {
|
||||||
|
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: ContractInterface): string {
|
||||||
|
const signature = ABIService.getMethodSignature(method)
|
||||||
|
return ABIService.getSignatureHash(signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
static getMethodSignatureAndSignatureHash(
|
||||||
|
method: ContractInterface,
|
||||||
|
): {|
|
||||||
|
signature: string,
|
||||||
|
signatureHash: string,
|
||||||
|
|} {
|
||||||
|
const signature = ABIService.getMethodSignature(method)
|
||||||
|
const signatureHash = ABIService.getSignatureHash(signature)
|
||||||
|
return { signature, signatureHash }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getMethodSignature({ inputs, name }: ContractInterface): string {
|
||||||
|
const params = inputs.map((x) => x.type).join(',')
|
||||||
|
return `${name}(${params})`
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSignatureHash(signature: string): string {
|
||||||
|
const web3: Web3 = getWeb3()
|
||||||
|
return web3.utils.keccak256(signature).toString(2, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
static isPayable(method: ContractInterface | ExtendedContractInterface): boolean {
|
||||||
|
return method.payable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ABIService
|
|
@ -0,0 +1,63 @@
|
||||||
|
// @flow
|
||||||
|
import { RateLimit } from 'async-sema'
|
||||||
|
import memoize from 'lodash.memoize'
|
||||||
|
|
||||||
|
import ABIService from '~/logic/contractInteraction/sources/ABIService'
|
||||||
|
import { ETHEREUM_NETWORK } from '~/logic/wallets/getWeb3'
|
||||||
|
import { ETHERSCAN_API_KEY } from '~/utils/constants'
|
||||||
|
|
||||||
|
class EtherscanService extends ABIService {
|
||||||
|
_rateLimit = async () => {}
|
||||||
|
|
||||||
|
_endpointsUrls: { [key: string]: string } = {
|
||||||
|
[ETHEREUM_NETWORK.MAINNET]: 'https://api.etherscan.io/api',
|
||||||
|
[ETHEREUM_NETWORK.RINKEBY]: 'https://api-rinkeby.etherscan.io/api',
|
||||||
|
}
|
||||||
|
|
||||||
|
_fetch = memoize(
|
||||||
|
async (url: string, contractAddress: string) => {
|
||||||
|
let params = {
|
||||||
|
module: 'contract',
|
||||||
|
action: 'getAbi',
|
||||||
|
address: contractAddress,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ETHERSCAN_API_KEY) {
|
||||||
|
const apiKey = ETHERSCAN_API_KEY
|
||||||
|
params = { ...params, apiKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${url}?${new URLSearchParams(params)}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { status: 0, result: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
(url, contractAddress) => `${url}_${contractAddress}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(options: { rps: number }) {
|
||||||
|
super()
|
||||||
|
this._rateLimit = RateLimit(options.rps)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContractABI(contractAddress: string, network: string) {
|
||||||
|
const etherscanUrl = this._endpointsUrls[network]
|
||||||
|
try {
|
||||||
|
const { result, status } = await this._fetch(etherscanUrl, contractAddress)
|
||||||
|
|
||||||
|
if (status === '0') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to retrieve ABI', e)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EtherscanService
|
|
@ -0,0 +1,8 @@
|
||||||
|
// @flow
|
||||||
|
import EtherscanService from '~/logic/contractInteraction/sources/EtherscanService'
|
||||||
|
|
||||||
|
const sources = {
|
||||||
|
etherscan: new EtherscanService({ rps: 4 }),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getConfiguredSource = () => sources['etherscan']
|
|
@ -0,0 +1,27 @@
|
||||||
|
// @flow
|
||||||
|
export type InterfaceParams = {
|
||||||
|
internalType: string,
|
||||||
|
name: string,
|
||||||
|
type: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContractInterface = {|
|
||||||
|
constant: boolean,
|
||||||
|
inputs: InterfaceParams[],
|
||||||
|
name: string,
|
||||||
|
outputs: InterfaceParams[],
|
||||||
|
payable: boolean,
|
||||||
|
stateMutability: string,
|
||||||
|
type: string,
|
||||||
|
|}
|
||||||
|
|
||||||
|
export type ExtendedContractInterface = {|
|
||||||
|
...ContractInterface,
|
||||||
|
action: string,
|
||||||
|
signature: string,
|
||||||
|
signatureHash: string,
|
||||||
|
|}
|
||||||
|
|
||||||
|
export type ABI = ContractInterface[]
|
||||||
|
|
||||||
|
export type ExtendedABI = ExtendedContractInterface[]
|
|
@ -1,67 +1,13 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
|
||||||
import CopyBtn from '~/components/CopyBtn'
|
import { AddressInfo } from '~/components-v2'
|
||||||
import EtherscanBtn from '~/components/EtherscanBtn'
|
import { safeSelector } from '~/routes/safe/store/selectors'
|
||||||
import Identicon from '~/components/Identicon'
|
|
||||||
import Block from '~/components/layout/Block'
|
|
||||||
import Bold from '~/components/layout/Bold'
|
|
||||||
import Col from '~/components/layout/Col'
|
|
||||||
import Paragraph from '~/components/layout/Paragraph'
|
|
||||||
import Row from '~/components/layout/Row'
|
|
||||||
import { border, xs } from '~/theme/variables'
|
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const SafeInfo = () => {
|
||||||
balanceContainer: {
|
const { address: safeAddress = '', ethBalance, name: safeName } = useSelector(safeSelector)
|
||||||
fontSize: '12px',
|
return <AddressInfo ethBalance={ethBalance} safeAddress={safeAddress} safeName={safeName} />
|
||||||
lineHeight: 1.08,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
backgroundColor: border,
|
|
||||||
width: 'fit-content',
|
|
||||||
padding: '5px 10px',
|
|
||||||
marginTop: xs,
|
|
||||||
borderRadius: '3px',
|
|
||||||
},
|
|
||||||
address: {
|
|
||||||
marginRight: xs,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
safeAddress: string,
|
|
||||||
safeName: string,
|
|
||||||
ethBalance: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
const SafeInfo = (props: Props) => {
|
|
||||||
const { ethBalance, safeAddress, safeName } = props
|
|
||||||
const classes = useStyles()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Row margin="md">
|
|
||||||
<Col xs={1}>
|
|
||||||
<Identicon address={safeAddress} diameter={32} />
|
|
||||||
</Col>
|
|
||||||
<Col layout="column" xs={11}>
|
|
||||||
<Paragraph noMargin style={{ lineHeight: 1 }} weight="bolder">
|
|
||||||
{safeName}
|
|
||||||
</Paragraph>
|
|
||||||
<Block justify="left">
|
|
||||||
<Paragraph className={classes.address} noMargin weight="bolder">
|
|
||||||
{safeAddress}
|
|
||||||
</Paragraph>
|
|
||||||
<CopyBtn content={safeAddress} />
|
|
||||||
<EtherscanBtn type="address" value={safeAddress} />
|
|
||||||
</Block>
|
|
||||||
<Block className={classes.balanceContainer}>
|
|
||||||
<Paragraph noMargin>
|
|
||||||
Balance: <Bold>{`${ethBalance} ETH`}</Bold>
|
|
||||||
</Paragraph>
|
|
||||||
</Block>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SafeInfo
|
export default SafeInfo
|
||||||
|
|
|
@ -18,16 +18,16 @@ const ReviewCollectible = React.lazy(() => import('./screens/ReviewCollectible')
|
||||||
|
|
||||||
const ReviewTx = React.lazy(() => import('./screens/ReviewTx'))
|
const ReviewTx = React.lazy(() => import('./screens/ReviewTx'))
|
||||||
|
|
||||||
const SendCustomTx = React.lazy(() => import('./screens/SendCustomTx'))
|
const ContractInteraction = React.lazy(() => import('./screens/ContractInteraction'))
|
||||||
|
|
||||||
const ReviewCustomTx = React.lazy(() => import('./screens/ReviewCustomTx'))
|
const ContractInteractionReview = React.lazy(() => import('./screens/ContractInteraction/Review'))
|
||||||
|
|
||||||
type ActiveScreen =
|
type ActiveScreen =
|
||||||
| 'chooseTxType'
|
| 'chooseTxType'
|
||||||
| 'sendFunds'
|
| 'sendFunds'
|
||||||
| 'reviewTx'
|
| 'reviewTx'
|
||||||
| 'sendCustomTx'
|
| 'contractInteraction'
|
||||||
| 'reviewCustomTx'
|
| 'contractInteractionReview'
|
||||||
| 'sendCollectible'
|
| 'sendCollectible'
|
||||||
| 'reviewCollectible'
|
| 'reviewCollectible'
|
||||||
|
|
||||||
|
@ -82,9 +82,9 @@ const SendModal = ({ activeScreenType, isOpen, onClose, recipientAddress, select
|
||||||
setTx(txInfo)
|
setTx(txInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCustomTxCreation = (customTxInfo) => {
|
const handleContractInteractionCreation = (contractInteractionInfo) => {
|
||||||
setActiveScreen('reviewCustomTx')
|
setTx(contractInteractionInfo)
|
||||||
setTx(customTxInfo)
|
setActiveScreen('contractInteractionReview')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSendCollectible = (txInfo) => {
|
const handleSendCollectible = (txInfo) => {
|
||||||
|
@ -122,16 +122,16 @@ const SendModal = ({ activeScreenType, isOpen, onClose, recipientAddress, select
|
||||||
{activeScreen === 'reviewTx' && (
|
{activeScreen === 'reviewTx' && (
|
||||||
<ReviewTx onClose={onClose} onPrev={() => setActiveScreen('sendFunds')} tx={tx} />
|
<ReviewTx onClose={onClose} onPrev={() => setActiveScreen('sendFunds')} tx={tx} />
|
||||||
)}
|
)}
|
||||||
{activeScreen === 'sendCustomTx' && (
|
{activeScreen === 'contractInteraction' && (
|
||||||
<SendCustomTx
|
<ContractInteraction
|
||||||
|
contractAddress={recipientAddress}
|
||||||
initialValues={tx}
|
initialValues={tx}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onNext={handleCustomTxCreation}
|
onNext={handleContractInteractionCreation}
|
||||||
recipientAddress={recipientAddress}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeScreen === 'reviewCustomTx' && (
|
{activeScreen === 'contractInteractionReview' && tx && (
|
||||||
<ReviewCustomTx onClose={onClose} onPrev={() => setActiveScreen('sendCustomTx')} tx={tx} />
|
<ContractInteractionReview onClose={onClose} onPrev={() => setActiveScreen('contractInteraction')} tx={tx} />
|
||||||
)}
|
)}
|
||||||
{activeScreen === 'sendCollectible' && (
|
{activeScreen === 'sendCollectible' && (
|
||||||
<SendCollectible
|
<SendCollectible
|
||||||
|
|
|
@ -6,7 +6,6 @@ import classNames from 'classnames/bind'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
|
|
||||||
import Code from '../assets/code.svg'
|
|
||||||
import Collectible from '../assets/collectibles.svg'
|
import Collectible from '../assets/collectibles.svg'
|
||||||
import Token from '../assets/token.svg'
|
import Token from '../assets/token.svg'
|
||||||
|
|
||||||
|
@ -17,6 +16,7 @@ import Hairline from '~/components/layout/Hairline'
|
||||||
import Img from '~/components/layout/Img'
|
import Img from '~/components/layout/Img'
|
||||||
import Paragraph from '~/components/layout/Paragraph'
|
import Paragraph from '~/components/layout/Paragraph'
|
||||||
import Row from '~/components/layout/Row'
|
import Row from '~/components/layout/Row'
|
||||||
|
import ContractInteractionIcon from '~/routes/safe/components/Transactions/TxsTable/TxType/assets/custom.svg'
|
||||||
import { safeSelector } from '~/routes/safe/store/selectors'
|
import { safeSelector } from '~/routes/safe/store/selectors'
|
||||||
import { lg, md, sm } from '~/theme/variables'
|
import { lg, md, sm } from '~/theme/variables'
|
||||||
|
|
||||||
|
@ -71,13 +71,13 @@ const ChooseTxType = ({ onClose, recipientAddress, setActiveScreen }: Props) =>
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const { featuresEnabled } = useSelector(safeSelector)
|
const { featuresEnabled } = useSelector(safeSelector)
|
||||||
const erc721Enabled = featuresEnabled.includes('ERC721')
|
const erc721Enabled = featuresEnabled.includes('ERC721')
|
||||||
const [disableCustomTx, setDisableCustomTx] = React.useState(!!recipientAddress)
|
const [disableContractInteraction, setDisableContractInteraction] = React.useState(!!recipientAddress)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let isCurrent = true
|
let isCurrent = true
|
||||||
const isContract = async () => {
|
const isContract = async () => {
|
||||||
if (recipientAddress && isCurrent) {
|
if (recipientAddress && isCurrent) {
|
||||||
setDisableCustomTx(!!(await mustBeEthereumContractAddress(recipientAddress)))
|
setDisableContractInteraction(!!(await mustBeEthereumContractAddress(recipientAddress)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -140,14 +140,18 @@ const ChooseTxType = ({ onClose, recipientAddress, setActiveScreen }: Props) =>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
disabled={disableCustomTx}
|
disabled={disableContractInteraction}
|
||||||
minHeight={52}
|
minHeight={52}
|
||||||
minWidth={260}
|
minWidth={260}
|
||||||
onClick={() => setActiveScreen('sendCustomTx')}
|
onClick={() => setActiveScreen('contractInteraction')}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
>
|
>
|
||||||
<Img alt="Send custom transaction" className={classNames(classes.leftIcon, classes.iconSmall)} src={Code} />
|
<Img
|
||||||
Send custom transaction
|
alt="Contract Interaction"
|
||||||
|
className={classNames(classes.leftIcon, classes.iconSmall)}
|
||||||
|
src={ContractInteractionIcon}
|
||||||
|
/>
|
||||||
|
Contract Interaction
|
||||||
</Button>
|
</Button>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
// @flow
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import React from 'react'
|
||||||
|
import { useField, useFormState } from 'react-final-form'
|
||||||
|
|
||||||
|
import Button from '~/components/layout/Button'
|
||||||
|
import Row from '~/components/layout/Row'
|
||||||
|
import { styles } from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style'
|
||||||
|
import { createTxObject } from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const Buttons = ({ onCallSubmit, onClose }: { onCallSubmit: (string) => void, onClose: () => void }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const {
|
||||||
|
input: { value: method },
|
||||||
|
} = useField('selectedMethod', { value: true })
|
||||||
|
const {
|
||||||
|
input: { value: contractAddress },
|
||||||
|
} = useField('contractAddress', { valid: true })
|
||||||
|
const { submitting, valid, validating, values } = useFormState({
|
||||||
|
subscription: { submitting: true, valid: true, values: true, validating: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCallSubmit = async () => {
|
||||||
|
const results = await createTxObject(method, contractAddress, values).call()
|
||||||
|
onCallSubmit(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row align="center" className={classes.buttonRow}>
|
||||||
|
<Button minWidth={140} onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{method && method.action === 'read' ? (
|
||||||
|
<Button
|
||||||
|
className={classes.submitButton}
|
||||||
|
color="primary"
|
||||||
|
data-testid="review-tx-btn"
|
||||||
|
disabled={validating || !valid}
|
||||||
|
minWidth={140}
|
||||||
|
onClick={handleCallSubmit}
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
|
Call
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className={classes.submitButton}
|
||||||
|
color="primary"
|
||||||
|
data-testid="review-tx-btn"
|
||||||
|
disabled={submitting || validating || !valid || !method || method.action === 'read'}
|
||||||
|
minWidth={140}
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
|
Review
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Buttons
|
|
@ -0,0 +1,31 @@
|
||||||
|
// @flow
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import TextareaField from '~/components/forms/TextareaField'
|
||||||
|
import Col from '~/components/layout/Col'
|
||||||
|
import Row from '~/components/layout/Row'
|
||||||
|
import EtherscanService from '~/logic/contractInteraction/sources/EtherscanService'
|
||||||
|
|
||||||
|
export const NO_DATA = 'no data'
|
||||||
|
|
||||||
|
const mustBeValidABI = (abi: string) => {
|
||||||
|
try {
|
||||||
|
const parsedABI = EtherscanService.extractUsefulMethods(JSON.parse(abi))
|
||||||
|
|
||||||
|
if (parsedABI.length === 0) {
|
||||||
|
return NO_DATA
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContractABI = () => (
|
||||||
|
<Row margin="sm">
|
||||||
|
<Col>
|
||||||
|
<TextareaField name="abi" placeholder="ABI*" text="ABI*" type="text" validate={mustBeValidABI} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default ContractABI
|
|
@ -0,0 +1,66 @@
|
||||||
|
// @flow
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { ScanQRWrapper } from '~/components/ScanQRModal/ScanQRWrapper'
|
||||||
|
import Field from '~/components/forms/Field'
|
||||||
|
import TextField from '~/components/forms/TextField'
|
||||||
|
import {
|
||||||
|
composeValidators,
|
||||||
|
mustBeEthereumAddress,
|
||||||
|
mustBeEthereumContractAddress,
|
||||||
|
required,
|
||||||
|
} from '~/components/forms/validator'
|
||||||
|
import Col from '~/components/layout/Col'
|
||||||
|
import Row from '~/components/layout/Row'
|
||||||
|
import { styles } from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isContract?: boolean,
|
||||||
|
isRequired?: boolean,
|
||||||
|
name: string,
|
||||||
|
onScannedValue: (string) => void,
|
||||||
|
text: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const EthAddressInput = ({ isContract = true, isRequired = true, name, onScannedValue, text }: Props) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const validatorsList = [isRequired && required, mustBeEthereumAddress, isContract && mustBeEthereumContractAddress]
|
||||||
|
const validate = composeValidators(...validatorsList.filter((_) => _))
|
||||||
|
|
||||||
|
const handleScan = (value, closeQrModal) => {
|
||||||
|
let scannedAddress = value
|
||||||
|
|
||||||
|
if (scannedAddress.startsWith('ethereum:')) {
|
||||||
|
scannedAddress = scannedAddress.replace('ethereum:', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
onScannedValue(scannedAddress)
|
||||||
|
closeQrModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row margin="md">
|
||||||
|
<Col xs={11}>
|
||||||
|
<Field
|
||||||
|
component={TextField}
|
||||||
|
name={name}
|
||||||
|
placeholder={text}
|
||||||
|
testId={name}
|
||||||
|
text={text}
|
||||||
|
type="text"
|
||||||
|
validate={validate}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col center="xs" className={classes} middle="xs" xs={1}>
|
||||||
|
<ScanQRWrapper handleScan={handleScan} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EthAddressInput
|
|
@ -0,0 +1,65 @@
|
||||||
|
// @flow
|
||||||
|
import InputAdornment from '@material-ui/core/InputAdornment'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import React from 'react'
|
||||||
|
import { useField } from 'react-final-form'
|
||||||
|
import { useSelector } from 'react-redux'
|
||||||
|
|
||||||
|
import Field from '~/components/forms/Field'
|
||||||
|
import TextField from '~/components/forms/TextField'
|
||||||
|
import { composeValidators, maxValue, mustBeFloat } from '~/components/forms/validator'
|
||||||
|
import ButtonLink from '~/components/layout/ButtonLink'
|
||||||
|
import Col from '~/components/layout/Col'
|
||||||
|
import Paragraph from '~/components/layout/Paragraph'
|
||||||
|
import Row from '~/components/layout/Row'
|
||||||
|
import ABIService from '~/logic/contractInteraction/sources/ABIService'
|
||||||
|
import { styles } from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style'
|
||||||
|
import { safeSelector } from '~/routes/safe/store/selectors'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const EthValue = ({ onSetMax }: { onSetMax: (string) => void }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const { ethBalance } = useSelector(safeSelector)
|
||||||
|
const {
|
||||||
|
input: { value: method },
|
||||||
|
} = useField('selectedMethod', { value: true })
|
||||||
|
const disabled = !ABIService.isPayable(method)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row className={classes.fullWidth} margin="xs">
|
||||||
|
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
|
||||||
|
Value
|
||||||
|
</Paragraph>
|
||||||
|
<ButtonLink
|
||||||
|
color={disabled ? 'disabled' : 'secondary'}
|
||||||
|
onClick={() => !disabled && onSetMax(ethBalance)}
|
||||||
|
weight="bold"
|
||||||
|
>
|
||||||
|
Send max
|
||||||
|
</ButtonLink>
|
||||||
|
</Row>
|
||||||
|
<Row margin="md">
|
||||||
|
<Col>
|
||||||
|
<Field
|
||||||
|
className={classes.addressInput}
|
||||||
|
component={TextField}
|
||||||
|
disabled={disabled}
|
||||||
|
inputAdornment={{
|
||||||
|
endAdornment: <InputAdornment position="end">ETH</InputAdornment>,
|
||||||
|
disabled,
|
||||||
|
}}
|
||||||
|
name="value"
|
||||||
|
placeholder="Value"
|
||||||
|
text="Value"
|
||||||
|
type="text"
|
||||||
|
validate={!disabled && composeValidators(mustBeFloat, maxValue(ethBalance))}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EthValue
|
|
@ -0,0 +1,21 @@
|
||||||
|
// @flow
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import Col from '~/components/layout/Col'
|
||||||
|
import Hairline from '~/components/layout/Hairline'
|
||||||
|
import Row from '~/components/layout/Row'
|
||||||
|
import ArrowDown from '~/routes/safe/components/Balances/SendModal/screens/assets/arrow-down.svg'
|
||||||
|
import { sm } from '~/theme/variables'
|
||||||
|
|
||||||
|
const FormDivisor = () => (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default FormDivisor
|
|
@ -0,0 +1,29 @@
|
||||||
|
// @flow
|
||||||
|
import IconButton from '@material-ui/core/IconButton'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import Close from '@material-ui/icons/Close'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import Paragraph from '~/components/layout/Paragraph'
|
||||||
|
import Row from '~/components/layout/Row'
|
||||||
|
import { styles } from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/style'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const Header = ({ onClose, subTitle, title }: { onClose: () => void, title: string, subTitle: string }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row align="center" className={classes.heading} grow>
|
||||||
|
<Paragraph className={classes.manage} noMargin weight="bolder">
|
||||||
|
{title}
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph className={classes.annotation}>{subTitle}</Paragraph>
|
||||||
|
<IconButton disableRipple onClick={onClose}>
|
||||||
|
<Close className={classes.closeIcon} />
|
||||||
|
</IconButton>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header
|
|
@ -0,0 +1,146 @@
|
||||||
|
// @flow
|
||||||
|
import InputBase from '@material-ui/core/InputBase'
|
||||||
|
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||||
|
import ListItemText from '@material-ui/core/ListItemText'
|
||||||
|
import Menu from '@material-ui/core/Menu'
|
||||||
|
import MenuItem from '@material-ui/core/MenuItem'
|
||||||
|
import { MuiThemeProvider } from '@material-ui/core/styles'
|
||||||
|
import SearchIcon from '@material-ui/icons/Search'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import React from 'react'
|
||||||
|
import { useField, useFormState } from 'react-final-form'
|
||||||
|
|
||||||
|
import Col from '~/components/layout/Col'
|
||||||
|
import Row from '~/components/layout/Row'
|
||||||
|
import EtherscanService from '~/logic/contractInteraction/sources/EtherscanService'
|
||||||
|
import { NO_CONTRACT } from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils'
|
||||||
|
import CheckIcon from '~/routes/safe/components/DropdownCurrency/img/check.svg'
|
||||||
|
import { useDropdownStyles } from '~/routes/safe/components/DropdownCurrency/style'
|
||||||
|
import { DropdownListTheme } from '~/theme/mui'
|
||||||
|
|
||||||
|
const MENU_WIDTH = '452px'
|
||||||
|
|
||||||
|
const MethodsDropdown = ({ onChange }: { onChange: (any) => void }) => {
|
||||||
|
const classes = useDropdownStyles({ buttonWidth: MENU_WIDTH })
|
||||||
|
const {
|
||||||
|
input: { value: abi },
|
||||||
|
meta: { valid },
|
||||||
|
} = useField('abi', { value: true, valid: true })
|
||||||
|
const {
|
||||||
|
initialValues: { selectedMethod: selectedMethodByDefault },
|
||||||
|
} = useFormState({ subscription: { initialValues: true } })
|
||||||
|
const [selectedMethod, setSelectedMethod] = React.useState(selectedMethodByDefault ? selectedMethodByDefault : {})
|
||||||
|
const [methodsList, setMethodsList] = React.useState([])
|
||||||
|
const [methodsListFiltered, setMethodsListFiltered] = React.useState([])
|
||||||
|
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null)
|
||||||
|
const [searchParams, setSearchParams] = React.useState('')
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (abi) {
|
||||||
|
try {
|
||||||
|
setMethodsList(EtherscanService.extractUsefulMethods(JSON.parse(abi)))
|
||||||
|
} catch (e) {
|
||||||
|
setMethodsList([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [abi])
|
||||||
|
|
||||||
|
React.useMemo(() => {
|
||||||
|
setMethodsListFiltered(methodsList.filter(({ name }) => name.toLowerCase().includes(searchParams.toLowerCase())))
|
||||||
|
}, [methodsList, searchParams])
|
||||||
|
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMethodSelectedChanged = (chosenMethod) => {
|
||||||
|
setSelectedMethod(chosenMethod)
|
||||||
|
onChange(chosenMethod)
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return !valid || !abi || abi === NO_CONTRACT ? null : (
|
||||||
|
<Row margin="sm">
|
||||||
|
<Col>
|
||||||
|
<MuiThemeProvider theme={DropdownListTheme}>
|
||||||
|
<>
|
||||||
|
<button className={classes.button} onClick={handleClick} type="button">
|
||||||
|
<span className={classNames(classes.buttonInner, anchorEl && classes.openMenuButton)}>
|
||||||
|
{selectedMethod.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
anchorOrigin={{
|
||||||
|
horizontal: 'center',
|
||||||
|
vertical: 'bottom',
|
||||||
|
}}
|
||||||
|
elevation={0}
|
||||||
|
getContentAnchorEl={null}
|
||||||
|
id="customizedMenu"
|
||||||
|
keepMounted
|
||||||
|
onClose={handleClose}
|
||||||
|
open={!!anchorEl}
|
||||||
|
PaperProps={{ style: { width: MENU_WIDTH } }}
|
||||||
|
rounded={0}
|
||||||
|
transformOrigin={{
|
||||||
|
horizontal: 'center',
|
||||||
|
vertical: 'top',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem className={classes.listItemSearch} key="0">
|
||||||
|
<div className={classes.search}>
|
||||||
|
<div className={classes.searchIcon}>
|
||||||
|
<SearchIcon />
|
||||||
|
</div>
|
||||||
|
<InputBase
|
||||||
|
classes={{
|
||||||
|
root: classes.inputRoot,
|
||||||
|
input: classes.inputInput,
|
||||||
|
}}
|
||||||
|
inputProps={{ 'aria-label': 'search' }}
|
||||||
|
onChange={(event) => setSearchParams(event.target.value)}
|
||||||
|
placeholder="Search…"
|
||||||
|
value={searchParams}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MenuItem>
|
||||||
|
<div className={classes.dropdownItemsScrollWrapper}>
|
||||||
|
{methodsListFiltered.map((method) => {
|
||||||
|
const { action, name, signatureHash } = method
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
className={classes.listItem}
|
||||||
|
key={signatureHash}
|
||||||
|
onClick={() => onMethodSelectedChanged(method)}
|
||||||
|
value={signatureHash}
|
||||||
|
>
|
||||||
|
<ListItemText primary={name} />
|
||||||
|
<ListItemIcon className={classes.iconRight}>
|
||||||
|
{signatureHash === selectedMethod.signatureHash ? (
|
||||||
|
<img alt="checked" src={CheckIcon} />
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemIcon className={classes.iconRight}>
|
||||||
|
<div>{action}</div>
|
||||||
|
</ListItemIcon>
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
</MuiThemeProvider>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MethodsDropdown
|
|
@ -0,0 +1,45 @@
|
||||||
|
// @flow
|
||||||
|
import React from 'react'
|
||||||
|
import { useField } from 'react-final-form'
|
||||||
|
|
||||||
|
import Field from '~/components/forms/Field'
|
||||||
|
import TextField from '~/components/forms/TextField'
|
||||||
|
import { composeValidators, mustBeEthereumAddress, required } from '~/components/forms/validator'
|
||||||
|
import Col from '~/components/layout/Col'
|
||||||
|
import Row from '~/components/layout/Row'
|
||||||
|
|
||||||
|
const RenderInputParams = () => {
|
||||||
|
const {
|
||||||
|
meta: { valid: validABI },
|
||||||
|
} = useField('abi', { valid: true })
|
||||||
|
const {
|
||||||
|
input: { value: method },
|
||||||
|
} = useField('selectedMethod', { value: true })
|
||||||
|
const renderInputs = validABI && !!method && method.inputs.length
|
||||||
|
|
||||||
|
return !renderInputs
|
||||||
|
? null
|
||||||
|
: method.inputs.map(({ name, type }, index) => {
|
||||||
|
const placeholder = name ? `${name} (${type})` : type
|
||||||
|
const key = `methodInput-${method.name}_${index}_${type}`
|
||||||
|
const validate = type === 'address' ? composeValidators(required, mustBeEthereumAddress) : required
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row key={key} margin="sm">
|
||||||
|
<Col>
|
||||||
|
<Field
|
||||||
|
component={TextField}
|
||||||
|
name={key}
|
||||||
|
placeholder={placeholder}
|
||||||
|
testId={key}
|
||||||
|
text={placeholder}
|
||||||
|
type="text"
|
||||||
|
validate={validate}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RenderInputParams
|
|
@ -0,0 +1,41 @@
|
||||||
|
// @flow
|
||||||
|
import React from 'react'
|
||||||
|
import { useField } from 'react-final-form'
|
||||||
|
|
||||||
|
import TextField from '~/components/forms/TextField'
|
||||||
|
import Col from '~/components/layout/Col'
|
||||||
|
import Row from '~/components/layout/Row'
|
||||||
|
|
||||||
|
const RenderOutputParams = () => {
|
||||||
|
const {
|
||||||
|
input: { value: method },
|
||||||
|
} = useField('selectedMethod', { value: true })
|
||||||
|
const {
|
||||||
|
input: { value: results },
|
||||||
|
} = useField('callResults', { value: true })
|
||||||
|
const multipleResults = !!method && method.outputs.length > 1
|
||||||
|
|
||||||
|
return results
|
||||||
|
? method.outputs.map(({ name, type }, index) => {
|
||||||
|
const placeholder = name ? `${name} (${type})` : type
|
||||||
|
const key = `methodCallResult-${method.name}_${index}_${type}`
|
||||||
|
const value = multipleResults ? results[index] : results
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row key={key} margin="sm">
|
||||||
|
<Col>
|
||||||
|
<TextField
|
||||||
|
disabled
|
||||||
|
input={{ name: key, value, placeholder, type: 'text' }}
|
||||||
|
meta={{ valid: true }}
|
||||||
|
testId={key}
|
||||||
|
text={placeholder}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RenderOutputParams
|
|
@ -1,18 +1,12 @@
|
||||||
// @flow
|
// @flow
|
||||||
import IconButton from '@material-ui/core/IconButton'
|
|
||||||
import { makeStyles } from '@material-ui/core/styles'
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
import Close from '@material-ui/icons/Close'
|
|
||||||
import { withSnackbar } from 'notistack'
|
import { withSnackbar } 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 ArrowDown from '../assets/arrow-down.svg'
|
|
||||||
|
|
||||||
import { styles } from './style'
|
import { styles } from './style'
|
||||||
|
|
||||||
import CopyBtn from '~/components/CopyBtn'
|
import AddressInfo from '~/components-v2/safeUtils/AddressInfo'
|
||||||
import EtherscanBtn from '~/components/EtherscanBtn'
|
|
||||||
import Identicon from '~/components/Identicon'
|
|
||||||
import Block from '~/components/layout/Block'
|
import Block from '~/components/layout/Block'
|
||||||
import Button from '~/components/layout/Button'
|
import Button from '~/components/layout/Button'
|
||||||
import Col from '~/components/layout/Col'
|
import Col from '~/components/layout/Col'
|
||||||
|
@ -25,11 +19,10 @@ import { estimateTxGasCosts } from '~/logic/safe/transactions/gasNew'
|
||||||
import { formatAmount } from '~/logic/tokens/utils/formatAmount'
|
import { formatAmount } from '~/logic/tokens/utils/formatAmount'
|
||||||
import { getEthAsToken } from '~/logic/tokens/utils/tokenHelpers'
|
import { getEthAsToken } from '~/logic/tokens/utils/tokenHelpers'
|
||||||
import { getWeb3 } from '~/logic/wallets/getWeb3'
|
import { getWeb3 } from '~/logic/wallets/getWeb3'
|
||||||
import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo'
|
import Header from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/Header'
|
||||||
import { setImageToPlaceholder } from '~/routes/safe/components/Balances/utils'
|
import { setImageToPlaceholder } from '~/routes/safe/components/Balances/utils'
|
||||||
import createTransaction from '~/routes/safe/store/actions/createTransaction'
|
import createTransaction from '~/routes/safe/store/actions/createTransaction'
|
||||||
import { safeSelector } from '~/routes/safe/store/selectors'
|
import { safeSelector } from '~/routes/safe/store/selectors'
|
||||||
import { sm } from '~/theme/variables'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
closeSnackbar: () => void,
|
closeSnackbar: () => void,
|
||||||
|
@ -41,10 +34,10 @@ type Props = {
|
||||||
|
|
||||||
const useStyles = makeStyles(styles)
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
const ReviewCustomTx = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx }: Props) => {
|
const ContractInteractionReview = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx }: Props) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const { address: safeAddress, ethBalance, name: safeName } = useSelector(safeSelector)
|
const { address: safeAddress } = useSelector(safeSelector)
|
||||||
const [gasCosts, setGasCosts] = useState<string>('< 0.001')
|
const [gasCosts, setGasCosts] = useState<string>('< 0.001')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -54,7 +47,7 @@ const ReviewCustomTx = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx }:
|
||||||
const { fromWei, toBN } = getWeb3().utils
|
const { fromWei, toBN } = getWeb3().utils
|
||||||
const txData = tx.data ? tx.data.trim() : ''
|
const txData = tx.data ? tx.data.trim() : ''
|
||||||
|
|
||||||
const estimatedGasCosts = await estimateTxGasCosts(safeAddress, tx.recipientAddress, txData)
|
const estimatedGasCosts = await estimateTxGasCosts(safeAddress, tx.contractAddress, txData)
|
||||||
const gasCostsAsEth = fromWei(toBN(estimatedGasCosts), 'ether')
|
const gasCostsAsEth = fromWei(toBN(estimatedGasCosts), 'ether')
|
||||||
const formattedGasCosts = formatAmount(gasCostsAsEth)
|
const formattedGasCosts = formatAmount(gasCostsAsEth)
|
||||||
|
|
||||||
|
@ -72,7 +65,7 @@ const ReviewCustomTx = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx }:
|
||||||
|
|
||||||
const submitTx = async () => {
|
const submitTx = async () => {
|
||||||
const web3 = getWeb3()
|
const web3 = getWeb3()
|
||||||
const txRecipient = tx.recipientAddress
|
const txRecipient = tx.contractAddress
|
||||||
const txData = tx.data ? tx.data.trim() : ''
|
const txData = tx.data ? tx.data.trim() : ''
|
||||||
const txValue = tx.value ? web3.utils.toWei(tx.value, 'ether') : '0'
|
const txValue = tx.value ? web3.utils.toWei(tx.value, 'ether') : '0'
|
||||||
|
|
||||||
|
@ -93,44 +86,16 @@ const ReviewCustomTx = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx }:
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Row align="center" className={classes.heading} grow>
|
<Header onClose={onClose} subTitle="2 of 2" title="Contract Interaction" />
|
||||||
<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 />
|
<Hairline />
|
||||||
<Block className={classes.container}>
|
<Block className={classes.container}>
|
||||||
<SafeInfo ethBalance={ethBalance} safeAddress={safeAddress} safeName={safeName} />
|
|
||||||
<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">
|
<Row margin="xs">
|
||||||
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
|
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
|
||||||
Recipient
|
Contract Address
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</Row>
|
</Row>
|
||||||
<Row align="center" margin="md">
|
<Row align="center" margin="md">
|
||||||
<Col xs={1}>
|
<AddressInfo safeAddress={tx.contractAddress} />
|
||||||
<Identicon address={tx.recipientAddress} diameter={32} />
|
|
||||||
</Col>
|
|
||||||
<Col layout="column" xs={11}>
|
|
||||||
<Block justify="left">
|
|
||||||
<Paragraph className={classes.address} noMargin weight="bolder">
|
|
||||||
{tx.recipientAddress}
|
|
||||||
</Paragraph>
|
|
||||||
<CopyBtn content={tx.recipientAddress} />
|
|
||||||
<EtherscanBtn type="address" value={tx.recipientAddress} />
|
|
||||||
</Block>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
</Row>
|
||||||
<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' }}>
|
||||||
|
@ -138,12 +103,46 @@ const ReviewCustomTx = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx }:
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</Row>
|
</Row>
|
||||||
<Row align="center" margin="md">
|
<Row align="center" margin="md">
|
||||||
<Img alt="Ether" height={28} onError={setImageToPlaceholder} src={getEthAsToken('0').logoUri} />
|
<Col xs={1}>
|
||||||
<Paragraph className={classes.value} noMargin size="md">
|
<Img alt="Ether" height={28} onError={setImageToPlaceholder} src={getEthAsToken('0').logoUri} />
|
||||||
{tx.value || 0}
|
</Col>
|
||||||
{' ETH'}
|
<Col layout="column" xs={11}>
|
||||||
|
<Block justify="left">
|
||||||
|
<Paragraph className={classes.value} noMargin size="md" style={{ margin: 0 }}>
|
||||||
|
{tx.value || 0}
|
||||||
|
{' ETH'}
|
||||||
|
</Paragraph>
|
||||||
|
</Block>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row margin="xs">
|
||||||
|
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
|
||||||
|
Method
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</Row>
|
</Row>
|
||||||
|
<Row align="center" margin="md">
|
||||||
|
<Paragraph className={classes.value} size="md" style={{ margin: 0 }}>
|
||||||
|
{tx.selectedMethod.name}
|
||||||
|
</Paragraph>
|
||||||
|
</Row>
|
||||||
|
{tx.selectedMethod.inputs.map(({ name, type }, index) => {
|
||||||
|
const key = `methodInput-${tx.selectedMethod.name}_${index}_${type}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={key}>
|
||||||
|
<Row margin="xs">
|
||||||
|
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
|
||||||
|
{name} ({type})
|
||||||
|
</Paragraph>
|
||||||
|
</Row>
|
||||||
|
<Row align="center" margin="md">
|
||||||
|
<Paragraph className={classes.value} noMargin size="md" style={{ margin: 0 }}>
|
||||||
|
{tx[key]}
|
||||||
|
</Paragraph>
|
||||||
|
</Row>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<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' }}>
|
||||||
Data (hex encoded)
|
Data (hex encoded)
|
||||||
|
@ -183,4 +182,4 @@ const ReviewCustomTx = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx }:
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withSnackbar(ReviewCustomTx)
|
export default withSnackbar(ContractInteractionReview)
|
|
@ -0,0 +1,89 @@
|
||||||
|
// @flow
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { styles } from './style'
|
||||||
|
|
||||||
|
import GnoForm from '~/components/forms/GnoForm'
|
||||||
|
import Block from '~/components/layout/Block'
|
||||||
|
import Hairline from '~/components/layout/Hairline'
|
||||||
|
import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo'
|
||||||
|
import Buttons from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/Buttons'
|
||||||
|
import ContractABI from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/ContractABI'
|
||||||
|
import EthAddressInput from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/EthAddressInput'
|
||||||
|
import EthValue from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/EthValue'
|
||||||
|
import FormDivisor from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/FormDivisor'
|
||||||
|
import Header from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/Header'
|
||||||
|
import MethodsDropdown from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/MethodsDropdown'
|
||||||
|
import RenderInputParams from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderInputParams'
|
||||||
|
import RenderOutputParams from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/RenderOutputParams'
|
||||||
|
import {
|
||||||
|
abiExtractor,
|
||||||
|
createTxObject,
|
||||||
|
formMutators,
|
||||||
|
} from '~/routes/safe/components/Balances/SendModal/screens/ContractInteraction/utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
initialValues: Object,
|
||||||
|
onClose: () => void,
|
||||||
|
onNext: (any) => void,
|
||||||
|
contractAddress?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const ContractInteraction = ({ contractAddress, initialValues, onClose, onNext }: Props) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
React.useMemo(() => {
|
||||||
|
if (contractAddress) {
|
||||||
|
initialValues.contractAddress = contractAddress
|
||||||
|
}
|
||||||
|
}, [contractAddress])
|
||||||
|
|
||||||
|
const handleSubmit = async ({ contractAddress, selectedMethod, value, ...values }: {}) => {
|
||||||
|
if (value || (contractAddress && selectedMethod)) {
|
||||||
|
const data = await createTxObject(selectedMethod, contractAddress, values).encodeABI()
|
||||||
|
onNext({ contractAddress, data, selectedMethod, value, ...values })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header onClose={onClose} subTitle="1 of 2" title="Contract Interaction" />
|
||||||
|
<Hairline />
|
||||||
|
<GnoForm
|
||||||
|
decorators={[abiExtractor]}
|
||||||
|
formMutators={formMutators}
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
subscription={{ submitting: true, pristine: true }}
|
||||||
|
>
|
||||||
|
{(submitting, validating, rest, mutators) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Block className={classes.formContainer}>
|
||||||
|
<SafeInfo />
|
||||||
|
<FormDivisor />
|
||||||
|
<EthAddressInput
|
||||||
|
name="contractAddress"
|
||||||
|
onScannedValue={mutators.setContractAddress}
|
||||||
|
text="Contract Address*"
|
||||||
|
/>
|
||||||
|
<EthValue onSetMax={mutators.setMax} />
|
||||||
|
<ContractABI />
|
||||||
|
<MethodsDropdown onChange={mutators.setSelectedMethod} />
|
||||||
|
<RenderInputParams />
|
||||||
|
<RenderOutputParams />
|
||||||
|
</Block>
|
||||||
|
<Hairline />
|
||||||
|
<Buttons onCallSubmit={mutators.setCallResults} onClose={onClose} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</GnoForm>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContractInteraction
|
|
@ -48,4 +48,7 @@ export const styles = () => ({
|
||||||
selectAddress: {
|
selectAddress: {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
},
|
},
|
||||||
|
fullWidth: {
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
})
|
})
|
|
@ -0,0 +1,59 @@
|
||||||
|
// @flow
|
||||||
|
import createDecorator from 'final-form-calculate'
|
||||||
|
|
||||||
|
import { mustBeEthereumAddress, mustBeEthereumContractAddress } from '~/components/forms/validator'
|
||||||
|
import { getNetwork } from '~/config'
|
||||||
|
import { getConfiguredSource } from '~/logic/contractInteraction/sources'
|
||||||
|
import { getWeb3 } from '~/logic/wallets/getWeb3'
|
||||||
|
|
||||||
|
export const NO_CONTRACT = 'no contract'
|
||||||
|
|
||||||
|
export const abiExtractor = createDecorator({
|
||||||
|
field: 'contractAddress',
|
||||||
|
updates: {
|
||||||
|
abi: async (contractAddress) => {
|
||||||
|
if (
|
||||||
|
!contractAddress ||
|
||||||
|
mustBeEthereumAddress(contractAddress) ||
|
||||||
|
(await mustBeEthereumContractAddress(contractAddress))
|
||||||
|
) {
|
||||||
|
return NO_CONTRACT
|
||||||
|
}
|
||||||
|
const network = getNetwork()
|
||||||
|
const source = getConfiguredSource()
|
||||||
|
return source.getContractABI(contractAddress, network)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const formMutators = {
|
||||||
|
setMax: (args, state, utils) => {
|
||||||
|
utils.changeValue(state, 'value', () => args[0])
|
||||||
|
},
|
||||||
|
setContractAddress: (args, state, utils) => {
|
||||||
|
utils.changeValue(state, 'contractAddress', () => args[0])
|
||||||
|
},
|
||||||
|
setSelectedMethod: (args, state, utils) => {
|
||||||
|
const modified =
|
||||||
|
state.lastFormState.values.selectedMethod && state.lastFormState.values.selectedMethod.name !== args[0].name
|
||||||
|
|
||||||
|
if (modified) {
|
||||||
|
utils.changeValue(state, 'callResults', () => '')
|
||||||
|
utils.changeValue(state, 'value', () => '')
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.changeValue(state, 'selectedMethod', () => args[0])
|
||||||
|
},
|
||||||
|
setCallResults: (args, state, utils) => {
|
||||||
|
utils.changeValue(state, 'callResults', () => args[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTxObject = (method, contractAddress, values) => {
|
||||||
|
const web3 = getWeb3()
|
||||||
|
const contract = new web3.eth.Contract([method], contractAddress)
|
||||||
|
const { inputs, name } = method
|
||||||
|
const args = inputs.map(({ type }, index) => values[`methodInput-${name}_${index}_${type}`])
|
||||||
|
|
||||||
|
return contract.methods[name](...args)
|
||||||
|
}
|
|
@ -53,7 +53,7 @@ const ReviewCollectible = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const shortener = textShortener()
|
const shortener = textShortener()
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const { address: safeAddress, ethBalance, name: safeName } = useSelector(safeSelector)
|
const { address: safeAddress } = useSelector(safeSelector)
|
||||||
const nftTokens: NFTTokensState = useSelector(nftTokensSelector)
|
const nftTokens: NFTTokensState = useSelector(nftTokensSelector)
|
||||||
const [gasCosts, setGasCosts] = useState<string>('< 0.001')
|
const [gasCosts, setGasCosts] = useState<string>('< 0.001')
|
||||||
const txToken = nftTokens.find(
|
const txToken = nftTokens.find(
|
||||||
|
@ -121,7 +121,7 @@ const ReviewCollectible = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx
|
||||||
</Row>
|
</Row>
|
||||||
<Hairline />
|
<Hairline />
|
||||||
<Block className={classes.container}>
|
<Block className={classes.container}>
|
||||||
<SafeInfo ethBalance={ethBalance} safeAddress={safeAddress} safeName={safeName} />
|
<SafeInfo />
|
||||||
<Row margin="md">
|
<Row margin="md">
|
||||||
<Col xs={1}>
|
<Col xs={1}>
|
||||||
<img alt="Arrow Down" src={ArrowDown} style={{ marginLeft: sm }} />
|
<img alt="Arrow Down" src={ArrowDown} style={{ marginLeft: sm }} />
|
||||||
|
|
|
@ -48,7 +48,7 @@ const useStyles = makeStyles(styles)
|
||||||
const ReviewTx = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx }: Props) => {
|
const ReviewTx = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx }: Props) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const { address: safeAddress, ethBalance, name: safeName } = useSelector(safeSelector)
|
const { address: safeAddress } = useSelector(safeSelector)
|
||||||
const tokens = useSelector(extendedSafeTokensSelector)
|
const tokens = useSelector(extendedSafeTokensSelector)
|
||||||
const [gasCosts, setGasCosts] = useState<string>('< 0.001')
|
const [gasCosts, setGasCosts] = useState<string>('< 0.001')
|
||||||
const [data, setData] = useState('')
|
const [data, setData] = useState('')
|
||||||
|
@ -125,7 +125,7 @@ const ReviewTx = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx }: Props
|
||||||
</Row>
|
</Row>
|
||||||
<Hairline />
|
<Hairline />
|
||||||
<Block className={classes.container}>
|
<Block className={classes.container}>
|
||||||
<SafeInfo ethBalance={ethBalance} safeAddress={safeAddress} safeName={safeName} />
|
<SafeInfo />
|
||||||
<Row margin="md">
|
<Row margin="md">
|
||||||
<Col xs={1}>
|
<Col xs={1}>
|
||||||
<img alt="Arrow Down" src={ArrowDown} style={{ marginLeft: sm }} />
|
<img alt="Arrow Down" src={ArrowDown} style={{ marginLeft: sm }} />
|
||||||
|
|
|
@ -31,7 +31,6 @@ import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo'
|
||||||
import AddressBookInput from '~/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
|
import AddressBookInput from '~/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
|
||||||
import CollectibleSelectField from '~/routes/safe/components/Balances/SendModal/screens/SendCollectible/CollectibleSelectField'
|
import CollectibleSelectField from '~/routes/safe/components/Balances/SendModal/screens/SendCollectible/CollectibleSelectField'
|
||||||
import TokenSelectField from '~/routes/safe/components/Balances/SendModal/screens/SendCollectible/TokenSelectField'
|
import TokenSelectField from '~/routes/safe/components/Balances/SendModal/screens/SendCollectible/TokenSelectField'
|
||||||
import { safeSelector } from '~/routes/safe/store/selectors'
|
|
||||||
import { sm } from '~/theme/variables'
|
import { sm } from '~/theme/variables'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -58,7 +57,6 @@ const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
const SendCollectible = ({ initialValues, onClose, onNext, recipientAddress, selectedToken = {} }: Props) => {
|
const SendCollectible = ({ initialValues, onClose, onNext, recipientAddress, selectedToken = {} }: Props) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const { address: safeAddress, ethBalance, name: safeName } = useSelector(safeSelector)
|
|
||||||
const nftAssets: NFTAssetsState = useSelector(safeActiveSelectorMap)
|
const nftAssets: NFTAssetsState = useSelector(safeActiveSelectorMap)
|
||||||
const nftTokens: NFTTokensState = useSelector(nftTokensSelector)
|
const nftTokens: NFTTokensState = useSelector(nftTokensSelector)
|
||||||
const addressBook: AddressBook = useSelector(getAddressBook)
|
const addressBook: AddressBook = useSelector(getAddressBook)
|
||||||
|
@ -130,7 +128,7 @@ const SendCollectible = ({ initialValues, onClose, onNext, recipientAddress, sel
|
||||||
<>
|
<>
|
||||||
<WhenFieldChanges field="assetAddress" set="nftTokenId" to={''} />
|
<WhenFieldChanges field="assetAddress" set="nftTokenId" to={''} />
|
||||||
<Block className={classes.formContainer}>
|
<Block className={classes.formContainer}>
|
||||||
<SafeInfo ethBalance={ethBalance} safeAddress={safeAddress} safeName={safeName} />
|
<SafeInfo />
|
||||||
<Row margin="md">
|
<Row margin="md">
|
||||||
<Col xs={1}>
|
<Col xs={1}>
|
||||||
<img alt="Arrow Down" src={ArrowDown} style={{ marginLeft: sm }} />
|
<img alt="Arrow Down" src={ArrowDown} style={{ marginLeft: sm }} />
|
||||||
|
|
|
@ -1,251 +1 @@
|
||||||
// @flow
|
// @flow
|
||||||
import IconButton from '@material-ui/core/IconButton'
|
|
||||||
import InputAdornment from '@material-ui/core/InputAdornment'
|
|
||||||
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 CopyBtn from '~/components/CopyBtn'
|
|
||||||
import EtherscanBtn from '~/components/EtherscanBtn'
|
|
||||||
import Identicon from '~/components/Identicon'
|
|
||||||
import { ScanQRWrapper } from '~/components/ScanQRModal/ScanQRWrapper'
|
|
||||||
import Field from '~/components/forms/Field'
|
|
||||||
import GnoForm from '~/components/forms/GnoForm'
|
|
||||||
import TextField from '~/components/forms/TextField'
|
|
||||||
import TextareaField from '~/components/forms/TextareaField'
|
|
||||||
import { composeValidators, maxValue, mustBeFloat } from '~/components/forms/validator'
|
|
||||||
import Block from '~/components/layout/Block'
|
|
||||||
import Button from '~/components/layout/Button'
|
|
||||||
import ButtonLink from '~/components/layout/ButtonLink'
|
|
||||||
import Col from '~/components/layout/Col'
|
|
||||||
import Hairline from '~/components/layout/Hairline'
|
|
||||||
import Paragraph from '~/components/layout/Paragraph'
|
|
||||||
import Row from '~/components/layout/Row'
|
|
||||||
import type { AddressBook } from '~/logic/addressBook/model/addressBook'
|
|
||||||
import { getAddressBook } from '~/logic/addressBook/store/selectors'
|
|
||||||
import { getNameFromAdbk } from '~/logic/addressBook/utils'
|
|
||||||
import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo'
|
|
||||||
import AddressBookInput from '~/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
|
|
||||||
import { safeSelector } from '~/routes/safe/store/selectors'
|
|
||||||
import { sm } from '~/theme/variables'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
initialValues: Object,
|
|
||||||
onClose: () => void,
|
|
||||||
onNext: (any) => void,
|
|
||||||
recipientAddress: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
const useStyles = makeStyles(styles)
|
|
||||||
|
|
||||||
const SendCustomTx = ({ initialValues, onClose, onNext, recipientAddress }: Props) => {
|
|
||||||
const classes = useStyles()
|
|
||||||
const { address: safeAddress, ethBalance, name: safeName } = useSelector(safeSelector)
|
|
||||||
const [selectedEntry, setSelectedEntry] = useState<Object | null>({
|
|
||||||
address: recipientAddress || initialValues.recipientAddress,
|
|
||||||
name: '',
|
|
||||||
})
|
|
||||||
const [pristine, setPristine] = useState<boolean>(true)
|
|
||||||
const [isValidAddress, setIsValidAddress] = useState<boolean>(true)
|
|
||||||
const addressBook: AddressBook = useSelector(getAddressBook)
|
|
||||||
|
|
||||||
React.useMemo(() => {
|
|
||||||
if (selectedEntry === null && pristine) {
|
|
||||||
setPristine(false)
|
|
||||||
}
|
|
||||||
}, [selectedEntry, pristine])
|
|
||||||
|
|
||||||
const handleSubmit = (values: Object) => {
|
|
||||||
if (values.data || values.value) {
|
|
||||||
onNext(values)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formMutators = {
|
|
||||||
setMax: (args, state, utils) => {
|
|
||||||
utils.changeValue(state, 'value', () => ethBalance)
|
|
||||||
},
|
|
||||||
setRecipient: (args, state, utils) => {
|
|
||||||
utils.changeValue(state, 'recipientAddress', () => 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} onSubmit={handleSubmit}>
|
|
||||||
{(...args) => {
|
|
||||||
const mutators = args[3]
|
|
||||||
|
|
||||||
let shouldDisableSubmitButton = !isValidAddress
|
|
||||||
if (selectedEntry) {
|
|
||||||
shouldDisableSubmitButton = !selectedEntry.address
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScan = (value, closeQrModal) => {
|
|
||||||
let scannedAddress = value
|
|
||||||
|
|
||||||
if (scannedAddress.startsWith('ethereum:')) {
|
|
||||||
scannedAddress = scannedAddress.replace('ethereum:', '')
|
|
||||||
}
|
|
||||||
const scannedName = addressBook ? getNameFromAdbk(addressBook, scannedAddress) : ''
|
|
||||||
mutators.setRecipient(scannedAddress)
|
|
||||||
setSelectedEntry({
|
|
||||||
name: scannedName,
|
|
||||||
address: scannedAddress,
|
|
||||||
})
|
|
||||||
closeQrModal()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Block className={classes.formContainer}>
|
|
||||||
<SafeInfo ethBalance={ethBalance} safeAddress={safeAddress} safeName={safeName} />
|
|
||||||
<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}>
|
|
||||||
<ScanQRWrapper handleScan={handleScan} />
|
|
||||||
</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
|
|
||||||
className={classes.addressInput}
|
|
||||||
component={TextField}
|
|
||||||
inputAdornment={{
|
|
||||||
endAdornment: <InputAdornment position="end">ETH</InputAdornment>,
|
|
||||||
}}
|
|
||||||
name="value"
|
|
||||||
placeholder="Value*"
|
|
||||||
text="Value*"
|
|
||||||
type="text"
|
|
||||||
validate={composeValidators(mustBeFloat, maxValue(ethBalance))}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
<Row margin="sm">
|
|
||||||
<Col>
|
|
||||||
<TextareaField
|
|
||||||
name="data"
|
|
||||||
placeholder="Data (hex encoded)*"
|
|
||||||
text="Data (hex encoded)*"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</GnoForm>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SendCustomTx
|
|
||||||
|
|
|
@ -34,7 +34,6 @@ import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo'
|
||||||
import AddressBookInput from '~/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
|
import AddressBookInput from '~/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
|
||||||
import TokenSelectField from '~/routes/safe/components/Balances/SendModal/screens/SendFunds/TokenSelectField'
|
import TokenSelectField from '~/routes/safe/components/Balances/SendModal/screens/SendFunds/TokenSelectField'
|
||||||
import { extendedSafeTokensSelector } from '~/routes/safe/container/selector'
|
import { extendedSafeTokensSelector } from '~/routes/safe/container/selector'
|
||||||
import { safeSelector } from '~/routes/safe/store/selectors'
|
|
||||||
import { sm } from '~/theme/variables'
|
import { sm } from '~/theme/variables'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -61,7 +60,6 @@ const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
const SendFunds = ({ initialValues, onClose, onNext, recipientAddress, selectedToken = '' }: Props) => {
|
const SendFunds = ({ initialValues, onClose, onNext, recipientAddress, selectedToken = '' }: Props) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const { address: safeAddress, ethBalance, name: safeName } = useSelector(safeSelector)
|
|
||||||
const tokens: Token = useSelector(extendedSafeTokensSelector)
|
const tokens: Token = useSelector(extendedSafeTokensSelector)
|
||||||
const addressBook: AddressBook = useSelector(getAddressBook)
|
const addressBook: AddressBook = useSelector(getAddressBook)
|
||||||
const [selectedEntry, setSelectedEntry] = useState<Object | null>({
|
const [selectedEntry, setSelectedEntry] = useState<Object | null>({
|
||||||
|
@ -128,7 +126,7 @@ const SendFunds = ({ initialValues, onClose, onNext, recipientAddress, selectedT
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Block className={classes.formContainer}>
|
<Block className={classes.formContainer}>
|
||||||
<SafeInfo ethBalance={ethBalance} safeAddress={safeAddress} safeName={safeName} />
|
<SafeInfo />
|
||||||
<Row margin="md">
|
<Row margin="md">
|
||||||
<Col xs={1}>
|
<Col xs={1}>
|
||||||
<img alt="Arrow Down" src={ArrowDown} style={{ marginLeft: sm }} />
|
<img alt="Arrow Down" src={ArrowDown} style={{ marginLeft: sm }} />
|
||||||
|
|
|
@ -4,11 +4,11 @@ import { makeStyles } from '@material-ui/core/styles'
|
||||||
const buttonWidth = '140px'
|
const buttonWidth = '140px'
|
||||||
export const useDropdownStyles = makeStyles({
|
export const useDropdownStyles = makeStyles({
|
||||||
listItem: {
|
listItem: {
|
||||||
maxWidth: buttonWidth,
|
maxWidth: (props) => (props.buttonWidth ? props.buttonWidth : buttonWidth),
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
},
|
},
|
||||||
listItemSearch: {
|
listItemSearch: {
|
||||||
maxWidth: buttonWidth,
|
maxWidth: (props) => (props.buttonWidth ? props.buttonWidth : buttonWidth),
|
||||||
padding: '0',
|
padding: '0',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
},
|
},
|
||||||
|
@ -37,7 +37,7 @@ export const useDropdownStyles = makeStyles({
|
||||||
height: '24px',
|
height: '24px',
|
||||||
lineHeight: '1.33',
|
lineHeight: '1.33',
|
||||||
marginRight: '20px',
|
marginRight: '20px',
|
||||||
minWidth: buttonWidth,
|
minWidth: (props) => (props.buttonWidth ? props.buttonWidth : buttonWidth),
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
padding: '0',
|
padding: '0',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
|
|
|
@ -23,7 +23,7 @@ const typeToIcon = {
|
||||||
const typeToLabel = {
|
const typeToLabel = {
|
||||||
outgoing: 'Outgoing transfer',
|
outgoing: 'Outgoing transfer',
|
||||||
incoming: 'Incoming transfer',
|
incoming: 'Incoming transfer',
|
||||||
custom: 'Custom transaction',
|
custom: 'Contract Interaction',
|
||||||
settings: 'Modify settings',
|
settings: 'Modify settings',
|
||||||
creation: 'Safe created',
|
creation: 'Safe created',
|
||||||
cancellation: 'Cancellation transaction',
|
cancellation: 'Cancellation transaction',
|
||||||
|
|
|
@ -14,3 +14,4 @@ export const APP_VERSION = process.env.REACT_APP_APP_VERSION || 'not-defined'
|
||||||
export const OPENSEA_API_KEY = process.env.REACT_APP_OPENSEA_API_KEY || ''
|
export const OPENSEA_API_KEY = process.env.REACT_APP_OPENSEA_API_KEY || ''
|
||||||
export const COLLECTIBLES_SOURCE = process.env.REACT_APP_COLLECTIBLES_SOURCE || 'OpenSea'
|
export const COLLECTIBLES_SOURCE = process.env.REACT_APP_COLLECTIBLES_SOURCE || 'OpenSea'
|
||||||
export const TIMEOUT = process.env.NODE_ENV === 'test' ? 1500 : 5000
|
export const TIMEOUT = process.env.NODE_ENV === 'test' ? 1500 : 5000
|
||||||
|
export const ETHERSCAN_API_KEY = process.env.REACT_APP_ETHERSCAN_API_KEY
|
||||||
|
|
Loading…
Reference in New Issue