Merge pull request #666 from gnosis/development

Merge development into master
This commit is contained in:
Fernando 2020-03-18 18:51:03 -03:00 committed by GitHub
commit f4bdf2f3c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 3603 additions and 1073 deletions

35
flow-typed/npm/async-sema_vx.x.x.js vendored Normal file
View File

@ -0,0 +1,35 @@
// flow-typed signature: 8bfc0eb0795000bed1976434e91400ae
// flow-typed version: <<STUB>>/async-sema_v3.1.0/flow_v0.114.0
/**
* This is an autogenerated libdef stub for:
*
* 'async-sema'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
declare module 'async-sema' {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module 'async-sema/lib' {
declare module.exports: any;
}
// Filename aliases
declare module 'async-sema/lib/index' {
declare module.exports: $Exports<'async-sema/lib'>;
}
declare module 'async-sema/lib/index.js' {
declare module.exports: $Exports<'async-sema/lib'>;
}

View File

@ -46,11 +46,13 @@
"@material-ui/core": "4.9.5",
"@material-ui/icons": "4.9.1",
"@material-ui/lab": "4.0.0-alpha.39",
"@openzeppelin/contracts": "^2.5.0",
"@testing-library/jest-dom": "5.1.1",
"@welldone-software/why-did-you-render": "4.0.5",
"async-sema": "^3.1.0",
"axios": "0.19.2",
"bignumber.js": "9.0.0",
"bnc-onboard": "1.3.3",
"bnc-onboard": "1.3.5",
"connected-react-router": "6.7.0",
"currency-flags": "^2.1.1",
"date-fns": "2.10.0",
@ -164,4 +166,4 @@
"webpack-dev-server": "3.10.3",
"webpack-manifest-plugin": "2.2.0"
}
}
}

View File

@ -2,7 +2,7 @@
import IconButton from '@material-ui/core/IconButton'
import { createStyles, makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import React from 'react'
import React, { type Node } from 'react'
import styled from 'styled-components'
import Modal from '~/components/Modal'
@ -40,8 +40,8 @@ const useStyles = makeStyles(() =>
type Props = {
title: string,
body: React.Node,
footer: React.Node,
body: Node,
footer: Node,
onClose: () => void,
}

View File

@ -105,8 +105,12 @@ const ConnectButton = (props: Props) => (
color="primary"
minWidth={140}
onClick={async () => {
await onboard.walletSelect()
await onboard.walletCheck()
const walletSelected = await onboard.walletSelect()
// perform wallet checks only if user selected a wallet
if (walletSelected) {
await onboard.walletCheck()
}
}}
variant="contained"
{...props}

View File

@ -30,6 +30,7 @@ const useStyles = makeStyles({
padding: '27px 15px',
position: 'fixed',
width: '100%',
zIndex: '5',
},
content: {
maxWidth: '100%',

View File

@ -0,0 +1,26 @@
// @flow
import React from 'react'
import { OnChange } from 'react-final-form-listeners'
import GnoField from '~/components/forms/Field'
type Props = {
field: string,
set: string,
to: string | number | null,
}
const WhenFieldChanges = ({ field, set, to }: Props) => (
<GnoField name={set} subscription={{}}>
{(
// No subscription. We only use Field to get to the change function
{ input: { onChange } },
) => (
<OnChange name={field}>
{() => {
onChange(to)
}}
</OnChange>
)}
</GnoField>
)
export default WhenFieldChanges

View File

@ -0,0 +1,12 @@
// @flow
import OpenSea from '~/logic/collectibles/sources/OpenSea'
import mockedOpenSea from '~/logic/collectibles/sources/mocked_opensea'
class MockedOpenSea extends OpenSea {
_fetch = async () => {
await this._rateLimit()
return { json: () => mockedOpenSea }
}
}
export default MockedOpenSea

View File

@ -0,0 +1,108 @@
// @flow
import { RateLimit } from 'async-sema'
import type {
CollectibleMetadataSource,
CollectiblesInfo,
NFTAsset,
NFTAssets,
NFTToken,
OpenSeaAsset,
} from '~/routes/safe/components/Balances/Collectibles/types'
import NFTIcon from '~/routes/safe/components/Balances/assets/nft_icon.png'
import { OPENSEA_API_KEY } from '~/utils/constants'
class OpenSea implements CollectibleMetadataSource {
_rateLimit = async () => {}
_endpointsUrls: { [key: number]: string } = {
// $FlowFixMe
1: 'https://api.opensea.io/api/v1',
// $FlowFixMe
4: 'https://rinkeby-api.opensea.io/api/v1',
}
_fetch = async (url: string) => {
// eslint-disable-next-line no-underscore-dangle
return fetch(url, {
headers: { 'X-API-KEY': OPENSEA_API_KEY || '' },
})
}
/**
* OpenSea class constructor
* @param {object} options
* @param {number} options.rps - requests per second
*/
constructor(options: { rps: number }) {
// eslint-disable-next-line no-underscore-dangle
this._rateLimit = RateLimit(options.rps, { timeUnit: 60 * 1000, uniformDistribution: true })
}
static extractAssets(assets: OpenSeaAsset[]): NFTAssets {
const extractNFTAsset = (asset): NFTAsset => ({
address: asset.asset_contract.address,
assetContract: asset.asset_contract,
collection: asset.collection,
description: asset.asset_contract.name,
image: asset.asset_contract.image_url || NFTIcon,
name: asset.collection.name,
numberOfTokens: 1,
slug: asset.collection.slug,
symbol: asset.asset_contract.symbol,
})
return assets.reduce((acc: NFTAssets, asset: OpenSeaAsset) => {
const address = asset.asset_contract.address
if (acc[address] === undefined) {
acc[address] = extractNFTAsset(asset)
} else {
// By default, extractNFTAsset sets `numberOfTokens` to 1,
// counting the asset recently processed.
// If it happens to already exist the asset in the map,
// then we just increment the `numberOfTokens` value by 1.
acc[address].numberOfTokens = acc[address].numberOfTokens + 1
}
return acc
}, {})
}
static extractTokens(assets: OpenSeaAsset[]): NFTToken[] {
return assets.map((asset: OpenSeaAsset): NFTToken => ({
assetAddress: asset.asset_contract.address,
color: asset.background_color,
description: asset.description,
image: asset.image_thumbnail_url || NFTIcon,
name: asset.name || `${asset.asset_contract.name} - #${asset.token_id}`,
tokenId: asset.token_id,
}))
}
static extractCollectiblesInfo(assetResponseJson: { assets: OpenSeaAsset[] }): CollectiblesInfo {
return {
nftAssets: OpenSea.extractAssets(assetResponseJson.assets),
nftTokens: OpenSea.extractTokens(assetResponseJson.assets),
}
}
/**
* Fetches from OpenSea the list of collectibles, grouped by category,
* for the provided Safe Address in the specified Network
* @param {string} safeAddress
* @param {number} networkId
* @returns {Promise<{ nftAssets: Map<string, NFTAsset>, nftTokens: Array<NFTToken> }>}
*/
async fetchAllUserCollectiblesByCategoryAsync(safeAddress: string, networkId: number) {
// eslint-disable-next-line no-underscore-dangle
const metadataSourceUrl = this._endpointsUrls[networkId]
const url = `${metadataSourceUrl}/assets/?owner=${safeAddress}`
// eslint-disable-next-line no-underscore-dangle
const assetsResponse = await this._fetch(url)
const assetsResponseJson = await assetsResponse.json()
return OpenSea.extractCollectiblesInfo(assetsResponseJson)
}
}
export default OpenSea

View File

@ -0,0 +1,12 @@
// @flow
import MockedOpenSea from '~/logic/collectibles/sources/MockedOpenSea'
import OpenSea from '~/logic/collectibles/sources/OpenSea'
import type { CollectibleMetadataSource } from '~/routes/safe/components/Balances/Collectibles/types'
import { COLLECTIBLES_SOURCE } from '~/utils/constants'
const sources: { [key: string]: CollectibleMetadataSource } = {
opensea: new OpenSea({ rps: 4 }),
mockedopensea: new MockedOpenSea({ rps: 4 }),
}
export const getConfiguredSource = () => sources[COLLECTIBLES_SOURCE.toLowerCase()]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
// @flow
import { createAction } from 'redux-actions'
import type { NFTAssets, NFTToken } from '~/routes/safe/components/Balances/Collectibles/types'
export const ADD_NFT_ASSETS = 'ADD_NFT_ASSETS'
export const ADD_NFT_TOKENS = 'ADD_NFT_TOKENS'
export const addNftAssets = createAction<string, *, *>(ADD_NFT_ASSETS, (nftAssets: NFTAssets) => ({
nftAssets,
}))
export const addNftTokens = createAction<string, *, *>(ADD_NFT_TOKENS, (nftTokens: NFTToken[]) => ({
nftTokens,
}))

View File

@ -0,0 +1,21 @@
// @flow
import type { Dispatch } from 'redux'
import { getConfiguredSource } from '~/logic/collectibles/sources'
import { addNftAssets, addNftTokens } from '~/logic/collectibles/store/actions/addCollectibles'
import { PROVIDER_REDUCER_ID } from '~/logic/wallets/store/reducer/provider'
import { safeParamAddressFromStateSelector } from '~/routes/safe/store/selectors'
import type { GlobalState } from '~/store'
const fetchCollectibles = () => async (dispatch: Dispatch<GlobalState>, getState) => {
const state = getState()
const { network } = state[PROVIDER_REDUCER_ID]
const safeAddress = safeParamAddressFromStateSelector(state)
const source = getConfiguredSource()
const collectibles = await source.fetchAllUserCollectiblesByCategoryAsync(safeAddress, network)
dispatch(addNftAssets(collectibles.nftAssets))
dispatch(addNftTokens(collectibles.nftTokens))
}
export default fetchCollectibles

View File

@ -0,0 +1,33 @@
// @flow
import { type ActionType, handleActions } from 'redux-actions'
import { ADD_NFT_ASSETS, ADD_NFT_TOKENS } from '~/logic/collectibles/store/actions/addCollectibles'
import type { NFTAssets, NFTToken } from '~/routes/safe/components/Balances/Collectibles/types'
export const NFT_ASSETS_REDUCER_ID = 'nftAssets'
export const NFT_TOKENS_REDUCER_ID = 'nftTokens'
export type NFTAssetsState = NFTAssets | {}
export type NFTTokensState = NFTToken[]
export const nftAssetReducer = handleActions<NFTAssetsState, *>(
{
[ADD_NFT_ASSETS]: (state: NFTAssetsState, action: ActionType<Function>): NFTAssetsState => {
const { nftAssets } = action.payload
return nftAssets
},
},
{},
)
export const nftTokensReducer = handleActions<NFTTokensState, *>(
{
[ADD_NFT_TOKENS]: (state: NFTTokensState, action: ActionType<Function>): NFTTokensState => {
const { nftTokens } = action.payload
return nftTokens
},
},
[],
)

View File

@ -0,0 +1,6 @@
// @flow
import { NFT_ASSETS_REDUCER_ID, NFT_TOKENS_REDUCER_ID } from '~/logic/collectibles/store/reducer/collectibles'
import type { GlobalState } from '~/store'
export const nftAssetsSelector = (state: GlobalState) => state[NFT_ASSETS_REDUCER_ID]
export const nftTokensSelector = (state: GlobalState) => state[NFT_TOKENS_REDUCER_ID]

View File

@ -3,7 +3,7 @@ import { List } from 'immutable'
import { type Confirmation } from '~/routes/safe/store/models/confirmation'
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
// https://docs.gnosis.io/safe/docs/docs5/#pre-validated-signatures
// https://github.com/gnosis/safe-contracts/blob/master/test/gnosisSafeTeamEdition.js#L26
export const generateSignaturesFromTxConfirmations = (
confirmations: List<Confirmation>,
@ -28,7 +28,7 @@ export const generateSignaturesFromTxConfirmations = (
if (conf.signature) {
sigs += conf.signature.slice(2)
} else {
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
// https://docs.gnosis.io/safe/docs/docs5/#pre-validated-signatures
sigs += `000000000000000000000000${addr.replace(
'0x',
'',

View File

@ -21,7 +21,7 @@ const estimateDataGasCosts = data => {
return data.match(/.{2}/g).reduce(reducer, 0)
}
// https://gnosis-safe.readthedocs.io/en/latest/contracts/transactions.html#safe-transaction-data-gas-estimation
// https://docs.gnosis.io/safe/docs/docs4/#safe-transaction-data-gas-estimation
// https://github.com/gnosis/safe-contracts/blob/a97c6fd24f79c0b159ddd25a10a2ebd3ea2ef926/test/utils/execution.js
export const estimateDataGas = (
safe: any,
@ -43,7 +43,7 @@ export const estimateDataGas = (
const gasPrice = 0 // no need to get refund when we submit txs to metamask
const signatureCost = signatureCount * (68 + 2176 + 2176 + 6000) // array count (3 -> r, s, v) * signature count
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
// https://docs.gnosis.io/safe/docs/docs5/#pre-validated-signatures
const sigs = `0x000000000000000000000000${from.replace(
'0x',
'',
@ -103,7 +103,7 @@ export const calculateTxFee = async (
safeInstance = await getGnosisSafeInstanceAt(safeAddress)
}
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
// https://docs.gnosis.io/safe/docs/docs5/#pre-validated-signatures
const sigs = `0x000000000000000000000000${from.replace(
'0x',
'',

View File

@ -27,7 +27,7 @@ export const estimateTxGasCosts = async (
let txData
if (isExecution) {
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
// https://docs.gnosis.io/safe/docs/docs5/#pre-validated-signatures
const signatures =
tx && tx.confirmations
? generateSignaturesFromTxConfirmations(tx.confirmations, preApprovingOwner)

View File

@ -1,22 +1,50 @@
// @flow
import semverLessThan from 'semver/functions/lt'
import semverSatisfies from 'semver/functions/satisfies'
import semverValid from 'semver/functions/valid'
import { getSafeLastVersion } from '~/config'
import { getGnosisSafeInstanceAt, getSafeMasterContract } from '~/logic/contracts/safeContracts'
export const FEATURES = [
{ name: 'ERC721', validVersion: '>=1.1.1' },
{ name: 'ERC1155', validVersion: '>=1.1.1' },
]
export const safeNeedsUpdate = (currentVersion: string, latestVersion: string) => {
if (!currentVersion || !latestVersion) {
return false
}
const current = semverValid(currentVersion)
const latest = semverValid(latestVersion)
return latest ? semverLessThan(current, latest) : false
}
export const getCurrentSafeVersion = gnosisSafeInstance => gnosisSafeInstance.VERSION()
export const enabledFeatures = (version: string) =>
FEATURES.reduce((acc, feature) => {
if (semverSatisfies(version, feature.validVersion)) {
acc.push(feature.name)
}
return acc
}, [])
export const checkIfSafeNeedsUpdate = async (gnosisSafeInstance, lastSafeVersion) => {
if (!gnosisSafeInstance || !lastSafeVersion) {
return null
}
const safeMasterVersion = await gnosisSafeInstance.VERSION()
const safeMasterVersion = await getCurrentSafeVersion(gnosisSafeInstance)
const current = semverValid(safeMasterVersion)
const latest = semverValid(lastSafeVersion)
const needUpdate = latest ? semverLessThan(current, latest) : false
const needUpdate = safeNeedsUpdate(safeMasterVersion, lastSafeVersion)
return { current, latest, needUpdate }
}
const getCurrentMasterContractLastVersion = async () => {
export const getCurrentMasterContractLastVersion = async () => {
const safeMaster = await getSafeMasterContract()
let safeMasterVersion
try {

View File

@ -1,6 +1,7 @@
// @flow
import StandardToken from '@gnosis.pm/util-contracts/build/contracts/GnosisStandardToken.json'
import HumanFriendlyToken from '@gnosis.pm/util-contracts/build/contracts/HumanFriendlyToken.json'
import ERC721 from '@openzeppelin/contracts/build/contracts/ERC721'
import { List } from 'immutable'
import type { Dispatch as ReduxDispatch } from 'redux'
import contract from 'truffle-contract'
@ -28,10 +29,27 @@ const createHumanFriendlyTokenContract = async () => {
return humanErc20Token
}
const createERC721TokenContract = async () => {
const web3 = getWeb3()
const erc721Token = await contract(ERC721)
erc721Token.setProvider(web3.currentProvider)
return erc721Token
}
export const getHumanFriendlyToken = ensureOnce(createHumanFriendlyTokenContract)
export const getStandardTokenContract = ensureOnce(createStandardTokenContract)
export const getERC721TokenContract = ensureOnce(createERC721TokenContract)
export const containsMethodByHash = async (contractAddress: string, methodHash: string) => {
const web3 = getWeb3()
const byteCode = await web3.eth.getCode(contractAddress)
return byteCode.indexOf(methodHash.replace('0x', '')) !== -1
}
export const fetchTokens = () => async (dispatch: ReduxDispatch<GlobalState>) => {
try {
const {

View File

@ -222,7 +222,7 @@ const AddressBookTable = ({ classes }: Props) => {
onClose={() => setDeleteEntryModalOpen(false)}
/>
<SendModal
activeScreenType="sendFunds"
activeScreenType="chooseTxType"
ethBalance={ethBalance}
isOpen={sendFundsModalOpen}
onClose={() => setSendFundsModalOpen(false)}

View File

@ -0,0 +1,140 @@
// @flow
import { makeStyles } from '@material-ui/core/styles'
import CallMade from '@material-ui/icons/CallMade'
import cn from 'classnames'
import * as React from 'react'
import { useSelector } from 'react-redux'
import Button from '~/components/layout/Button'
import type { NFTToken } from '~/routes/safe/components/Balances/Collectibles/types'
import { grantedSelector } from '~/routes/safe/container/selector'
import { fontColor, sm, xs } from '~/theme/variables'
const useStyles = makeStyles({
item: {
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 0 10px 0 rgba(33, 48, 77, 0.10)',
boxSizing: 'border-box',
cursor: props => (props.granted ? 'pointer' : 'default'),
display: 'flex',
flexDirection: 'column',
flexGrow: '1',
minHeight: '250px',
minWidth: '0',
position: 'relative',
'&:hover .showOnHover': {
opacity: '1',
},
'&:active .showOnHover': {
opacity: '1',
},
},
mainContent: {
display: 'flex',
flexDirection: 'column',
flexGrow: '1',
position: 'relative',
zIndex: '1',
},
extraContent: {
alignItems: 'center',
backgroundColor: 'rgba(255, 243, 226, 0.6)',
bottom: '0',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
left: '0',
opacity: '0',
position: 'absolute',
right: '0',
top: '0',
transition: 'opacity 0.15s ease-out',
zIndex: '5',
},
image: {
backgroundColor: props => `#${props.backgroundColor}` || '#f0efee',
backgroundPosition: '50% 50%',
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
borderRadius: '8px',
height: '178px',
flexGrow: '1',
width: '100%',
},
textContainer: {
boxSizing: 'border-box',
color: fontColor,
flexShrink: '0',
fontSize: '12px',
lineHeight: '1.4',
padding: '15px 22px 20px',
},
title: {
fontWeight: 'bold',
margin: '0',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
text: {
margin: '0',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
buttonIcon: {
fontSize: 16,
marginRight: sm,
},
sendButton: {
borderRadius: xs,
minWidth: '85px',
'& > span': {
fontSize: '14px',
},
},
})
type Props = {
data: NFTToken,
onSend: Function,
}
const Item = ({ data, onSend }: Props) => {
const granted = useSelector(grantedSelector)
const classes = useStyles({ backgroundColor: data.color, granted })
return (
<div className={classes.item}>
<div className={classes.mainContent}>
<div className={classes.image} style={{ backgroundImage: `url(${data.image})` }} />
<div className={classes.textContainer}>
{data.name && (
<h3 className={classes.title} title={data.name}>
{data.name}
</h3>
)}
{data.description && (
<p className={classes.text} title={data.description}>
{data.description}
</p>
)}
</div>
</div>
{granted && (
<div className={cn(classes.extraContent, 'showOnHover')}>
<Button className={classes.sendButton} color="primary" onClick={onSend} size="small" variant="contained">
<CallMade alt="Send" className={classes.buttonIcon} /> Send
</Button>
</div>
)}
</div>
)
}
export default Item

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -0,0 +1,138 @@
// @flow
import Card from '@material-ui/core/Card'
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import { useSelector } from 'react-redux'
import Item from './components/Item'
import Paragraph from '~/components/layout/Paragraph'
import type { NFTAssetsState, NFTTokensState } from '~/logic/collectibles/store/reducer/collectibles'
import { nftAssetsSelector, nftTokensSelector } from '~/logic/collectibles/store/selectors'
import SendModal from '~/routes/safe/components/Balances/SendModal'
import { safeSelector } from '~/routes/safe/store/selectors'
import { fontColor, lg, screenSm, screenXs } from '~/theme/variables'
const useStyles = makeStyles({
cardInner: {
boxSizing: 'border-box',
maxWidth: '100%',
padding: '52px 54px',
},
cardOuter: {
boxShadow: '1px 2px 10px 0 rgba(212, 212, 211, 0.59)',
},
gridRow: {
boxSizing: 'border-box',
columnGap: '30px',
display: 'grid',
gridTemplateColumns: '1fr',
marginBottom: '45px',
maxWidth: '100%',
rowGap: '45px',
'&:last-child': {
marginBottom: '0',
},
[`@media (min-width: ${screenXs}px)`]: {
gridTemplateColumns: '1fr 1fr',
},
[`@media (min-width: ${screenSm}px)`]: {
gridTemplateColumns: '1fr 1fr 1fr 1fr',
},
},
title: {
alignItems: 'center',
display: 'flex',
margin: '0 0 18px',
},
titleImg: {
backgroundPosition: '50% 50%',
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
borderRadius: '50%',
height: '45px',
margin: '0 10px 0 0',
width: '45px',
},
titleText: {
color: fontColor,
fontSize: '18px',
fontWeight: 'normal',
lineHeight: '1.2',
margin: '0',
},
titleFiller: {
backgroundColor: '#e8e7e6',
flexGrow: '1',
height: '2px',
marginLeft: '40px',
},
noData: {
fontSize: lg,
textAlign: 'center',
},
})
const Collectibles = () => {
const classes = useStyles()
const [selectedToken, setSelectedToken] = React.useState({})
const [sendNFTsModalOpen, setSendNFTsModalOpen] = React.useState(false)
const { address, ethBalance, name } = useSelector(safeSelector)
const nftAssets: NFTAssetsState = useSelector(nftAssetsSelector)
const nftTokens: NFTTokensState = useSelector(nftTokensSelector)
const nftAssetsKeys = Object.keys(nftAssets)
const handleItemSend = nftToken => {
setSelectedToken(nftToken)
setSendNFTsModalOpen(true)
}
return (
<Card className={classes.cardOuter}>
<div className={classes.cardInner}>
{nftAssetsKeys.length ? (
nftAssetsKeys.map(assetAddress => {
const nftAsset = nftAssets[assetAddress]
return (
<React.Fragment key={nftAsset.slug}>
<div className={classes.title}>
<div className={classes.titleImg} style={{ backgroundImage: `url(${nftAsset.image || ''})` }} />
<h2 className={classes.titleText}>{nftAsset.name}</h2>
<div className={classes.titleFiller} />
</div>
<div className={classes.gridRow}>
{nftTokens
.filter(({ assetAddress }) => nftAsset.address === assetAddress)
.map(nftToken => (
<Item
data={nftToken}
key={`${nftAsset.slug}_${nftToken.tokenId}`}
onSend={() => handleItemSend(nftToken)}
/>
))}
</div>
</React.Fragment>
)
})
) : (
<Paragraph className={classes.noData}>No collectibles available</Paragraph>
)}
</div>
<SendModal
activeScreenType="sendCollectible"
ethBalance={ethBalance}
isOpen={sendNFTsModalOpen}
onClose={() => setSendNFTsModalOpen(false)}
safeAddress={address}
safeName={name}
selectedToken={selectedToken}
/>
</Card>
)
}
export default Collectibles

View File

@ -0,0 +1,209 @@
// @flow
export type AssetContractType = 'fungible' | 'non-fungible' | 'semi-fungible' | 'unknown'
export type CollectibleContract = {
address: string,
asset_contract_type: AssetContractType,
created_date: string,
name: string,
nft_version: string,
opensea_version: ?string,
owner: ?number,
schema_name: string,
symbol: string,
total_supply: ?string,
description: ?string,
external_link: ?string,
image_url: ?string,
default_to_fiat: boolean,
dev_buyer_fee_basis_points: number,
dev_seller_fee_basis_points: number,
only_proxied_transfers: boolean,
opensea_buyer_fee_basis_points: number,
opensea_seller_fee_basis_points: number,
buyer_fee_basis_points: number,
seller_fee_basis_points: number,
payout_address: ?string,
}
export type Range = {
min: number,
max: number,
}
export type Traits = {
cooldown_index: Range,
generation: Range,
fancy_ranking: Range,
}
export type Stats = {
seven_day_volume: number,
seven_day_change: number,
total_volume: number,
count: number,
num_owners: number,
market_cap: number,
average_price: number,
items_sold: number,
}
export type DisplayData = {
card_display_style?: ?string,
images: ?(string[]),
}
export type OpenSeaCollection = {
primary_asset_contracts?: CollectibleContract[],
traits?: Traits | {},
stats?: Stats,
banner_image_url: ?string,
chat_url: ?string,
created_date: string,
default_to_fiat: boolean,
description: ?string,
dev_buyer_fee_basis_points: string,
dev_seller_fee_basis_points: string,
display_data: DisplayData,
external_url: ?string,
featured: boolean,
featured_image_url: ?string,
hidden: boolean,
image_url: ?string,
is_subject_to_whitelist: boolean,
large_image_url: ?string,
name: string,
only_proxied_transfers: boolean,
opensea_buyer_fee_basis_points: string,
opensea_seller_fee_basis_points: string,
payout_address: ?string,
require_email: boolean,
short_description: ?string,
slug: string,
wiki_url: ?string,
owned_asset_count?: number,
}
export type OpenSeaAccount = {
user: ?number | string,
profile_img_url: string,
address: string,
config: string,
discord_id: string,
}
export type OpenSeaTransaction = {
id: number,
from_account: OpenSeaAccount,
to_account: OpenSeaAccount,
created_date: string,
modified_date: string,
transaction_hash: string,
transaction_index: string,
block_number: string,
block_hash: string,
timestamp: string,
}
export type OpenSeaToken = {
symbol: string,
address: string,
image_url: ?string,
name: string,
decimals: number,
eth_price: string,
usd_price: string,
}
export type OpenSeaSale = {
event_type: string,
auction_type: ?string,
total_price: string,
transaction: OpenSeaTransaction,
payment_token: OpenSeaToken,
}
export type OpenSeaAsset = {
token_id: string,
num_sales: number,
background_color: string,
image_url: string,
image_preview_url: string,
image_thumbnail_url: string,
image_original_url: string,
animation_url: ?string,
animation_original_url: ?string,
name: string,
description: string,
external_link: string,
asset_contract: CollectibleContract,
owner: OpenSeaAccount,
permalink: string,
collection: OpenSeaCollection,
decimals: ?(number | string),
auctions: ?(number | string),
sell_orders: ?(string[]),
traits: {}[],
last_sale: OpenSeaSale,
top_bid: ?(number | string),
current_price: ?(number | string),
current_escrow_price: ?(number | string),
listing_date: ?string,
is_presale: boolean,
transfer_fee_payment_token: ?(number | string),
transfer_fee: ?(number | string),
}
export type AssetCollectible = {
tokenId: string,
title: string,
text: ?string,
color: string,
image: string,
assetUrl: string,
description: string,
order?: ?string,
assetAddress: string,
asset: CollectibleContract,
collection: OpenSeaCollection,
}
export type CollectibleData = {
image: ?string,
slug: string,
title: string,
data: AssetCollectible[],
}
export type NFTAsset = {
address: string,
assetContract: CollectibleContract,
collection: OpenSeaCollection,
description: ?string,
image: ?string,
name: string,
numberOfTokens: number,
slug: string,
symbol: string,
}
export type NFTToken = {
assetAddress: string,
color: ?string,
description: string,
image: string,
name: string,
tokenId: string,
}
export type NFTAssets = { [key: string]: NFTAsset }
export type CollectiblesInfo = {
nftAssets: NFTAssets,
nftTokens: NFTToken[],
}
export interface CollectibleMetadataSource {
constructor(options: { rps: number }): void;
fetchAllUserCollectiblesByCategoryAsync(safeAddress: string, networkId: number): Promise<CollectiblesInfo>;
}

View File

@ -1,35 +1,46 @@
// @flow
import CircularProgress from '@material-ui/core/CircularProgress'
import { withStyles } from '@material-ui/core/styles'
import { makeStyles } from '@material-ui/core/styles'
import cn from 'classnames'
import { List } from 'immutable'
import React, { Suspense, useEffect, useState } from 'react'
import Modal from '~/components/Modal'
import { type Token } from '~/logic/tokens/store/model/token'
import type { NFTToken } from '~/routes/safe/components/Balances/Collectibles/types'
const ChooseTxType = React.lazy(() => import('./screens/ChooseTxType'))
const SendFunds = React.lazy(() => import('./screens/SendFunds'))
const SendCollectible = React.lazy(() => import('./screens/SendCollectible'))
const ReviewCollectible = React.lazy(() => import('./screens/ReviewCollectible'))
const ReviewTx = React.lazy(() => import('./screens/ReviewTx'))
const SendCustomTx = React.lazy(() => import('./screens/SendCustomTx'))
const ReviewCustomTx = React.lazy(() => import('./screens/ReviewCustomTx'))
type ActiveScreen = 'chooseTxType' | 'sendFunds' | 'reviewTx' | 'sendCustomTx' | 'reviewCustomTx'
type ActiveScreen =
| 'chooseTxType'
| 'sendFunds'
| 'reviewTx'
| 'sendCustomTx'
| 'reviewCustomTx'
| 'sendCollectible'
| 'reviewCollectible'
type Props = {
onClose: () => void,
classes: Object,
isOpen: boolean,
safeAddress: string,
safeName: string,
ethBalance: string,
tokens: List<Token>,
selectedToken: string,
createTransaction: Function,
selectedToken?: string | NFTToken | {},
createTransaction?: Function,
activeScreenType: ActiveScreen,
recipientAddress?: string,
}
@ -43,7 +54,7 @@ type TxStateType =
}
| Object
const styles = () => ({
const useStyles = makeStyles({
scalableModalWindow: {
height: 'auto',
},
@ -51,19 +62,17 @@ const styles = () => ({
height: 'auto',
position: 'static',
},
loaderStyle: {
height: '500px',
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
})
const loaderStyle = {
height: '500px',
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}
const Send = ({
const SendModal = ({
activeScreenType,
classes,
createTransaction,
ethBalance,
isOpen,
@ -74,6 +83,7 @@ const Send = ({
selectedToken,
tokens,
}: Props) => {
const classes = useStyles()
const [activeScreen, setActiveScreen] = useState<ActiveScreen>(activeScreenType || 'chooseTxType')
const [tx, setTx] = useState<TxStateType>({})
@ -94,6 +104,11 @@ const Send = ({
setTx(customTxInfo)
}
const handleSendCollectible = txInfo => {
setActiveScreen('reviewCollectible')
setTx(txInfo)
}
return (
<Modal
description="Send Tokens Form"
@ -104,12 +119,14 @@ const Send = ({
>
<Suspense
fallback={
<div style={loaderStyle}>
<div className={classes.loaderStyle}>
<CircularProgress size={40} />
</div>
}
>
{activeScreen === 'chooseTxType' && <ChooseTxType onClose={onClose} setActiveScreen={setActiveScreen} />}
{activeScreen === 'chooseTxType' && (
<ChooseTxType onClose={onClose} recipientAddress={recipientAddress} setActiveScreen={setActiveScreen} />
)}
{activeScreen === 'sendFunds' && (
<SendFunds
ethBalance={ethBalance}
@ -141,6 +158,7 @@ const Send = ({
initialValues={tx}
onClose={onClose}
onSubmit={handleCustomTxCreation}
recipientAddress={recipientAddress}
safeAddress={safeAddress}
safeName={safeName}
/>
@ -156,9 +174,21 @@ const Send = ({
tx={tx}
/>
)}
{activeScreen === 'sendCollectible' && (
<SendCollectible
initialValues={tx}
onClose={onClose}
onNext={handleSendCollectible}
recipientAddress={recipientAddress}
selectedToken={selectedToken}
/>
)}
{activeScreen === 'reviewCollectible' && (
<ReviewCollectible onClose={onClose} onPrev={() => setActiveScreen('sendCollectible')} tx={tx} />
)}
</Suspense>
</Modal>
)
}
export default withStyles(styles)(Send)
export default SendModal

View File

@ -1,22 +1,26 @@
// @flow
import IconButton from '@material-ui/core/IconButton'
import { withStyles } from '@material-ui/core/styles'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import classNames from 'classnames/bind'
import * as React from 'react'
import { useSelector } from 'react-redux'
import Code from '../assets/code.svg'
import Collectible from '../assets/collectibles.svg'
import Token from '../assets/token.svg'
import { mustBeEthereumContractAddress } from '~/components/forms/validator'
import Button from '~/components/layout/Button'
import Col from '~/components/layout/Col'
import Hairline from '~/components/layout/Hairline'
import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import { safeSelector } from '~/routes/safe/store/selectors'
import { lg, md, sm } from '~/theme/variables'
const styles = () => ({
const useStyles = makeStyles({
heading: {
padding: `${md} ${lg}`,
justifyContent: 'space-between',
@ -26,6 +30,14 @@ const styles = () => ({
manage: {
fontSize: lg,
},
disclaimer: {
marginBottom: `-${md}`,
paddingTop: md,
textAlign: 'center',
},
disclaimerText: {
fontSize: md,
},
closeIcon: {
height: '35px',
width: '35px',
@ -51,47 +63,96 @@ const styles = () => ({
type Props = {
onClose: () => void,
classes: Object,
recipientAddress?: string,
setActiveScreen: Function,
}
const ChooseTxType = ({ classes, onClose, setActiveScreen }: Props) => (
<>
<Row align="center" className={classes.heading} grow>
<Paragraph className={classes.manage} noMargin weight="bolder">
Send
</Paragraph>
<IconButton disableRipple onClick={onClose}>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<Row align="center">
<Col className={classes.buttonColumn} layout="column" middle="xs">
<Button
className={classes.firstButton}
color="primary"
minHeight={52}
minWidth={260}
onClick={() => setActiveScreen('sendFunds')}
variant="contained"
>
<Img alt="Send funds" className={classNames(classes.leftIcon, classes.iconSmall)} src={Token} />
Send funds
</Button>
<Button
color="primary"
minHeight={52}
minWidth={260}
onClick={() => setActiveScreen('sendCustomTx')}
variant="outlined"
>
<Img alt="Send custom transaction" className={classNames(classes.leftIcon, classes.iconSmall)} src={Code} />
Send custom transaction
</Button>
</Col>
</Row>
</>
)
const ChooseTxType = ({ onClose, recipientAddress, setActiveScreen }: Props) => {
const classes = useStyles()
const { featuresEnabled } = useSelector(safeSelector)
const erc721Enabled = featuresEnabled.includes('ERC721')
const [disableCustomTx, setDisableCustomTx] = React.useState(!!recipientAddress)
export default withStyles(styles)(ChooseTxType)
React.useEffect(() => {
let isCurrent = true
const isContract = async () => {
if (recipientAddress && isCurrent) {
setDisableCustomTx(!!(await mustBeEthereumContractAddress(recipientAddress)))
}
}
isContract()
return () => {
isCurrent = false
}
}, [recipientAddress])
return (
<>
<Row align="center" className={classes.heading} grow>
<Paragraph className={classes.manage} noMargin weight="bolder">
Send
</Paragraph>
<IconButton disableRipple onClick={onClose}>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
{!!recipientAddress && (
<Row align="center">
<Col className={classes.disclaimer} layout="column" middle="xs">
<Paragraph className={classes.disclaimerText} noMargin>
Please select what you will send to {recipientAddress}
</Paragraph>
</Col>
</Row>
)}
<Row align="center">
<Col className={classes.buttonColumn} layout="column" middle="xs">
<Button
className={classes.firstButton}
color="primary"
minHeight={52}
minWidth={260}
onClick={() => setActiveScreen('sendFunds')}
variant="contained"
>
<Img alt="Send funds" className={classNames(classes.leftIcon, classes.iconSmall)} src={Token} />
Send funds
</Button>
{erc721Enabled && (
<Button
className={classes.firstButton}
color="primary"
minHeight={52}
minWidth={260}
onClick={() => setActiveScreen('sendCollectible')}
variant="contained"
>
<Img
alt="Send collectible"
className={classNames(classes.leftIcon, classes.iconSmall)}
src={Collectible}
/>
Send collectible
</Button>
)}
<Button
color="primary"
disabled={disableCustomTx}
minHeight={52}
minWidth={260}
onClick={() => setActiveScreen('sendCustomTx')}
variant="outlined"
>
<Img alt="Send custom transaction" className={classNames(classes.leftIcon, classes.iconSmall)} src={Code} />
Send custom transaction
</Button>
</Col>
</Row>
</>
)
}
export default ChooseTxType

View File

@ -0,0 +1,199 @@
// @flow
import IconButton from '@material-ui/core/IconButton'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import { List } from 'immutable'
import { withSnackbar } 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 '~/components/CopyBtn'
import EtherscanBtn from '~/components/EtherscanBtn'
import Identicon from '~/components/Identicon'
import Block from '~/components/layout/Block'
import Button from '~/components/layout/Button'
import Col from '~/components/layout/Col'
import Hairline from '~/components/layout/Hairline'
import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import type { NFTTokensState } from '~/logic/collectibles/store/reducer/collectibles'
import { nftTokensSelector } from '~/logic/collectibles/store/selectors'
import { TX_NOTIFICATION_TYPES } from '~/logic/safe/transactions'
import { estimateTxGasCosts } from '~/logic/safe/transactions/gasNew'
import {
containsMethodByHash,
getERC721TokenContract,
getHumanFriendlyToken,
} from '~/logic/tokens/store/actions/fetchTokens'
import { type Token } from '~/logic/tokens/store/model/token'
import { formatAmount } from '~/logic/tokens/utils/formatAmount'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo'
import { setImageToPlaceholder } from '~/routes/safe/components/Balances/utils'
import createTransaction from '~/routes/safe/store/actions/createTransaction'
import { safeSelector } from '~/routes/safe/store/selectors'
import { sm } from '~/theme/variables'
import { textShortener } from '~/utils/strings'
const useStyles = makeStyles(styles)
const SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH = '0x42842e0e'
type Props = {
onClose: () => void,
onPrev: () => void,
classes: Object,
tx: Object,
tokens: List<Token>,
createTransaction: Function,
enqueueSnackbar: Function,
closeSnackbar: Function,
}
const ReviewCollectible = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx }: Props) => {
const classes = useStyles()
const shortener = textShortener()
const dispatch = useDispatch()
const { address: safeAddress, ethBalance, name: safeName } = useSelector(safeSelector)
const nftTokens: NFTTokensState = useSelector(nftTokensSelector)
const [gasCosts, setGasCosts] = useState<string>('< 0.001')
const txToken = nftTokens.find(
({ assetAddress, tokenId }) => assetAddress === tx.assetAddress && tokenId === tx.nftTokenId,
)
const [data, setData] = useState('')
useEffect(() => {
let isCurrent = true
const estimateGas = async () => {
const { fromWei, toBN } = getWeb3().utils
const supportsSafeTransfer = await containsMethodByHash(tx.assetAddress, SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH)
const methodToCall = supportsSafeTransfer ? SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH : 'transfer'
const transferParams = [tx.recipientAddress, tx.nftTokenId]
const params = methodToCall === 'transfer' ? transferParams : [safeAddress, ...transferParams]
const ERC721Token = methodToCall === 'transfer' ? await getHumanFriendlyToken() : await getERC721TokenContract()
const tokenInstance = await ERC721Token.at(tx.assetAddress)
const txData = tokenInstance.contract.methods[methodToCall](...params).encodeABI()
const estimatedGasCosts = await estimateTxGasCosts(safeAddress, tx.recipientAddress, txData)
const gasCostsAsEth = fromWei(toBN(estimatedGasCosts), 'ether')
const formattedGasCosts = formatAmount(gasCostsAsEth)
if (isCurrent) {
setGasCosts(formattedGasCosts)
setData(txData)
}
}
estimateGas()
return () => {
isCurrent = false
}
}, [])
const submitTx = async () => {
dispatch(
createTransaction({
safeAddress,
to: tx.assetAddress,
valueInWei: '0',
txData: data,
notifiedTransaction: TX_NOTIFICATION_TYPES.STANDARD_TX,
enqueueSnackbar,
closeSnackbar,
}),
)
onClose()
}
return (
<>
<Row align="center" className={classes.heading} grow>
<Paragraph className={classes.headingText} noMargin weight="bolder">
Send Funds
</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 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">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Recipient
</Paragraph>
</Row>
<Row align="center" margin="md">
<Col xs={1}>
<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 margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
{textShortener({ charsStart: 40, charsEnd: 0 })(tx.assetName)}
</Paragraph>
</Row>
{txToken && (
<Row align="center" margin="md">
<Img alt={txToken.name} height={28} onError={setImageToPlaceholder} src={txToken.image} />
<Paragraph className={classes.amount} noMargin size="md">
{shortener(txToken.name)} (Token ID: {shortener(txToken.tokenId)})
</Paragraph>
</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 style={{ position: 'absolute', bottom: 85 }} />
<Row align="center" className={classes.buttonRow}>
<Button minWidth={140} onClick={onPrev}>
Back
</Button>
<Button
className={classes.submitButton}
color="primary"
data-testid="submit-tx-btn"
disabled={!data}
minWidth={140}
onClick={submitTx}
type="submit"
variant="contained"
>
Submit
</Button>
</Row>
</>
)
}
export default withSnackbar(ReviewCollectible)

View File

@ -0,0 +1,45 @@
// @flow
import { lg, md, secondaryText, sm } from '~/theme/variables'
export const styles = () => ({
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}`,
},
amount: {
marginLeft: sm,
},
address: {
marginRight: sm,
},
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,85 @@
// @flow
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText'
import MenuItem from '@material-ui/core/MenuItem'
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import { selectStyles, selectedTokenStyles } from './style'
import Field from '~/components/forms/Field'
import SelectField from '~/components/forms/SelectField'
import { required } from '~/components/forms/validator'
import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph'
import type { NFTTokensState } from '~/logic/collectibles/store/reducer/collectibles'
import { setImageToPlaceholder } from '~/routes/safe/components/Balances/utils'
import { textShortener } from '~/utils/strings'
type SelectedCollectibleProps = {
tokens: NFTTokensState,
tokenId: string | number,
}
const useSelectedCollectibleStyles = makeStyles(selectedTokenStyles)
const SelectedCollectible = ({ tokenId, tokens }: SelectedCollectibleProps) => {
const classes = useSelectedCollectibleStyles()
const token = tokenId && tokens ? tokens.find(({ tokenId: id }) => tokenId === id) : null
const shortener = textShortener({ charsStart: 40, charsEnd: 0 })
return (
<MenuItem className={classes.container}>
{token ? (
<>
<ListItemIcon className={classes.tokenImage}>
<Img alt={token.description} height={28} onError={setImageToPlaceholder} src={token.image} />
</ListItemIcon>
<ListItemText
className={classes.tokenData}
primary={shortener(token.name)}
secondary={`token ID: ${shortener(token.tokenId)}`}
/>
</>
) : (
<Paragraph color="disabled" size="md" style={{ opacity: 0.5 }} weight="light">
Select a token*
</Paragraph>
)}
</MenuItem>
)
}
type SelectFieldProps = {
initialValue: string,
tokens: NFTTokensState,
}
const useCollectibleSelectFieldStyles = makeStyles(selectStyles)
const CollectibleSelectField = ({ initialValue, tokens }: SelectFieldProps) => {
const classes = useCollectibleSelectFieldStyles()
return (
<Field
className={classes.selectMenu}
component={SelectField}
disabled={!tokens.length}
initialValue={initialValue}
name="nftTokenId"
renderValue={nftTokenId => <SelectedCollectible tokenId={nftTokenId} tokens={tokens} />}
validate={required}
>
{tokens.map(token => (
<MenuItem key={`${token.assetAddress}-${token.tokenId}`} value={token.tokenId}>
<ListItemIcon className={classes.tokenImage}>
<Img alt={token.name} height={28} onError={setImageToPlaceholder} src={token.image} />
</ListItemIcon>
<ListItemText primary={token.name} secondary={`token ID: ${token.tokenId}`} />
</MenuItem>
))}
</Field>
)
}
export default CollectibleSelectField

View File

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

View File

@ -0,0 +1,94 @@
// @flow
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText'
import MenuItem from '@material-ui/core/MenuItem'
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import { selectStyles, selectedTokenStyles } from './style'
import Field from '~/components/forms/Field'
import SelectField from '~/components/forms/SelectField'
import { required } from '~/components/forms/validator'
import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph'
import type { NFTAssetsState } from '~/logic/collectibles/store/reducer/collectibles'
import { formatAmount } from '~/logic/tokens/utils/formatAmount'
import { setImageToPlaceholder } from '~/routes/safe/components/Balances/utils'
import { textShortener } from '~/utils/strings'
type SelectedTokenProps = {
assetAddress?: string,
assets: NFTAssetsState,
}
const useSelectedTokenStyles = makeStyles(selectedTokenStyles)
const SelectedToken = ({ assetAddress, assets }: SelectedTokenProps) => {
const classes = useSelectedTokenStyles()
const asset = assetAddress ? assets[assetAddress] : null
const shortener = textShortener({ charsStart: 40, charsEnd: 0 })
return (
<MenuItem className={classes.container}>
{asset && asset.numberOfTokens ? (
<>
<ListItemIcon className={classes.tokenImage}>
<Img alt={asset.name} height={28} onError={setImageToPlaceholder} src={asset.image} />
</ListItemIcon>
<ListItemText
className={classes.tokenData}
primary={shortener(asset.name)}
secondary={`${formatAmount(asset.numberOfTokens)} ${asset.symbol}`}
/>
</>
) : (
<Paragraph color="disabled" size="md" style={{ opacity: 0.5 }} weight="light">
Select an asset*
</Paragraph>
)}
</MenuItem>
)
}
type SelectFieldProps = {
assets: NFTAssetsState,
initialValue: ?string,
}
const useTokenSelectFieldStyles = makeStyles(selectStyles)
const TokenSelectField = ({ assets, initialValue }: SelectFieldProps) => {
const classes = useTokenSelectFieldStyles()
const assetsAddresses = Object.keys(assets)
return (
<Field
className={classes.selectMenu}
component={SelectField}
disabled={!assetsAddresses.length}
initialValue={initialValue}
name="assetAddress"
renderValue={assetAddress => <SelectedToken assetAddress={assetAddress} assets={assets} />}
validate={required}
>
{assetsAddresses.map(assetAddress => {
const asset = assets[assetAddress]
return (
<MenuItem key={asset.slug} value={assetAddress}>
<ListItemIcon className={classes.tokenImage}>
<Img alt={asset.name} height={28} onError={setImageToPlaceholder} src={asset.image} />
</ListItemIcon>
<ListItemText
primary={asset.name}
secondary={`Count: ${formatAmount(asset.numberOfTokens)} ${asset.symbol}`}
/>
</MenuItem>
)
})}
</Field>
)
}
export default TokenSelectField

View File

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

View File

@ -0,0 +1,268 @@
// @flow
import IconButton from '@material-ui/core/IconButton'
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 '~/assets/icons/qrcode.svg'
import CopyBtn from '~/components/CopyBtn'
import EtherscanBtn from '~/components/EtherscanBtn'
import Identicon from '~/components/Identicon'
import ScanQRModal from '~/components/ScanQRModal'
import WhenFieldChanges from '~/components/WhenFieldChanges'
import GnoForm from '~/components/forms/GnoForm'
import Block from '~/components/layout/Block'
import Button from '~/components/layout/Button'
import Col from '~/components/layout/Col'
import Hairline from '~/components/layout/Hairline'
import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import type { NFTAssetsState, NFTTokensState } from '~/logic/collectibles/store/reducer/collectibles'
import { nftAssetsSelector, nftTokensSelector } from '~/logic/collectibles/store/selectors'
import type { NFTToken } from '~/routes/safe/components/Balances/Collectibles/types'
import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo'
import AddressBookInput from '~/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
import CollectibleSelectField from '~/routes/safe/components/Balances/SendModal/screens/SendCollectible/CollectibleSelectField'
import TokenSelectField from '~/routes/safe/components/Balances/SendModal/screens/SendCollectible/TokenSelectField'
import { safeSelector } from '~/routes/safe/store/selectors'
import { sm } from '~/theme/variables'
type Props = {
initialValues: Object,
onClose: () => void,
onNext: any => void,
recipientAddress?: string,
selectedToken?: NFTToken | {},
}
const formMutators = {
setMax: (args, state, utils) => {
utils.changeValue(state, 'amount', () => args[0])
},
onTokenChange: (args, state, utils) => {
utils.changeValue(state, 'amount', () => '')
},
setRecipient: (args, state, utils) => {
utils.changeValue(state, 'recipientAddress', () => args[0])
},
}
const useStyles = makeStyles(styles)
const SendCollectible = ({ initialValues, onClose, onNext, recipientAddress, selectedToken = {} }: Props) => {
const classes = useStyles()
const { address: safeAddress, ethBalance, name: safeName } = useSelector(safeSelector)
const nftAssets: NFTAssetsState = useSelector(nftAssetsSelector)
const nftTokens: NFTTokensState = useSelector(nftTokensSelector)
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false)
const [selectedEntry, setSelectedEntry] = useState<Object | null>({
address: recipientAddress || initialValues.recipientAddress,
name: '',
})
const [pristine, setPristine] = useState<boolean>(true)
const [isValidAddress, setIsValidAddress] = useState<boolean>(true)
React.useMemo(() => {
if (selectedEntry === null && pristine) {
setPristine(false)
}
}, [selectedEntry, pristine])
const handleSubmit = values => {
// If the input wasn't modified, there was no mutation of the recipientAddress
if (!values.recipientAddress) {
values.recipientAddress = selectedEntry.address
}
values.assetName = nftAssets[values.assetAddress].name
onNext(values)
}
const openQrModal = () => {
setQrModalOpen(true)
}
const closeQrModal = () => {
setQrModalOpen(false)
}
return (
<>
<Row align="center" className={classes.heading} grow>
<Paragraph className={classes.manage} noMargin weight="bolder">
Send Collectible
</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 formState = args[2]
const mutators = args[3]
const { assetAddress } = formState.values
const selectedNFTTokens = nftTokens.filter(nftToken => nftToken.assetAddress === assetAddress)
const handleScan = value => {
let scannedAddress = value
if (scannedAddress.startsWith('ethereum:')) {
scannedAddress = scannedAddress.replace('ethereum:', '')
}
mutators.setRecipient(scannedAddress)
closeQrModal()
}
let shouldDisableSubmitButton = !isValidAddress
if (selectedEntry) {
shouldDisableSubmitButton = !selectedEntry.address
}
return (
<>
<WhenFieldChanges field="assetAddress" set="nftTokenId" to={''} />
<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}
pristine={pristine}
recipientAddress={recipientAddress}
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' }}>
Collectible
</Paragraph>
</Col>
</Row>
<Row margin="sm">
<Col>
<TokenSelectField assets={nftAssets} initialValue={selectedToken.assetAddress} />
</Col>
</Row>
<Row margin="xs">
<Col between="lg">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Token ID
</Paragraph>
</Col>
</Row>
<Row margin="md">
<Col>
<CollectibleSelectField initialValue={selectedToken.tokenId} tokens={selectedNFTTokens} />
</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>
{qrModalOpen && <ScanQRModal isOpen={qrModalOpen} onClose={closeQrModal} onScan={handleScan} />}
</>
)
}}
</GnoForm>
</>
)
}
export default SendCollectible

View File

@ -0,0 +1,45 @@
// @flow
import { lg, md, secondaryText } from '~/theme/variables'
export const styles = () => ({
heading: {
padding: `${md} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: secondaryText,
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',
},
selectAddress: {
cursor: 'pointer',
},
})

View File

@ -34,6 +34,7 @@ import { sm } from '~/theme/variables'
type Props = {
onClose: () => void,
classes: Object,
recipientAddress: string,
safeAddress: string,
safeName: string,
ethBalance: string,
@ -41,10 +42,19 @@ type Props = {
initialValues: Object,
}
const SendCustomTx = ({ classes, ethBalance, initialValues, onClose, onSubmit, safeAddress, safeName }: Props) => {
const SendCustomTx = ({
classes,
ethBalance,
initialValues,
onClose,
onSubmit,
recipientAddress,
safeAddress,
safeName,
}: Props) => {
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false)
const [selectedEntry, setSelectedEntry] = useState<Object | null>({
address: '',
address: recipientAddress || initialValues.recipientAddress,
name: '',
})
const [pristine, setPristine] = useState<boolean>(true)

View File

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<defs>
<path id="prefix__a" d="M0 0L23.984 0 23.984 22.002 0 22.002z"/>
</defs>
<g fill="none" fill-rule="evenodd">
<path d="M0 0H24V24H0z"/>
<g transform="translate(0 1)">
<mask id="prefix__b" fill="#fff">
<use xlink:href="#prefix__a"/>
</mask>
<path fill="#FFF" d="M11.992 19.384L3.05 8.003h17.884l-8.942 11.38zM5.999 2h12.4l2.709 4.003H2.993L5.999 2zm17.974 5.16c.007-.042.009-.085.01-.128.002-.047.002-.093-.004-.141-.005-.043-.013-.084-.024-.127-.011-.046-.025-.092-.044-.137-.008-.02-.01-.042-.02-.061-.01-.022-.027-.038-.04-.06-.011-.02-.017-.043-.031-.063L19.759.44c-.186-.275-.496-.44-.83-.44H5.5c-.315 0-.611.149-.8.4L.192 6.403c-.02.026-.03.056-.047.085-.016.027-.04.048-.052.078-.006.013-.008.028-.014.042-.02.045-.031.09-.045.138-.011.042-.023.084-.028.127-.007.047-.005.094-.005.142 0 .044 0 .088.006.13.007.048.02.092.035.137.012.044.023.087.043.128.005.014.006.028.012.04.017.033.04.058.06.088.018.028.028.058.049.083l11 13.999c.189.242.479.382.786.382.307 0 .597-.14.786-.382l11-14c.019-.022.027-.05.044-.075.017-.025.038-.046.053-.073.009-.016.01-.033.018-.05.019-.041.034-.083.046-.126.015-.045.027-.09.034-.136z" mask="url(#prefix__b)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@ -11,7 +11,6 @@ import * as React from 'react'
import AssetTableCell from './AssetTableCell'
import Receive from './Receive'
import SendModal from './SendModal'
import Tokens from './Tokens'
import { BALANCE_TABLE_ASSET_ID, type BalanceRow, generateColumns, getBalanceData } from './dataFetcher'
import { styles } from './style'
@ -22,36 +21,47 @@ import { type Column, cellWidth } from '~/components/Table/TableHead'
import Button from '~/components/layout/Button'
import ButtonLink from '~/components/layout/ButtonLink'
import Col from '~/components/layout/Col'
import Divider from '~/components/layout/Divider'
import Link from '~/components/layout/Link'
import Row from '~/components/layout/Row'
import type { BalanceCurrencyType } from '~/logic/currencyValues/store/model/currencyValues'
import { type Token } from '~/logic/tokens/store/model/token'
import { SAFELIST_ADDRESS } from '~/routes/routes'
import Collectibles from '~/routes/safe/components/Balances/Collectibles'
import SendModal from '~/routes/safe/components/Balances/SendModal'
import { BALANCE_TABLE_BALANCE_ID, BALANCE_TABLE_VALUE_ID } from '~/routes/safe/components/Balances/dataFetcher'
import DropdownCurrency from '~/routes/safe/components/DropdownCurrency'
import { history } from '~/store'
export const MANAGE_TOKENS_BUTTON_TEST_ID = 'manage-tokens-btn'
export const BALANCE_ROW_TEST_ID = 'balance-row'
type State = {
showToken: boolean,
showReceive: boolean,
erc721Enabled: boolean,
subMenuOptions: string[],
sendFunds: Object,
showCoins: boolean,
showCollectibles: boolean,
showReceive: boolean,
showToken: boolean,
}
type Props = {
classes: Object,
granted: boolean,
tokens: List<Token>,
activateTokensByBalance: Function,
activeTokens: List<Token>,
blacklistedTokens: List<Token>,
activateTokensByBalance: Function,
fetchTokens: Function,
safeAddress: string,
safeName: string,
ethBalance: string,
classes: Object,
createTransaction: Function,
currencySelected: string,
fetchCurrencyValues: Function,
currencyValues: BalanceCurrencyType[],
ethBalance: string,
featuresEnabled: string[],
fetchCurrencyValues: Function,
fetchTokens: Function,
granted: boolean,
safeAddress: string,
safeName: string,
tokens: List<Token>,
}
type Action = 'Token' | 'Send' | 'Receive'
@ -60,20 +70,58 @@ class Balances extends React.Component<Props, State> {
constructor(props) {
super(props)
this.state = {
erc721Enabled: false,
subMenuOptions: [],
showToken: false,
sendFunds: {
isOpen: false,
selectedToken: undefined,
},
showCoins: true,
showCollectibles: false,
showReceive: false,
}
props.fetchTokens()
}
static isCoinsLocation = /\/balances\/?$/
static isCollectiblesLocation = /\/balances\/collectibles$/
componentDidMount(): void {
const { activateTokensByBalance, fetchCurrencyValues, safeAddress } = this.props
fetchCurrencyValues(safeAddress)
activateTokensByBalance(safeAddress)
const showCollectibles = Balances.isCollectiblesLocation.test(history.location.pathname)
const showCoins = Balances.isCoinsLocation.test(history.location.pathname)
if (!showCollectibles && !showCoins) {
history.replace(`${SAFELIST_ADDRESS}/${this.props.safeAddress}/balances`)
}
const subMenuOptions = [
{ enabled: showCoins, legend: 'Coins', url: `${SAFELIST_ADDRESS}/${this.props.safeAddress}/balances` },
]
const erc721Enabled = this.props.featuresEnabled.includes('ERC721')
if (erc721Enabled) {
subMenuOptions.push({
enabled: showCollectibles,
legend: 'Collectibles',
url: `${SAFELIST_ADDRESS}/${this.props.safeAddress}/balances/collectibles`,
})
} else {
if (showCollectibles) {
history.replace(subMenuOptions[0].url)
}
}
this.setState({
showCoins,
showCollectibles,
erc721Enabled,
subMenuOptions,
})
}
onShow = (action: Action) => () => {
@ -103,7 +151,7 @@ class Balances extends React.Component<Props, State> {
}
render() {
const { sendFunds, showReceive, showToken } = this.state
const { erc721Enabled, sendFunds, showCoins, showCollectibles, showReceive, showToken, subMenuOptions } = this.state
const {
activeTokens,
blacklistedTokens,
@ -125,106 +173,135 @@ class Balances extends React.Component<Props, State> {
return (
<>
<Row align="center" className={classes.message}>
<Col end="sm" xs={12}>
<DropdownCurrency />
<ButtonLink onClick={this.onShow('Token')} size="lg" testId="manage-tokens-btn">
Manage List
</ButtonLink>
<Modal
description="Enable and disable tokens to be listed"
handleClose={this.onHide('Token')}
open={showToken}
title="Manage List"
>
<Tokens
activeTokens={activeTokens}
blacklistedTokens={blacklistedTokens}
onClose={this.onHide('Token')}
safeAddress={safeAddress}
tokens={tokens}
/>
</Modal>
<Row align="center" className={classes.controls}>
<Col className={classes.assetTabs} sm={6} start="sm" xs={12}>
{subMenuOptions.length > 1 &&
subMenuOptions.map(({ enabled, legend, url }, index) => (
<React.Fragment key={`legend-${index}`}>
{index > 0 && <Divider className={classes.assetDivider} />}
<Link
className={enabled ? classes.assetTabActive : classes.assetTab}
data-testid={`${legend.toLowerCase()}'-assets-btn'`}
size="md"
to={url}
weight={enabled ? 'bold' : 'regular'}
>
{legend}
</Link>
</React.Fragment>
))}
</Col>
<Col className={classes.tokenControls} end="sm" sm={6} xs={12}>
{showCoins && (
<>
<DropdownCurrency />
<ButtonLink
className={classes.manageTokensButton}
onClick={this.onShow('Token')}
size="lg"
testId="manage-tokens-btn"
>
Manage List
</ButtonLink>
<Modal
description="Enable and disable tokens to be listed"
handleClose={this.onHide('Token')}
open={showToken}
title="Manage List"
>
<Tokens
activeTokens={activeTokens}
blacklistedTokens={blacklistedTokens}
onClose={this.onHide('Token')}
safeAddress={safeAddress}
tokens={tokens}
/>
</Modal>
</>
)}
</Col>
</Row>
<TableContainer>
<Table
columns={columns}
data={filteredData}
defaultFixed
defaultOrderBy={BALANCE_TABLE_ASSET_ID}
defaultRowsPerPage={10}
label="Balances"
size={filteredData.size}
>
{(sortedData: Array<BalanceRow>) =>
sortedData.map((row: any, index: number) => (
<TableRow className={classes.hide} data-testid={BALANCE_ROW_TEST_ID} key={index} tabIndex={-1}>
{autoColumns.map((column: Column) => {
const { align, id, width } = column
let cellItem
switch (id) {
case BALANCE_TABLE_ASSET_ID: {
cellItem = <AssetTableCell asset={row[id]} />
break
{showCoins && (
<TableContainer>
<Table
columns={columns}
data={filteredData}
defaultFixed
defaultOrderBy={BALANCE_TABLE_ASSET_ID}
defaultRowsPerPage={10}
label="Balances"
size={filteredData.size}
>
{(sortedData: Array<BalanceRow>) =>
sortedData.map((row: any, index: number) => (
<TableRow className={classes.hide} data-testid={BALANCE_ROW_TEST_ID} key={index} tabIndex={-1}>
{autoColumns.map((column: Column) => {
const { align, id, width } = column
let cellItem
switch (id) {
case BALANCE_TABLE_ASSET_ID: {
cellItem = <AssetTableCell asset={row[id]} />
break
}
case BALANCE_TABLE_BALANCE_ID: {
cellItem = <div>{row[id]}</div>
break
}
case BALANCE_TABLE_VALUE_ID: {
cellItem = <div className={classes.currencyValueRow}>{row[id]}</div>
break
}
default: {
cellItem = null
break
}
}
case BALANCE_TABLE_BALANCE_ID: {
cellItem = <div>{row[id]}</div>
break
}
case BALANCE_TABLE_VALUE_ID: {
cellItem = <div className={classes.currencyValueRow}>{row[id]}</div>
break
}
default: {
cellItem = null
break
}
}
return (
<TableCell align={align} component="td" key={id} style={cellWidth(width)}>
{cellItem}
</TableCell>
)
})}
<TableCell component="td">
<Row align="end" className={classes.actions}>
{granted && (
return (
<TableCell align={align} component="td" key={id} style={cellWidth(width)}>
{cellItem}
</TableCell>
)
})}
<TableCell component="td">
<Row align="end" className={classes.actions}>
{granted && (
<Button
className={classes.send}
color="primary"
onClick={() => this.showSendFunds(row.asset.address)}
size="small"
testId="balance-send-btn"
variant="contained"
>
<CallMade
alt="Send Transaction"
className={classNames(classes.leftIcon, classes.iconSmall)}
/>
Send
</Button>
)}
<Button
className={classes.send}
className={classes.receive}
color="primary"
onClick={() => this.showSendFunds(row.asset.address)}
onClick={this.onShow('Receive')}
size="small"
testId="balance-send-btn"
variant="contained"
>
<CallMade
alt="Send Transaction"
<CallReceived
alt="Receive Transaction"
className={classNames(classes.leftIcon, classes.iconSmall)}
/>
Send
Receive
</Button>
)}
<Button
className={classes.receive}
color="primary"
onClick={this.onShow('Receive')}
size="small"
variant="contained"
>
<CallReceived
alt="Receive Transaction"
className={classNames(classes.leftIcon, classes.iconSmall)}
/>
Receive
</Button>
</Row>
</TableCell>
</TableRow>
))
}
</Table>
</TableContainer>
</Row>
</TableCell>
</TableRow>
))
}
</Table>
</TableContainer>
)}
{erc721Enabled && showCollectibles && <Collectibles />}
<SendModal
activeScreenType="sendFunds"
createTransaction={createTransaction}

View File

@ -1,17 +1,66 @@
// @flow
import { md, sm, xs } from '~/theme/variables'
import { md, screenSm, secondary, sm, xs } from '~/theme/variables'
export const styles = (theme: Object) => ({
root: {
width: '20px',
marginRight: sm,
width: '20px',
},
message: {
margin: `${sm} 0`,
padding: `${md} 0`,
maxHeight: '54px',
controls: {
alignItems: 'center',
boxSizing: 'border-box',
justifyContent: 'flex-end',
justifyContent: 'space-between',
padding: `${md} 0`,
},
assetTabs: {
alignItems: 'center',
display: 'flex',
order: '2',
[`@media (min-width: ${screenSm}px)`]: {
order: '1',
},
},
assetDivider: {
borderRightColor: `${secondary} !important`,
height: '18px !important',
},
assetTab: {
color: '#686868',
margin: '2px 0',
padding: '0 10px',
textDecoration: 'underline',
'&:hover': {
cursor: 'pointer',
},
},
assetTabActive: {
color: secondary,
fontWeight: 'bold',
margin: '2px 0',
padding: '0 10px',
textDecoration: 'none',
},
tokenControls: {
alignItems: 'center',
display: 'flex',
justifyContent: 'space-between',
order: '1',
padding: '0 0 10px',
[`@media (min-width: ${screenSm}px)`]: {
justifyContent: 'flex-end',
order: '2',
padding: '0',
},
},
manageTokensButton: {
marginLeft: 'auto',
[`@media (min-width: ${screenSm}px)`]: {
marginLeft: '0',
},
},
actionIcon: {
marginRight: theme.spacing(1),

View File

@ -13,7 +13,6 @@ import { type Actions } from '../container/actions'
import Balances from './Balances'
import Receive from './Balances/Receive'
import SendModal from './Balances/SendModal'
import Settings from './Settings'
import Transactions from './Transactions'
import { AddressBookIcon } from './assets/AddressBookIcon'
@ -35,9 +34,9 @@ import Hairline from '~/components/layout/Hairline'
import Heading from '~/components/layout/Heading'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import { getSafeVersion } from '~/logic/safe/utils/safeVersion'
import { getEtherScanLink, getWeb3 } from '~/logic/wallets/getWeb3'
import AddressBookTable from '~/routes/safe/components/AddressBook'
import SendModal from '~/routes/safe/components/Balances/SendModal'
import { type SelectorProps } from '~/routes/safe/container/selector'
import { border } from '~/theme/variables'
@ -107,23 +106,6 @@ const Layout = (props: Props) => {
onClose: null,
})
const [needUpdate, setNeedUpdate] = useState(false)
React.useEffect(() => {
const checkUpdateRequirement = async () => {
let safeVersion = {}
try {
safeVersion = await getSafeVersion(safe.address)
} catch (e) {
console.error('failed to check version', e)
}
setNeedUpdate(safeVersion.needUpdate)
}
checkUpdateRequirement()
}, [safe && safe.address])
const handleCallToRouter = (_, value) => {
const { history } = props
@ -134,7 +116,7 @@ const Layout = (props: Props) => {
return <NoSafe provider={provider} text="Safe not found" />
}
const { address, ethBalance, name } = safe
const { address, ethBalance, featuresEnabled, name } = safe
const etherScanLink = getEtherScanLink('address', address)
const web3Instance = getWeb3()
@ -176,7 +158,7 @@ const Layout = (props: Props) => {
<Badge
badgeContent=""
color="error"
invisible={!needUpdate || !granted}
invisible={!safe.needsUpdate || !granted}
style={{ paddingRight: '10px' }}
variant="dot"
>
@ -187,7 +169,7 @@ const Layout = (props: Props) => {
const labelBalances = (
<>
<BalancesIcon />
Balances
Assets
</>
)
const labelTransactions = (
@ -212,6 +194,18 @@ const Layout = (props: Props) => {
</React.Suspense>
)
const tabsValue = () => {
const balanceLocation = `${match.url}/balances`
const isInBalance = new RegExp(`^${balanceLocation}.*$`)
const { pathname } = location
if (isInBalance.test(pathname)) {
return balanceLocation
}
return pathname
}
return (
<>
<Block className={classes.container} margin="xl">
@ -261,7 +255,7 @@ const Layout = (props: Props) => {
indicatorColor="secondary"
onChange={handleCallToRouter}
textColor="secondary"
value={location.pathname}
value={tabsValue()}
variant="scrollable"
>
<Tab
@ -316,7 +310,7 @@ const Layout = (props: Props) => {
<Switch>
<Route
exact
path={`${match.path}/balances`}
path={`${match.path}/balances/:assetType?`}
render={() => (
<Balances
activateTokensByBalance={activateTokensByBalance}
@ -326,6 +320,7 @@ const Layout = (props: Props) => {
currencySelected={currencySelected}
currencyValues={currencyValues}
ethBalance={ethBalance}
featuresEnabled={featuresEnabled}
fetchCurrencyValues={fetchCurrencyValues}
fetchTokens={fetchTokens}
granted={granted}

View File

@ -1,7 +1,7 @@
// @flow
import { makeStyles } from '@material-ui/core/styles'
import { withSnackbar } from 'notistack'
import React, { useEffect, useState } from 'react'
import React from 'react'
import { useSelector } from 'react-redux'
import { styles } from './style'
@ -19,9 +19,9 @@ import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import { getNotificationsFromTxType, showSnackbar } from '~/logic/notifications'
import { TX_NOTIFICATION_TYPES } from '~/logic/safe/transactions'
import { getSafeVersion } from '~/logic/safe/utils/safeVersion'
import UpdateSafeModal from '~/routes/safe/components/Settings/UpdateSafeModal'
import { grantedSelector } from '~/routes/safe/container/selector'
import { latestMasterContractVersionSelector } from '~/routes/safe/store/selectors'
export const SAFE_NAME_INPUT_TEST_ID = 'safe-name-input'
export const SAFE_NAME_SUBMIT_BTN_TEST_ID = 'change-safe-name-btn'
@ -29,7 +29,9 @@ export const SAFE_NAME_UPDATE_SAFE_BTN_TEST_ID = 'update-safe-name-btn'
type Props = {
safeAddress: string,
safeCurrentVersion: string,
safeName: string,
safeNeedsUpdate: boolean,
updateSafe: Function,
enqueueSnackbar: Function,
createTransaction: Function,
@ -40,11 +42,19 @@ const useStyles = makeStyles(styles)
const SafeDetails = (props: Props) => {
const classes = useStyles()
const [safeVersions, setSafeVersions] = React.useState({ current: null, latest: null, needUpdate: false })
const isUserOwner = useSelector(grantedSelector)
const { closeSnackbar, createTransaction, enqueueSnackbar, safeAddress, safeName, updateSafe } = props
const latestMasterContractVersion = useSelector(latestMasterContractVersionSelector)
const {
closeSnackbar,
enqueueSnackbar,
safeAddress,
safeCurrentVersion,
safeName,
safeNeedsUpdate,
updateSafe,
} = props
const [isModalOpen, setModalOpen] = useState(false)
const [isModalOpen, setModalOpen] = React.useState(false)
const toggleModal = () => {
setModalOpen(prevOpen => !prevOpen)
@ -61,19 +71,6 @@ const SafeDetails = (props: Props) => {
setModalOpen(true)
}
useEffect(() => {
const getVersion = async () => {
try {
const { current, latest, needUpdate } = await getSafeVersion(safeAddress)
setSafeVersions({ current, latest, needUpdate })
} catch (err) {
setSafeVersions({ current: 'Version not defined' })
console.error(err)
}
}
getVersion()
}, [])
return (
<>
<GnoForm onSubmit={handleSubmit}>
@ -83,11 +80,11 @@ const SafeDetails = (props: Props) => {
<Heading tag="h2">Safe Version</Heading>
<Row align="end" grow>
<Paragraph className={classes.versionNumber}>
{safeVersions.current}
{safeVersions.needUpdate && ` (there's a newer version: ${safeVersions.latest})`}
{safeCurrentVersion}
{safeNeedsUpdate && ` (there's a newer version: ${latestMasterContractVersion})`}
</Paragraph>
</Row>
{safeVersions.needUpdate && isUserOwner ? (
{safeNeedsUpdate && isUserOwner ? (
<Row align="end" grow>
<Paragraph>
<Button
@ -138,7 +135,7 @@ const SafeDetails = (props: Props) => {
</Col>
</Row>
<Modal description="Update Safe" handleClose={toggleModal} open={isModalOpen} title="Update Safe">
<UpdateSafeModal createTransaction={createTransaction} onClose={toggleModal} safeAddress={safeAddress} />
<UpdateSafeModal onClose={toggleModal} safeAddress={safeAddress} />
</Modal>
</>
)}

View File

@ -3,6 +3,8 @@ import IconButton from '@material-ui/core/IconButton'
import Close from '@material-ui/icons/Close'
import { withStyles } from '@material-ui/styles'
import React from 'react'
import { useDispatch } from 'react-redux'
import { bindActionCreators } from 'redux'
import { styles } from './style'
@ -13,18 +15,19 @@ import Hairline from '~/components/layout/Hairline'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import { upgradeSafeToLatestVersion } from '~/logic/safe/utils/upgradeSafe'
import createTransaction from '~/routes/safe/store/actions/createTransaction'
type Props = {
onClose: Function,
createTransaction: Function,
classes: Object,
safeAddress: string,
}
const UpdateSafeModal = ({ classes, createTransaction, onClose, safeAddress }: Props) => {
const UpdateSafeModal = ({ classes, onClose, safeAddress }: Props) => {
const dispatch = useDispatch()
const handleSubmit = async () => {
// Call the update safe method
await upgradeSafeToLatestVersion(safeAddress, createTransaction)
await upgradeSafeToLatestVersion(safeAddress, bindActionCreators(createTransaction, dispatch))
onClose()
}

View File

@ -26,7 +26,6 @@ import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import Span from '~/components/layout/Span'
import type { AddressBook } from '~/logic/addressBook/model/addressBook'
import { getSafeVersion } from '~/logic/safe/utils/safeVersion'
import { type Owner } from '~/routes/safe/store/models/owner'
import type { Safe } from '~/routes/safe/store/models/safe'
@ -35,7 +34,6 @@ export const OWNERS_SETTINGS_TAB_TEST_ID = 'owner-settings-tab'
type State = {
showRemoveSafe: boolean,
menuOptionIndex: number,
needUpdate: boolean,
}
type Props = Actions & {
@ -68,25 +66,9 @@ class Settings extends React.Component<Props, State> {
this.state = {
showRemoveSafe: false,
menuOptionIndex: 1,
needUpdate: false,
}
}
componentDidMount(): void {
const checkUpdateRequirement = async () => {
let safeVersion = {}
try {
safeVersion = await getSafeVersion(this.props.safe.address)
} catch (e) {
console.error('failed to check version', e)
}
this.setState({ needUpdate: safeVersion.needUpdate })
}
checkUpdateRequirement()
}
handleChange = menuOptionIndex => () => {
this.setState({ menuOptionIndex })
}
@ -148,7 +130,7 @@ class Settings extends React.Component<Props, State> {
<Badge
badgeContent=" "
color="error"
invisible={!this.state.needUpdate || !granted}
invisible={!safe.needsUpdate || !granted}
style={{ paddingRight: '10px' }}
variant="dot"
>
@ -184,7 +166,9 @@ class Settings extends React.Component<Props, State> {
<SafeDetails
createTransaction={createTransaction}
safeAddress={safeAddress}
safeCurrentVersion={safe.currentVersion}
safeName={safeName}
safeNeedsUpdate={safe.needsUpdate}
updateSafe={updateSafe}
/>
)}

View File

@ -21,6 +21,7 @@ type Props = {
const statusToIcon = {
success: OkIcon,
cancelled: ErrorIcon,
failed: ErrorIcon,
awaiting_your_confirmation: AwaitingIcon,
awaiting_confirmations: AwaitingIcon,
awaiting_execution: AwaitingIcon,
@ -30,6 +31,7 @@ const statusToIcon = {
const statusToLabel = {
success: 'Success',
cancelled: 'Cancelled',
failed: 'Failed',
awaiting_your_confirmation: 'Awaiting your confirmation',
awaiting_confirmations: 'Awaiting confirmations',
awaiting_execution: 'Awaiting execution',

View File

@ -23,6 +23,11 @@ export const styles = () => ({
color: error,
border: `1px solid ${error}`,
},
failed: {
backgroundColor: 'transparent',
color: error,
border: `1px solid ${error}`,
},
awaiting_your_confirmation: {
backgroundColor: '#d4d5d3',
color: disabled,

View File

@ -125,7 +125,7 @@ const TxsTable = ({
{autoColumns.map((column: Column) => (
<TableCell
align={column.align}
className={cn(classes.cell, row.status === 'cancelled' && classes.cancelledRow)}
className={cn(classes.cell, ['cancelled', 'failed'].includes(row.status) && classes.cancelledRow)}
component="td"
key={column.id}
style={cellWidth(column.width)}

View File

@ -1,12 +1,14 @@
// @flow
import loadAddressBookFromStorage from '~/logic/addressBook/store/actions/loadAddressBookFromStorage'
import { updateAddressBookEntry } from '~/logic/addressBook/store/actions/updateAddressBookEntry'
import fetchCollectibles from '~/logic/collectibles/store/actions/fetchCollectibles'
import fetchCurrencyValues from '~/logic/currencyValues/store/actions/fetchCurrencyValues'
import addViewedSafe from '~/logic/currentSession/store/actions/addViewedSafe'
import activateTokensByBalance from '~/logic/tokens/store/actions/activateTokensByBalance'
import fetchTokens from '~/logic/tokens/store/actions/fetchTokens'
import createTransaction from '~/routes/safe/store/actions/createTransaction'
import fetchEtherBalance from '~/routes/safe/store/actions/fetchEtherBalance'
import fetchLatestMasterContractVersion from '~/routes/safe/store/actions/fetchLatestMasterContractVersion'
import fetchSafe, { checkAndUpdateSafe } from '~/routes/safe/store/actions/fetchSafe'
import fetchTokenBalances from '~/routes/safe/store/actions/fetchTokenBalances'
import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
@ -19,9 +21,11 @@ export type Actions = {
createTransaction: typeof createTransaction,
fetchTransactions: typeof fetchTransactions,
updateSafe: typeof updateSafe,
fetchCollectibles: typeof fetchCollectibles,
fetchTokens: typeof fetchTokens,
processTransaction: typeof processTransaction,
fetchEtherBalance: typeof fetchEtherBalance,
fetchLatestMasterContractVersion: typeof fetchLatestMasterContractVersion,
activateTokensByBalance: typeof activateTokensByBalance,
checkAndUpdateSafeOwners: typeof checkAndUpdateSafe,
fetchCurrencyValues: typeof fetchCurrencyValues,
@ -35,11 +39,13 @@ export default {
fetchTokenBalances,
createTransaction,
processTransaction,
fetchCollectibles,
fetchTokens,
fetchTransactions,
activateTokensByBalance,
updateSafe,
fetchEtherBalance,
fetchLatestMasterContractVersion,
fetchCurrencyValues,
checkAndUpdateSafeOwners: checkAndUpdateSafe,
loadAddressBook: loadAddressBookFromStorage,

View File

@ -34,11 +34,15 @@ class SafeView extends React.Component<Props, State> {
intervalId: IntervalID
longIntervalId: IntervalID
componentDidMount() {
const {
activeTokens,
addViewedSafe,
fetchCollectibles,
fetchCurrencyValues,
fetchLatestMasterContractVersion,
fetchSafe,
fetchTokenBalances,
fetchTokens,
@ -47,11 +51,14 @@ class SafeView extends React.Component<Props, State> {
safeUrl,
} = this.props
fetchSafe(safeUrl).then(() => {
// The safe needs to be loaded before fetching the transactions
fetchTransactions(safeUrl)
addViewedSafe(safeUrl)
})
fetchLatestMasterContractVersion()
.then(() => fetchSafe(safeUrl))
.then(() => {
// The safe needs to be loaded before fetching the transactions
fetchTransactions(safeUrl)
addViewedSafe(safeUrl)
fetchCollectibles()
})
fetchTokenBalances(safeUrl, activeTokens)
// fetch tokens there to get symbols for tokens in TXs list
fetchTokens()
@ -61,6 +68,10 @@ class SafeView extends React.Component<Props, State> {
this.intervalId = setInterval(() => {
this.checkForUpdates()
}, TIMEOUT)
this.longIntervalId = setInterval(() => {
fetchCollectibles()
}, TIMEOUT * 3)
}
componentDidUpdate(prevProps) {
@ -78,6 +89,7 @@ class SafeView extends React.Component<Props, State> {
componentWillUnmount() {
clearInterval(this.intervalId)
clearInterval(this.longIntervalId)
}
onShow = (action: Action) => () => {

View File

@ -63,6 +63,10 @@ const getTxStatus = (tx: Transaction, userAddress: string, safe: Safe): Transact
txStatus = !userConfirmed && userIsSafeOwner ? 'awaiting_your_confirmation' : 'awaiting_confirmations'
}
if (tx.isSuccessful === false) {
txStatus = 'failed'
}
return txStatus
}
@ -98,12 +102,12 @@ export const extendedSafeTokensSelector: Selector<GlobalState, RouterProps, List
map.set(tokenAddress, baseToken.set('balance', tokenBalance || '0'))
}
})
if (ethAsToken) {
map.set(ethAsToken.address, ethAsToken)
}
})
if (ethAsToken) {
return extendedTokens.set(ethAsToken.address, ethAsToken).toList()
}
return extendedTokens.toList()
},
)

View File

@ -28,7 +28,7 @@ type CreateTransactionArgs = {
to: string,
valueInWei: string,
txData: string,
notifiedTransaction: NotifiedTransaction,
notifiedTransaction: $Values<NotifiedTransaction>,
enqueueSnackbar: Function,
closeSnackbar: Function,
txNonce?: number,
@ -65,7 +65,7 @@ const createTransaction = ({
const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance)
const isExecution = await shouldExecuteTransaction(safeInstance, nonce, lastTx)
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
// https://docs.gnosis.io/safe/docs/docs5/#pre-validated-signatures
const sigs = `0x000000000000000000000000${from.replace(
'0x',
'',

View File

@ -0,0 +1,14 @@
// @flow
import type { Dispatch as ReduxDispatch } from 'redux'
import { getCurrentMasterContractLastVersion } from '~/logic/safe/utils/safeVersion'
import setLatestMasterContractVersion from '~/routes/safe/store/actions/setLatestMasterContractVersion'
import { type GlobalState } from '~/store'
const fetchLatestMasterContractVersion = () => async (dispatch: ReduxDispatch<GlobalState>) => {
const latestVersion = await getCurrentMasterContractLastVersion()
dispatch(setLatestMasterContractVersion(latestVersion))
}
export default fetchLatestMasterContractVersion

View File

@ -4,6 +4,7 @@ import type { Dispatch as ReduxDispatch } from 'redux'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { getLocalSafe, getSafeName } from '~/logic/safe/utils'
import { enabledFeatures, safeNeedsUpdate } from '~/logic/safe/utils/safeVersion'
import { sameAddress } from '~/logic/wallets/ethAddresses'
import { getBalanceInEtherOf, getWeb3 } from '~/logic/wallets/getWeb3'
import addSafe from '~/routes/safe/store/actions/addSafe'
@ -34,7 +35,7 @@ const buildOwnersFrom = (
})
})
export const buildSafe = async (safeAdd: string, safeName: string) => {
export const buildSafe = async (safeAdd: string, safeName: string, latestMasterContractVersion: string) => {
const safeAddress = getWeb3().utils.toChecksumAddress(safeAdd)
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
const ethBalance = await getBalanceInEtherOf(safeAddress)
@ -42,6 +43,9 @@ export const buildSafe = async (safeAdd: string, safeName: string) => {
const threshold = Number(await gnosisSafe.getThreshold())
const nonce = Number(await gnosisSafe.nonce())
const owners = List(buildOwnersFrom(await gnosisSafe.getOwners(), await getLocalSafe(safeAddress)))
const currentVersion = await gnosisSafe.VERSION()
const needsUpdate = await safeNeedsUpdate(currentVersion, latestMasterContractVersion)
const featuresEnabled = enabledFeatures(currentVersion)
const safe: SafeProps = {
address: safeAddress,
@ -50,6 +54,9 @@ export const buildSafe = async (safeAdd: string, safeName: string) => {
owners,
ethBalance,
nonce,
currentVersion,
needsUpdate,
featuresEnabled,
}
return safe
@ -93,11 +100,12 @@ export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: ReduxDis
}
// eslint-disable-next-line consistent-return
export default (safeAdd: string) => async (dispatch: ReduxDispatch<GlobalState>) => {
export default (safeAdd: string) => async (dispatch: ReduxDispatch<GlobalState>, getState: () => GlobalState) => {
try {
const safeAddress = getWeb3().utils.toChecksumAddress(safeAdd)
const safeName = (await getSafeName(safeAddress)) || 'LOADED SAFE'
const safeProps: SafeProps = await buildSafe(safeAddress, safeName)
const latestMasterContractVersion = getState().safes.get('latestMasterContractVersion')
const safeProps: SafeProps = await buildSafe(safeAddress, safeName, latestMasterContractVersion)
dispatch(addSafe(safeProps))
} catch (err) {

View File

@ -52,6 +52,7 @@ type TxServiceModel = {
executionDate: ?string,
confirmations: ConfirmationServiceModel[],
isExecuted: boolean,
isSuccessful: boolean,
transactionHash: ?string,
creationTx?: boolean,
}
@ -90,10 +91,13 @@ export const buildTransactionFrom = async (safeAddress: string, tx: TxServiceMod
)
const modifySettingsTx = sameAddress(tx.to, safeAddress) && Number(tx.value) === 0 && !!tx.data
const cancellationTx = sameAddress(tx.to, safeAddress) && Number(tx.value) === 0 && !tx.data
const isSendTokenTx = isTokenTransfer(tx.data, Number(tx.value))
const code = tx.to ? await web3.eth.getCode(tx.to) : ''
const isERC721Token =
code.includes('42842e0e') || (isTokenTransfer(tx.data, Number(tx.value)) && code.includes('06fdde03'))
const isSendTokenTx = !isERC721Token && isTokenTransfer(tx.data, Number(tx.value))
const isMultiSendTx = isMultisendTransaction(tx.data, Number(tx.value))
const isUpgradeTx = isMultiSendTx && isUpgradeTransaction(tx.data)
const customTx = !sameAddress(tx.to, safeAddress) && !!tx.data && !isSendTokenTx && !isUpgradeTx
const customTx = !sameAddress(tx.to, safeAddress) && !!tx.data && !isSendTokenTx && !isUpgradeTx && !isERC721Token
let refundParams = null
if (tx.gasPrice > 0) {
@ -163,6 +167,7 @@ export const buildTransactionFrom = async (safeAddress: string, tx: TxServiceMod
refundReceiver: tx.refundReceiver,
refundParams,
isExecuted: tx.isExecuted,
isSuccessful: tx.isSuccessful,
submissionDate: tx.submissionDate,
executor: tx.executor,
executionDate: tx.executionDate,

View File

@ -11,10 +11,10 @@ import { loadFromStorage } from '~/utils/storage'
const loadSafesFromStorage = () => async (dispatch: ReduxDispatch<GlobalState>) => {
try {
const safes: ?{ [string]: SafeProps } = await loadFromStorage(SAFES_KEY)
const safes: ?{ [key: string]: SafeProps } = await loadFromStorage(SAFES_KEY)
if (safes) {
Object.values(safes).forEach((safeProps: SafeProps) => {
Object.values(safes).forEach(safeProps => {
dispatch(addSafe(buildSafe(safeProps)))
})
}

View File

@ -48,7 +48,7 @@ const processTransaction = ({
const isExecution = approveAndExecute || (await shouldExecuteTransaction(safeInstance, nonce, lastTx))
let sigs = generateSignaturesFromTxConfirmations(tx.confirmations, approveAndExecute && userAddress)
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
// https://docs.gnosis.io/safe/docs/docs5/#pre-validated-signatures
if (!sigs) {
sigs = `0x000000000000000000000000${from.replace(
'0x',
@ -82,6 +82,12 @@ const processTransaction = ({
transaction = isExecution ? await getExecutionTransaction(txArgs) : await getApprovalTransaction(txArgs)
const sendParams = { from, value: 0 }
// TODO find a better solution for this in dev and production.
if (process.env.REACT_APP_ENV !== 'production') {
sendParams.gasLimit = 1000000
}
// if not set owner management tests will fail on ganache
if (process.env.NODE_ENV === 'test') {
sendParams.gas = '7000000'

View File

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

View File

@ -16,6 +16,9 @@ export type SafeProps = {
nonce: number,
latestIncomingTxBlock?: number,
recurringUser?: boolean,
currentVersion: string,
needsUpdate: boolean,
featuresEnabled: string[],
}
const SafeRecord: RecordFactory<SafeProps> = Record({
@ -30,6 +33,9 @@ const SafeRecord: RecordFactory<SafeProps> = Record({
nonce: 0,
latestIncomingTxBlock: 0,
recurringUser: undefined,
currentVersion: '',
needsUpdate: false,
featuresEnabled: [],
})
export type Safe = RecordOf<SafeProps>

View File

@ -20,6 +20,7 @@ export type TransactionStatus =
| 'awaiting_your_confirmation'
| 'awaiting_confirmations'
| 'success'
| 'failed'
| 'cancelled'
| 'awaiting_execution'
| 'pending'
@ -39,6 +40,7 @@ export type TransactionProps = {
gasToken: string,
refundReceiver: string,
isExecuted: boolean,
isSuccessful: boolean,
submissionDate: ?string,
executionDate: ?string,
symbol: string,
@ -75,6 +77,7 @@ export const makeTransaction: RecordFactory<TransactionProps> = Record({
gasToken: ZERO_ADDRESS,
refundReceiver: ZERO_ADDRESS,
isExecuted: false,
isSuccessful: true,
submissionDate: '',
executor: '',
executionDate: '',

View File

@ -11,9 +11,10 @@ import { REMOVE_SAFE } from '~/routes/safe/store/actions/removeSafe'
import { REMOVE_SAFE_OWNER } from '~/routes/safe/store/actions/removeSafeOwner'
import { REPLACE_SAFE_OWNER } from '~/routes/safe/store/actions/replaceSafeOwner'
import { SET_DEFAULT_SAFE } from '~/routes/safe/store/actions/setDefaultSafe'
import { SET_LATEST_MASTER_CONTRACT_VERSION } from '~/routes/safe/store/actions/setLatestMasterContractVersion'
import { UPDATE_SAFE } from '~/routes/safe/store/actions/updateSafe'
import { UPDATE_SAFE_THRESHOLD } from '~/routes/safe/store/actions/updateSafeThreshold'
import { type OwnerProps, makeOwner } from '~/routes/safe/store/models/owner'
import { makeOwner } from '~/routes/safe/store/models/owner'
import SafeRecord, { type SafeProps } from '~/routes/safe/store/models/safe'
export const SAFE_REDUCER_ID = 'safes'
@ -21,11 +22,8 @@ export const SAFE_REDUCER_ID = 'safes'
export type SafeReducerState = Map<string, *>
export const buildSafe = (storedSafe: SafeProps) => {
const names = storedSafe.owners.map((owner: OwnerProps) => owner.name)
const addresses = storedSafe.owners.map((owner: OwnerProps) => {
const checksumed = getWeb3().utils.toChecksumAddress(owner.address)
return checksumed
})
const names = storedSafe.owners.map(owner => owner.name)
const addresses = storedSafe.owners.map(owner => getWeb3().utils.toChecksumAddress(owner.address))
const owners = buildOwnersFrom(Array.from(names), Array.from(addresses))
const activeTokens = Set(storedSafe.activeTokens)
const blacklistedTokens = Set(storedSafe.blacklistedTokens)
@ -53,7 +51,7 @@ export default handleActions<SafeReducerState, *>(
[ACTIVATE_TOKEN_FOR_ALL_SAFES]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => {
const tokenAddress = action.payload
const newState = state.withMutations(map => {
return state.withMutations(map => {
map
.get('safes')
.keySeq()
@ -64,8 +62,6 @@ export default handleActions<SafeReducerState, *>(
map.updateIn(['safes', safeAddress], prevSafe => prevSafe.merge({ activeTokens }))
})
})
return newState
},
[ADD_SAFE]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => {
const { safe }: { safe: SafeProps } = action.payload
@ -132,10 +128,14 @@ export default handleActions<SafeReducerState, *>(
},
[SET_DEFAULT_SAFE]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState =>
state.set('defaultSafe', action.payload),
[SET_LATEST_MASTER_CONTRACT_VERSION]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState =>
state.set('latestMasterContractVersion', action.payload),
},
Map({
// $FlowFixMe
defaultSafe: undefined,
safes: Map(),
// $FlowFixMe
latestMasterContractVersion: '',
}),
)

View File

@ -52,6 +52,14 @@ export const defaultSafeSelector: OutputSelector<GlobalState, {}, string> = crea
(safeState: Map<string, *>): string => safeState.get('defaultSafe'),
)
export const latestMasterContractVersionSelector: OutputSelector<
GlobalState,
{},
string,
> = createSelector(safesStateSelector, (safeState: Map<string, *>): string =>
safeState.get('latestMasterContractVersion'),
)
const transactionsSelector = (state: GlobalState): TransactionsState => state[TRANSACTIONS_REDUCER_ID]
const cancellationTransactionsSelector = (state: GlobalState): CancelTransactionsState =>

View File

@ -6,6 +6,14 @@ import thunk from 'redux-thunk'
import addressBookMiddleware from '~/logic/addressBook/store/middleware/addressBookMiddleware'
import addressBook, { ADDRESS_BOOK_REDUCER_ID } from '~/logic/addressBook/store/reducer/addressBook'
import {
type NFTAssetsState,
type NFTTokensState,
NFT_ASSETS_REDUCER_ID,
NFT_TOKENS_REDUCER_ID,
nftAssetReducer,
nftTokensReducer,
} from '~/logic/collectibles/store/reducer/collectibles'
import cookies, { COOKIES_REDUCER_ID } from '~/logic/cookies/store/reducer/cookies'
import currencyValues, { CURRENCY_VALUES_KEY } from '~/logic/currencyValues/store/reducer/currencyValues'
import currentSession, {
@ -54,6 +62,8 @@ export type GlobalState = {
providers: ProviderState,
router: RouterHistory,
safes: SafeState,
nftAssets: NFTAssetsState,
nftTokens: NFTTokensState,
tokens: TokensState,
transactions: TransactionsState,
cancellationTransactions: CancelTransactionsState,
@ -68,6 +78,8 @@ const reducers: CombinedReducer<GlobalState, *> = combineReducers({
router: connectRouter(history),
[PROVIDER_REDUCER_ID]: provider,
[SAFE_REDUCER_ID]: safe,
[NFT_ASSETS_REDUCER_ID]: nftAssetReducer,
[NFT_TOKENS_REDUCER_ID]: nftTokensReducer,
[TOKEN_REDUCER_ID]: tokens,
[TRANSACTIONS_REDUCER_ID]: transactions,
[CANCELLATION_TRANSACTIONS_REDUCER_ID]: cancellationTransactions,

View File

@ -309,6 +309,16 @@ const theme = createMuiTheme({
},
},
},
MuiTableContainer: {
root: {
marginLeft: '-10px',
marginRight: '-10px',
marginTop: '-10px',
paddingLeft: '10px',
paddingRight: '10px',
paddingTop: '10px',
},
},
MuiTablePagination: {
toolbar: {
paddingRight: '15px',

View File

@ -0,0 +1,63 @@
import { textShortener } from '~/utils/strings'
describe('Utils > strings > textShortener', () => {
it(`should return the original string if there's no room to shorten`, () => {
// Given
const text = "I'm a short string"
const shortener = textShortener() // default values
// When
const shortenText = shortener(text)
// Then
expect(shortenText).toEqual(text)
})
it(`should return a shorter version with the default ellipsis`, () => {
// Given
const text = "I'm a short string" // 18 chars long
const shortener = textShortener({ charsStart: 2, charsEnd: 2 })
// When
const shortenText = shortener(text)
// Then
expect(shortenText).toEqual("I'...ng")
})
it(`should return a shorter version with the '---' ellipsis`, () => {
// Given
const text = "I'm a short string" // 18 chars long
const shortener = textShortener({ charsStart: 2, charsEnd: 2, ellipsis: '---' })
// When
const shortenText = shortener(text)
// Then
expect(shortenText).toEqual("I'---ng")
})
it(`should return a shorter version with only the start of the string`, () => {
// Given
const text = "I'm a short string" // 18 chars long
const shortener = textShortener({ charsStart: 2, charsEnd: 0 })
// When
const shortenText = shortener(text)
// Then
expect(shortenText).toEqual("I'...")
})
it(`should return a shorter version with only the end of the string`, () => {
// Given
const text = "I'm a short string" // 18 chars long
const shortener = textShortener({ charsStart: 0, charsEnd: 2 })
// When
const shortenText = shortener(text)
// Then
expect(shortenText).toEqual('...ng')
})
})

13
src/utils/constants.js Normal file
View File

@ -0,0 +1,13 @@
// @flow
export const NETWORK = process.env.REACT_APP_NETWORK
export const GOOGLE_ANALYTICS_ID_RINKEBY = process.env.REACT_APP_GOOGLE_ANALYTICS_ID_RINKEBY
export const GOOGLE_ANALYTICS_ID_MAINNET = process.env.REACT_APP_GOOGLE_ANALYTICS_ID_MAINNET
export const INTERCOM_ID = process.env.REACT_APP_INTERCOM_ID
export const PORTIS_ID = process.env.REACT_APP_PORTIS_ID
export const SQUARELINK_ID = process.env.REACT_APP_SQUARELINK_ID
export const FORTMATIC_KEY = process.env.REACT_APP_FORTMATIC_KEY
export const INFURA_TOKEN = process.env.REACT_APP_INFURA_TOKEN || ''
export const LATEST_SAFE_VERSION = process.env.REACT_APP_LATEST_SAFE_VERSION || 'not-defined'
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 COLLECTIBLES_SOURCE = process.env.REACT_APP_COLLECTIBLES_SOURCE || 'OpenSea'

54
src/utils/strings.js Normal file
View File

@ -0,0 +1,54 @@
// @flow
type ShortenTextOptionsProps = {
charsStart?: number,
charsEnd?: number,
ellipsis?: string,
}
/**
* Setups `shortenText` options
* @param {object} opts
* @param {number} opts.charsStart=10 - characters to preserve from the beginning
* @param {number} opts.charsEnd=10 - characters to preserve at the end
* @param {string} opts.ellipsis='...' - ellipsis characters
* @returns {function} shortener
*/
export const textShortener = ({ charsEnd = 10, charsStart = 10, ellipsis = '...' }: ShortenTextOptionsProps = {}) =>
/**
* @function
* @name shortener
*
* Shortens a text string based on options
* @param {string} text=null - String to shorten
* @param text
* @returns {string|?string}
*/
(text: ?string = null) => {
if (typeof text !== 'string') {
throw new TypeError(` A string is required. ${typeof text} was provided instead.`)
}
if (!text) {
return ''
}
const amountOfCharsToKeep = charsEnd + charsStart
const finalStringLength = amountOfCharsToKeep + ellipsis.length
if (finalStringLength >= text.length || !amountOfCharsToKeep) {
// no need to shorten
return text
}
const r = new RegExp(`^(.{${charsStart}}).+(.{${charsEnd}})$`)
const matchResult = r.exec(text)
if (!matchResult) {
// if for any reason the exec returns null, the text remains untouched
return text
}
const [, textStart, textEnd] = matchResult
return `${textStart}${ellipsis}${textEnd}`
}

896
yarn.lock

File diff suppressed because it is too large Load Diff